真正“搞”懂HTTP协议06之body的玩法(实践篇)
我真没想到这篇文章竟然写了将近一个月,一方面我在写这篇文章的时候阳了,所以将近有两周没干活,另外一方面,我发现在写基于Node的HTTP的demo的时候,我不会Node,所以我又要一边学学Node,一边百度,一边看HTTP,最后百度的东西百分之九十不能用,所以某些点就卡的我特别难受。
比如最后的分段传输的例子,我以为是浏览器会解析分段数据,谁知道是拼接在body里的。
其次,我还觉得是否这样去详细的逐字的写例子是不是有点本末倒置,本来是讲HTTP的,结果全是一些例子。但是我又觉得不这么写,你就知道点概念,没有弄清楚具体某些字段的交互和使用,跟没学好像也没多大区别。
我还是拿分段传输来举例子,我不写出来,你知道它是在body里的么?
所以,后续,反正我想咋写就咋写吧,不去纠结这些,啦啦啦啦~
以下是正文。
话说上一篇文章真的有些无聊,全是理论,一点意思都没有,我写的都要睡着了。不过这一篇我希望你可以跟我一起来玩一玩,并且这一篇文章所实现的一些例子还是有一定的实践价值的。比如断点续传?比如不听话的服务器。
我们就按照上一篇理论篇的顺序,来实现我们的具体的例子。
一、基本代码实现
我们先来回顾一下之前写过的一个最简单的例子,html和js服务代码如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>content-type</title> </head> <body> 可以了 </body> </html>
然后是server.js:
const http = require("http"); const fs = require("fs"); const path = require("path"); const hostname = "127.0.0.1"; const port = 9000; const server = http.createServer((req, res) => { let sourceCode = fs.readFileSync( path.resolve(__dirname, "./index.html"), "utf8" ); res.end(sourceCode); }); server.listen(port, hostname, () => { console.log(`Server running at http://${hostname}:${port}/`); });
我们的代码很简单,就不解释了哈,我们直接来看请求的结果:
这是我们打开我们在hosts文件中修改的域名,以及在node服务中设置监听的端口号后,发出的请求及其报文内容,要强调的一点是,我们目前在代码层面没有添加任何头字段的内容,无论是客户端还是服务器。
我相信这张图你一定可以看懂至少四个字段。我们发现其实浏览器和服务器默认给我们进行了一些头字段的设置,比如请求头中的Accept、Accept-Encoding和Accept-Language,响应头中的Content-Length等等。这些默认设置其实是固定的,或者说是根据系统环境固定了一些默认设置,当然,这个我是猜的,因为它跟HTTP标准就没啥关系了,这是浏览器或者Node的实现层面的事情了,我们就不过多的涉猎了。
然后,我们稍微修改一下媒体的类型,我在当前的代码下增加了一个media文件夹,里面放了几个类型的文件,然后我们什么都不用干,直接修改路径地址就好,试一试返回是什么样的。大家可以在当前的场景下自行尝试。其中文本类型的文件,都可以直接显示在页面上,但是媒体类型的就不行了,比如图片,仅用当前的代码,浏览器是无法正确的解析的。这部分的代码我放在了content-type-01目录下。
我们继续噢,上面的简单的小例子仅仅是使用了浏览器和Node服务器的一些默认能力,现在我们尝试在页面中手动发起一个ajax请求,来获取服务器的返回,并在此基础上,加以额外的尝试。
server.js的代码是这样的:
const http = require("http"); const fs = require("fs"); const path = require("path"); const { URL } = require("url"); const hostname = "127.0.0.1"; const port = 9000; const server = http.createServer((req, res) => { const parsedUrl = new URL(req.url, "http://www.zaking.com"); // 浏览器icon,浏览器会默认请求,如果是这个的话,直接返回个200好了。 // 或者你可以自己尝试返回一个icon,啊哈哈 if (parsedUrl.pathname == "/favicon.ico") { res.writeHead(200); res.end(); return; } // 返回静态html文件 if (parsedUrl.pathname == "/home") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "./index.html"), "utf8" ); res.end(sourceCode); } // 返回静态json资源 if (parsedUrl.pathname == "/api") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "../media/web.json"), "utf8" ); res.end(sourceCode); } }); server.listen(port, hostname, () => { console.log(`Server running at http://${hostname}:${port}/`); });
我们来看这段代码,和之前的例子稍稍有些区别,在这个例子里,我并没有分别创建静态html和被请求接口的独立的服务器,而是把静态html和被请求接口放在了同一个端口和服务下,为啥要这样做呢?因为我不想解决跨域问题。
另外,其实这样的写法和实现在服务器实践中很常见,比如,你可以看看你现在自己的手中正在开发的项目,外网访问地址是https://www.example.com,而接口地址则是https://www.example.com/api/yourpath这样。那么基本上就是基于这样的思路实现的,只不过或许是不同的语言,比如JAVA,或许用了某一个类库,比如express。
好啦,我们解释下上面的代码,很简单,我觉得你大致肯定是可以看懂的。我们新增了一个url模块,这个模块从名字就知道是用来做url解析的。然后呢,我们通过解析request也就是请求的url来获取到一些数据。
然后呢,如果请求的icon,那就直接返回个200就好了,这个不重要,就是稍微处理下。其实你不写也是可以的。
再然后,如果请求的是/home这个path路径,则会去读取静态的html文件,如果是/api这个路径,则会读取一个静态的json文件并返回。当然,这个路径的判断你可以随便写~
那么,我们来稍稍修改下html的代码,我希望可以点击一下按钮,请求我们提供的接口的这个/api路径。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>content-type</title> </head> <body> <button id="btn">点我试试</button> </body> <script> const btnDom = document.getElementById("btn"); function requestFn() { const xhr = new XMLHttpRequest(); const url = "http://www.zaking.com:9000/api"; xhr.open("GET", url); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { console.log(xhr); console.log(xhr.responseText); } }; xhr.send(); } btnDom.addEventListener("click", requestFn); </script> </html>
其实就是之前的例子,没有区别,然后我们可以启动服务node youfilepath,点击按钮,你就可以看到请求结果了。一点问题没有~。大家稍微注意观察下头字段的变化,了解下就行了。
到目前为止我们讲清楚了怎么用Node搭建简单的测试环境,都还没怎么涉及到HTTP的内容,别急,马上就来了。
二、玩一玩数据类型
这一篇啊,我们就不传JSON、HTML、TXT啥的这种文件了,咱们来玩点复杂的,看看图片和视频、Excel要怎么玩。
一、图片的玩法
在实践中,我们差不多有那么几种获取和使用图片的方式,嗯……大概可以分为两种吧,一种是后端提供一个远程的服务器的图片的地址,我们通过img标签直接访问就好了,另外一种就是像请求接口那样,获取图片的body,然后通过Blob或者其它类似手段生成本地的地址来访问。我们先来看看简单的,访问一个远程图片地址的情况。
我们现在index.html中加上点这样的代码:
<br /> <img src="http://www.zaking.com:9000/img" alt="" style="width: 100px; height: 100px" />
然后,服务器的代码是这样的:
if (parsedUrl.pathname == "/img") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "../media/puppy.jpeg") ); console.log(sourceCode, "sourceCode"); res.end(sourceCode); }
重新启动服务后,你会发现,请求成功了:
你会发现,其实我们也没做什么复杂的事情,就是读取后返回,去掉了读取文件时的utf8编码,当然,如果你友善一点,可以加一点代码:
res.setHeader("Content-Type", "image/jpeg");
友好的告诉客户端,我传给你个图片哦,你看着办哦。
到这里,我还有个问题,大家在工作中,遇没遇到这种,比如图片的地址是https://www.baidu.com/aaa.jpg,和我们这个例子中有什么区别呢?其实本质来说都是一样的,只不过,https://www.baidu.com/aaa.jpg这种,实际上访问的是服务器上的静态资源,没有经过服务器的代码处理,直接访问就好了。
而我们的例子,实际上你请求的是服务器的接口,你需要通过服务器读取图片后再返回给你,这是两者细微的区别噢。下面我们就看看如何返回个图片流(其实就是二进制数据啦),然后通过前端代码解析成一个本地地址。我们先来看后端代码咋写的:
if (parsedUrl.pathname == "/stream-img") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "../media/puppy.jpeg") ); const streamData = Buffer.from(sourceCode); // res.setHeader("Content-Type", "application/octet-stream"); res.end(streamData); }
我们看这段代码,只多了两行,一行是通过Buffer.from方法把获取到的图片文件转换成二进制,然后,注释的部分,实际上是告诉浏览器你要按照二进制来解析,不然的话,其实浏览器还是会按照图片来解析,你拿到的就是图片。当然,这么说其实不太“准确”,因为无论是什么形式,什么数据类型,本质上来说,它都是一个“图片”,只不过这个“图片”的数据类型是什么可能会有所区别,所以,哪怕你传输的是二进制,但是你要是不告诉浏览器它的数据类型的话,还是会按照图片来解析,也就是,返回的body看起来是这样的:
当我们把响应头中的Content-Type设置好,返回的body则会像下面这样:
是不是很熟悉的乱码,然后,我们就可以通过前端JS代码,来解析这段二进制的数据了:
// html <button id="streamImgBtn">点我显示流图片</button> // js const streamImgBtnDom = document.getElementById("streamImgBtn"); streamImgBtnDom.addEventListener("click", requestStreamImgFn); function requestStreamImgFn() { const xhr = new XMLHttpRequest(); const url = "http://www.zaking.com:9000/stream-img"; xhr.responseType = "arraybuffer"; xhr.open("GET", url); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { const result = xhr.response; const blobData = new Blob([result]); const blobSrc = URL.createObjectURL(blobData); const img = document.createElement("img"); img.src = blobSrc; document.body.appendChild(img); } }; xhr.send(); }
整个代码并不复杂,点击一下按钮就可以出现预料中的结果。但是尤其要注意加粗的那一块代码,虽然你的服务器返回和浏览器解析都是按照二进制来的,但是xhr对象并不知道,否则会按照文本来处理,所以需要设置一下responseType。
好啦,关于图片的部分,我们暂时告一段落咯。接下来我们简单看看Excel文件,其实本质上来说都是一样的。不同的就是Content-Type的类型。我们稍微试一下,尽量少花点篇幅,把重头戏留给视频那部分。
二、Excel要这么玩
服务器端的代码是这样的:
if (parsedUrl.pathname == "/excel") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "../media/test.xlsx") ); res.setHeader( "Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ); res.end(sourceCode); } if (parsedUrl.pathname == "/stream-excel") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "../media/test.xlsx") ); const streamData = Buffer.from(sourceCode); res.setHeader( "Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ); res.end(streamData); }
就其实很简单,唯一注意的就是返回的Content-Type的类型,其他的跟图片其实一模一样。然后客户端请求的代码也是一样的,我就不贴了,当然,这里没法在浏览器查看Excel,需要额外的插件支持,这里就不多说了,毕竟这不是重点。
三、重要的视频处理
简单的传输方式其实对于视频来说也是可以的,我在示例代码中也写了这一部分,不再在这里无意义的重复了。我们先来看看分块传输是怎么玩的。
一)基于NodeJs实现视频的分块传输
废话不多说,咱们直接上代码,哦对这是服务器的代码:
if (parsedUrl.pathname == "/video-chunked") { let sourceCode = fs.readFileSync( path.resolve(__dirname, "../media/maomao.mp4") ); const bufSource = Buffer.from(sourceCode); res.setHeader("Content-Type", "video/mp4"); res.setHeader("Transfer-Encoding", "chunked"); const chunkSize = 1024; const chunks = []; for (let i = 0; i < bufSource.length; i += chunkSize) { chunks.push(Uint8Array.prototype.slice.call(bufSource, i, i + chunkSize)); } console.log(chunks, "chunks"); for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; res.write(chunk); } res.end(); }
我们来看这段代码,信息量有点大,而且有点有趣(当然我并不知道为啥会这么有趣,但是就是有趣)。
首先我要强调的一点是,Transfer-Encoding: chunked的设置不是默认开启的,你要手动,而且还要匹配你的数据块,否则就会发生有趣的事情。
然后,我们看代码,首先我们按照每一个块是1024字节来拆分,最后有多少块我不管,我们来循环整个chunks数组,通过response.write写到响应体里,最后结束这次实验。我们无法直接操作源文件并slice,所以我们需要先把源文件转换成Buffer,再去通过Uint8Array原型上的slice方法来拆分。
OK,代码我们简单的解释完了,我们可以在index.html中添加一点代码:
<body> <video controls width="250"> <source src="http://www.zaking.com:9000/video" type="video/mp4" /> </video> <video controls width="250"> <source src="http://www.zaking.com:9000/video-chunked" type="video/mp4" /> </video> </body>
第二个就是我们新的地址。然后,我们启动服务,打开页面:
注意看我们红框的地方,当我们用Transfer-Encoding: chunked的时候前后两个视频加载的细微对比,并且,你可以点击开始按钮,你会发现它的加载速度是不一样的,第一个视频,基本上一下子就满了,而第二个则是一点一点一点一点的加载。
那这样就算是chunked成功了么?我们来看下:
理论上讲,这样确实是成功了,并且我们还从侧面进一步验证,但是,我不想从侧面,我想正面验证一下不行么?好吧,满足你的小小愿望。但是为了满足你的这个愿望,我们需要额外的工具,也就是WireShark,或者你会使用其他的抓包工具也可以,我们现在在这里, 就使用WireShark来抓包看下哦。
首先,进入界面后点击下面红框的loopback:
就是回环的意思,大概是说你的本地电脑即作为服务器又作为客户端,自己玩,就点这个就行了,然后进去后你会发现咔咔咔咔一顿跳各种请求,嗯,是你电脑里各种软件的请求信息,那咋整呢?
在过滤栏里输入这样的过滤条件,你会发现世界都安静了,好舒服~然后呢,我们刷新下刚刚的页面,哦抱歉,你还不能这样做,不过你可以先这样试下。
好吧~接下来我们再写一个小服务吧,文件名叫做video/client.js:
const http = require("http"); const options = { hostname: "www.zaking.com", port: 9000, path: "/video-chunked", method: "GET", }; const req = http.request(options, (res) => { // console.log(res, "res"); console.log(`STATUS: ${res.statusCode}`); console.log(`HEADERS: ${JSON.stringify(res.headers)}`); res.on("data", (chunk) => { console.log(`BODY: ${chunk}`); }); res.on("end", () => { console.log("No more data in response."); }); }); req.on("error", (e) => { console.error(`problem with request: ${e.message}`); }); req.end();
很简单,这个例子咱们之前也用过,稍微的改造了下,我们在命令行工具中启动一下即可:
node 06/video/client.js
然后,我们切回WireShark,内容很多,我们不管他都是啥,我们找到这个带路径的HTTP信息:
然后点击一下,再把滚动条往后面拽,使劲拽,拽到底:
然后我们就可以看到这条,你发现这俩是一对,咋发现的呢?通过箭头发现的,一去一回~,然后我们点击它,可以看到它的详细信息:
好大啊,我看个毛?别急,把Hypertext Transfer Protocol打开:
再打开HTTP chunked response:
看到这,我们是不是就可以完全确定我们设置的chunked生效了?没毛病吧~完美~~~但是呢~还没完,我们再打开其中一个块:
注意哦,你现在可以手动自己去打开每一个块,你会发现,每一个块都有这样的编码:
并且它在第一个块就有一个这玩意,然后最后一个块是这样的:
好吧,恭喜你,发现了Transfer-encoding: chunked的核心内容,这里稍微涉及点理论知识,下面我们根据我们的实际操作,来补全一下这部分理论。
二)分块传输的数据格式
分块传输也是采用明文的方式,主要分为两部分,长度头和数据块,长度头呢是以CRLF(回车换行,即\r\n)结尾的一行明文,用16进制数字表示块的长度,数据块紧跟在长度头后,最后也用 CRLF 结尾,但数据不包含 CRLF;最后用一个长度为 0 的块表示结束,即“0\r\n\r\n”。
诶?是不是跟我们刚才看到的对上了,那个400是16进制的长度,我算算,400的16进制转成10进制是不是1024:
好像,有点完美啊~~环环相扣,丝毫不漏。哈哈哈哈~
然后,我们可以再来个图示:
没问题吧,嗯……分块传输就基本上完事了,大家可以试试这些实际的例子。
哦对了,我还忘了一个我在开始的时候说的有趣的事情,就是如果你把chunkSize设置的很大,比如1024*1024,抓包的时候会是什么样呢?你可以自己试下。你会发现它并没有按照chunked形式传递。至于为啥,我猜是因为你的块分的太大,实现的部分就不再视为chunked了,当然,这个是我猜的,我也不知道为啥。
哦对,我还在代码里附上了wireshark的快照,用wireshark打开就可以回溯上面例子了。
三)范围请求可以这样玩
我们稍微回到用html来请求分块传输的视频的那个例子,假设你在跟着我玩这个游戏,不知道你在那个例子的时候是否拖拽了一下进度条?那你是否发现怎么拖好像都没效果~,没实现肯定没效果。
再有,不知道你是否细心的看到了这个东东:
你看到,实际上在使用chunked的时候,请求头中已经加了Range字段,并且默认是获取所有从0开始到最后,下面,我们就来看看如何实现这个范围请求。
1)简单的范围请求
很简单,我们来直接看代码咯,首先是发起请求的html按钮,跟之前一样:
<body> <button id="simpleRangeBtn">发起这个视频的简单的范围请求</button> </body> <script> const simpleRangeBtn = document.getElementById("simpleRangeBtn"); simpleRangeBtn.addEventListener("click", simpleRangeRequestFn); function simpleRangeRequestFn() { const xhr = new XMLHttpRequest(); const url = "http://www.zaking.com:9000/simple-range"; xhr.open("GET", url); xhr.setRequestHeader("Range", "bytes=0-2048"); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) { console.log(xhr); console.log(xhr.responseText); const result = xhr.responseText; console.log(result.name); } }; xhr.send(); } </script>
唯一的区别是我加了Range的请求头,请求从0到2048字节的视频数据。然后,服务器端是这样的:
if (parsedUrl.pathname == "/simple-range") { const range = req.headers["range"]; console.log(range); res.setHeader("Accept-Ranges", "bytes"); res.end("zaking"); }
诶?你这写的不对吧?你这怎么就返回个字符串?嗯……我强调过不止一遍,客户端和服务器使用HTTP通信的作用是协商,协商的结果是服务器给的,服务器不一定会按照你客户端期望的那样返回给你预期的结果,所以,其实服务器是不那么听话的。但是,HTTP是一份协议,协议的目的就是在约定的范围内,你最好听话,不然我玩什么?好吧,上面仅仅是个小例子,为了进一步说明啥是协商。
其实接下来的事情就很简单了,获取视频数据然后再截取请求的范围的长度即可,下面我们就按照协议的要求来完善这个简单的例子,让服务器返回我们期望的范围的视频数据。
OK,我们先来看完整的服务器端的代码:
if (parsedUrl.pathname == "/simple-range") { let videoSource = fs.readFileSync( path.resolve(__dirname, "../media/maomao.mp4") ); // 转换 const bufSource = Buffer.from(videoSource); // 获取长度 const bufSourceLen = bufSource.length; // 获取请求的Range头的长度范围 const range = req.headers["range"]; const rangeVal = range.split("=")[1].split("-"); // 获取开始和结束的长度 const start = parseInt(rangeVal[0], 10); const end = rangeVal[1] ? parseInt(rangeVal[1], 10) : start + bufSourceLen; console.log(start, end, bufSourceLen); // 判断是否超出请求资源的最大长度,就返回416 if (start > bufSourceLen || end > bufSourceLen) { res.writeHead(416, { "Content-Range": `bytes */${bufSourceLen}` }); res.end(); } else { // 否则返回206即可 res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${bufSourceLen}`, "Accept-Ranges": "bytes", "Content-type": "video/mp4", }); res.write(Uint8Array.prototype.slice.call(bufSource, start, end)); res.end(); } }
这是目前最复杂的代码了,我们稍微来捋一下,首先,我们获取服务器上的源文件,然后把它转换成blob并且获取到blob的长度,因为我们要校验客户端给你的Range范围是否合法,这很重要。我们会按照HTTP的Range头的格式来分割一下字符串,获取数据范围的开始和结束数据,再然后,我们根据数据的长度判断请求范围是否合法。如果不合法,那就返回个416,结束。如果合法,那么我们使用Uint8Array原型链上的方法去切分一下我们的数据并返回给客户端即可。
然后,我们看下客户端的代码:
// html <button id="simpleRangeBtn">发起这个视频的简单的范围请求</button> // js const simpleRangeBtn = document.getElementById("simpleRangeBtn"); simpleRangeBtn.addEventListener("click", simpleRangeRequestFn); function simpleRangeRequestFn() { const xhr = new XMLHttpRequest(); const url = "http://www.zaking.com:9000/simple-range"; xhr.open("GET", url); xhr.responseType = "blob"; xhr.setRequestHeader("Range", "bytes=0-2048"); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { console.log(xhr); const result = xhr.response; // 我们需要把这段二进制数据转换成视频 const blobData = new Blob([result]); const blobSrc = URL.createObjectURL(blobData); const video = document.createElement("video"); video.controls = true; video.width = "250"; video.src = blobSrc; document.body.appendChild(video); } }; xhr.send(); }
差不多这样,这整体的代码没啥好说的,我尤其要说一下的上面加粗的两部分,嗯……稍后说,我们来看看效果。
诶?看起来好像不太对,请求没问题,范围也没问题,OK的,但是为啥视频没播放呢?你猜猜呢?答案就在我加粗的两行代码里,首先,后端服务器传回的是blob文件,前端的XMLHttpRequest对象也要设置responseType为blob,这个很重要。然后,最最重要的来了,你的视频,注意,是视频,所请求的视频的范围不能太小,你可以看到Content-Range的整个文件的大小是195万2139,所以你这给个零头还不到的范围,不行,我们把范围调大一点,就100w吧,然后我们再看效果。
非常完美,但是我要强调两个细节。首先,我们请求的是范围,差不多是一半左右的视频吧,所以当开始后,后面的数据就没有了,视频也就暂停了。其次,我们发现,其实这样的前后端交互设计,就可以实现原生的进度条拖拽了。不信你可以在返回数据的范围内拖拽一下进度条试试?
那么简单的范围请求我们就搞定了~,其实也是我们最核心的部分。
2)简单范围请求的例子补全
上一个例子,我们完成了范围请求并且确切的获取到了一段视频数据并渲染了,但是后面的部分没渲染啊。这咋整?我们可以利用video对象的一些能力,来继续后续的请求。我纠结了一下,例子我写好了,在这里,大家自己自行下载到本地玩一玩吧,因为没有什么新的HTTP的内容,其实更多是偏向于文件编码的处理的一些技术细节,所以就不再在这里浪费篇幅了,这篇实践文章比我预料的要长太多了。
当然,这个例子写的只是个例子。翻译过来就是仅供参考。
我们继续把后续的一个知识点再实践一下。
四、多段数据的范围请求
关于在一个HTTP请求中请求多段数据,其实并不十分复杂,它有两个核心,一个是特殊的媒体类型multipart/byterange,另外就是分割多段数据的分隔符。我们不多废话,直接来看下代码的实现。
// 因为我懒所以没有去获取请求头拼接字符串,也没做一些判断,就这样吧。 if (parsedUrl.pathname === "/multipart-range") { const str = "1234567890"; const boundary = "split_bound"; const len = str.length; const data = [ { headers: { "Content-Range": `bytes 0-3/${len}`, "Content-Type": "text/plain", }, body: str.slice(0, 3), }, { headers: { "Content-Range": `bytes 4-6/${len}`, "Content-Type": "text/plain", }, body: str.slice(4, 6), }, ]; let body = data .map((item) => { let part = `\n--${boundary}\n`; for (const [key, value] of Object.entries(item.headers)) { part += `${key}: ${value}\n`; } part += "\n"; part += item.body; return part; }) .join(""); body += `\n--${boundary}--\n`; res.writeHead(206, { "Accept-Ranges": "bytes", "Content-type": `multipart/byteranges; boundary=${boundary}`, "Content-Length": Buffer.byteLength(body), }); res.write(body); res.end(); }
这块代码有点长,我们需要来分析一下。嗯……稍后再分析,我们先看下测试的结果,哦对了,客户端请求是这样的:
// html <button id="multipleRangeBtn">点发我发起多段数据请求</button> // js const multipleRangeBtn = document.getElementById("multipleRangeBtn"); multipleRangeBtn.addEventListener("click", multipleRangeBtnRequestFn); function multipleRangeBtnRequestFn() { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://www.zaking.com:9000/multipart-range"); xhr.setRequestHeader("Range", `bytes=0-3, 4-6`); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { // 因为我懒所以只console了一下 console.log(xhr); } }; xhr.send(); }
我们看下结果:
这里有点小瑕疵,我们不管他,我懒得再切字符串了。你发现一个问题没有,分段传输实际上传输的是整个body,我们操作的是body的数据,是由前后端手动去分辨你分了哪些段,信息都在body的数据里,而不是通过服务器或者浏览器帮你去解析分段数据返回给你。为什么会这样呢?
想象一下,浏览器怎么知道这些“段”是整体数据的哪一部分?它没法帮你做啊,所以那就都交给你们自己解决,自己商议了,那我们看这个数据结构。是HTTP协议要求这样去做的。我们看这段数据就可以理解,首先,每一段数据的开始都要有一个“--”加上服务器告诉你的分隔符是啥,在响应头里告诉你了,然后一块数据就类似一个小的http段,头部和body用\n分割,前端收到这段数据要自己通过逻辑代码去处理,最后,通过一个--加上分隔符--作为整体数据的结束。
那既然是body数据,我的理解,你可以随意设置前端需要的,或者前后端约定的分段数据内的可能的、允许的、默认的数据形式和结构,也就是说,你不一定非要返回Content-Range和Content-Type,你还可以返回其他的,甚至不返回。
嗯……看起来就是这个样子:
这就是分段数据在body中的结构,注意,我一再强调,这是约定的结构,你完全可以不按照这样来。只要前后端商议好,并且不会造成未知的副作用。
那么说了这么多,我们回头看下代码吧,其实代码很简单,就是写死了一块数据,然后形成了一个数组,最后遍历这个数据拼接上协议约定的分隔符就完事了。当然,这里我偷懒了,没有去读取请求头中的数据作为依据,而是写死的,额……这不是重点,我就偷点懒。
总结
首先,本篇文章有两件事没有事无巨细的去做,一个是我在文章开头提到的断点续传,这个东西我觉得你学完了,学会了本篇的所有例子,你一定有思路去实现断点续传,一点都不复杂,我觉得我再写的话这篇文章就太长了,本来就长的出乎我的预估,所以留作课后作业吧。
其次,还有一个没实现的例子就是基于Stream的分块传输,这个其实本质没有区别,大家有兴趣也可以自己去找一找资料,因为它其实更偏向于Node,和HTTP没有太大关系了。
最后,我们稍微回顾一下本篇文章都做了啥。我们刚开始的时候用json、img、xlsx作为例子,看看前后端的交互处理是怎样的,很简单。
然后,我们着重学习了以视频数据为例子的分块传输和范围请求。在文章的最后,我们用一个简单的例子,来实现了分段传输。
我要强调的是,大家在学习这篇文章的时候,一定要结合例子,能清楚的分辨哪些是前后端代码要做的事情,哪些是我设置了头字段客户端会处理的情况。
最后,终于结束了~
本文来自博客园,作者:Zaking,转载请注明原文链接:https://www.cnblogs.com/zaking/p/16966545.html