真正“搞”懂HTTP协议07之body的玩法(实践篇)

时间:2023-01-07 17:06:27

  我真没想到这篇文章竟然写了将近一个月,一方面我在写这篇文章的时候阳了,所以将近有两周没干活,另外一方面,我发现在写基于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}/`);
});

  我们的代码很简单,就不解释了哈,我们直接来看请求的结果:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   这是我们打开我们在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 >点我试试</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);
}

  重新启动服务后,你会发现,请求成功了:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

  你会发现,其实我们也没做什么复杂的事情,就是读取后返回,去掉了读取文件时的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看起来是这样的:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   当我们把响应头中的Content-Type设置好,返回的body则会像下面这样:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   是不是很熟悉的乱码,然后,我们就可以通过前端JS代码,来解析这段二进制的数据了:

// html
<button >点我显示流图片</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>

  第二个就是我们新的地址。然后,我们启动服务,打开页面:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   注意看我们红框的地方,当我们用Transfer-Encoding: chunked的时候前后两个视频加载的细微对比,并且,你可以点击开始按钮,你会发现它的加载速度是不一样的,第一个视频,基本上一下子就满了,而第二个则是一点一点一点一点的加载。

  那这样就算是chunked成功了么?我们来看下:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

  理论上讲,这样确实是成功了,并且我们还从侧面进一步验证,但是,我不想从侧面,我想正面验证一下不行么?好吧,满足你的小小愿望。但是为了满足你的这个愿望,我们需要额外的工具,也就是WireShark,或者你会使用其他的抓包工具也可以,我们现在在这里, 就使用WireShark来抓包看下哦。

  首先,进入界面后点击下面红框的loopback:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   就是回环的意思,大概是说你的本地电脑即作为服务器又作为客户端,自己玩,就点这个就行了,然后进去后你会发现咔咔咔咔一顿跳各种请求,嗯,是你电脑里各种软件的请求信息,那咋整呢?

真正“搞”懂HTTP协议07之body的玩法(实践篇)

  在过滤栏里输入这样的过滤条件,你会发现世界都安静了,好舒服~然后呢,我们刷新下刚刚的页面,哦抱歉,你还不能这样做,不过你可以先这样试下。

  好吧~接下来我们再写一个小服务吧,文件名叫做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信息: 真正“搞”懂HTTP协议07之body的玩法(实践篇)

   然后点击一下,再把滚动条往后面拽,使劲拽,拽到底:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   然后我们就可以看到这条,你发现这俩是一对,咋发现的呢?通过箭头发现的,一去一回~,然后我们点击它,可以看到它的详细信息:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   好大啊,我看个毛?别急,把Hypertext Transfer Protocol打开:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   再打开HTTP chunked response:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   看到这,我们是不是就可以完全确定我们设置的chunked生效了?没毛病吧~完美~~~但是呢~还没完,我们再打开其中一个块:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   注意哦,你现在可以手动自己去打开每一个块,你会发现,每一个块都有这样的编码:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   并且它在第一个块就有一个这玩意,然后最后一个块是这样的:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   好吧,恭喜你,发现了Transfer-encoding: chunked的核心内容,这里稍微涉及点理论知识,下面我们根据我们的实际操作,来补全一下这部分理论。

二)分块传输的数据格式

  分块传输也是采用明文的方式,主要分为两部分,长度头和数据块,长度头呢是以CRLF(回车换行,即\r\n)结尾的一行明文,用16进制数字表示块的长度,数据块紧跟在长度头后,最后也用 CRLF 结尾,但数据不包含 CRLF;最后用一个长度为 0 的块表示结束,即“0\r\n\r\n”。

  诶?是不是跟我们刚才看到的对上了,那个400是16进制的长度,我算算,400的16进制转成10进制是不是1024:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

  好像,有点完美啊~~环环相扣,丝毫不漏。哈哈哈哈~ 

  然后,我们可以再来个图示:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   没问题吧,嗯……分块传输就基本上完事了,大家可以试试这些实际的例子

  哦对了,我还忘了一个我在开始的时候说的有趣的事情,就是如果你把chunkSize设置的很大,比如1024*1024,抓包的时候会是什么样呢?你可以自己试下。你会发现它并没有按照chunked形式传递。至于为啥,我猜是因为你的块分的太大,实现的部分就不再视为chunked了,当然,这个是我猜的,我也不知道为啥。

  哦对,我还在代码里附上了wireshark的快照,用wireshark打开就可以回溯上面例子了。

三)范围请求可以这样玩

  我们稍微回到用html来请求分块传输的视频的那个例子,假设你在跟着我玩这个游戏,不知道你在那个例子的时候是否拖拽了一下进度条?那你是否发现怎么拖好像都没效果~,没实现肯定没效果。

  再有,不知道你是否细心的看到了这个东东:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   你看到,实际上在使用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 >发起这个视频的简单的范围请求</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();
}

  差不多这样,这整体的代码没啥好说的,我尤其要说一下的上面加粗的两部分,嗯……稍后说,我们来看看效果。

真正“搞”懂HTTP协议07之body的玩法(实践篇)

  诶?看起来好像不太对,请求没问题,范围也没问题,OK的,但是为啥视频没播放呢?你猜猜呢?答案就在我加粗的两行代码里,首先,后端服务器传回的是blob文件,前端的XMLHttpRequest对象也要设置responseType为blob,这个很重要。然后,最最重要的来了,你的视频,注意,是视频,所请求的视频的范围不能太小,你可以看到Content-Range的整个文件的大小是195万2139,所以你这给个零头还不到的范围,不行,我们把范围调大一点,就100w吧,然后我们再看效果。

真正“搞”懂HTTP协议07之body的玩法(实践篇)

   非常完美,但是我要强调两个细节。首先,我们请求的是范围,差不多是一半左右的视频吧,所以当开始后,后面的数据就没有了,视频也就暂停了。其次,我们发现,其实这样的前后端交互设计,就可以实现原生的进度条拖拽了。不信你可以在返回数据的范围内拖拽一下进度条试试?

   那么简单的范围请求我们就搞定了~,其实也是我们最核心的部分。

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 >点发我发起多段数据请求</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();
}

  我们看下结果:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

 

   这里有点小瑕疵,我们不管他,我懒得再切字符串了。你发现一个问题没有,分段传输实际上传输的是整个body,我们操作的是body的数据,是由前后端手动去分辨你分了哪些段,信息都在body的数据里,而不是通过服务器或者浏览器帮你去解析分段数据返回给你。为什么会这样呢?

  想象一下,浏览器怎么知道这些“段”是整体数据的哪一部分?它没法帮你做啊,所以那就都交给你们自己解决,自己商议了,那我们看这个数据结构。是HTTP协议要求这样去做的。我们看这段数据就可以理解,首先,每一段数据的开始都要有一个“--”加上服务器告诉你的分隔符是啥,在响应头里告诉你了,然后一块数据就类似一个小的http段,头部和body用\n分割,前端收到这段数据要自己通过逻辑代码去处理,最后,通过一个--加上分隔符--作为整体数据的结束。

  那既然是body数据,我的理解,你可以随意设置前端需要的,或者前后端约定的分段数据内的可能的、允许的、默认的数据形式和结构,也就是说,你不一定非要返回Content-Range和Content-Type,你还可以返回其他的,甚至不返回。

  嗯……看起来就是这个样子:

真正“搞”懂HTTP协议07之body的玩法(实践篇)

 

   这就是分段数据在body中的结构,注意,我一再强调,这是约定的结构,你完全可以不按照这样来。只要前后端商议好,并且不会造成未知的副作用。

  那么说了这么多,我们回头看下代码吧,其实代码很简单,就是写死了一块数据,然后形成了一个数组,最后遍历这个数据拼接上协议约定的分隔符就完事了。当然,这里我偷懒了,没有去读取请求头中的数据作为依据,而是写死的,额……这不是重点,我就偷点懒。

总结

  首先,本篇文章有两件事没有事无巨细的去做,一个是我在文章开头提到的断点续传,这个东西我觉得你学完了,学会了本篇的所有例子,你一定有思路去实现断点续传,一点都不复杂,我觉得我再写的话这篇文章就太长了,本来就长的出乎我的预估,所以留作课后作业吧。

  其次,还有一个没实现的例子就是基于Stream的分块传输,这个其实本质没有区别,大家有兴趣也可以自己去找一找资料,因为它其实更偏向于Node,和HTTP没有太大关系了。

  最后,我们稍微回顾一下本篇文章都做了啥。我们刚开始的时候用json、img、xlsx作为例子,看看前后端的交互处理是怎样的,很简单。

  然后,我们着重学习了以视频数据为例子的分块传输和范围请求。在文章的最后,我们用一个简单的例子,来实现了分段传输。

  我要强调的是,大家在学习这篇文章的时候,一定要结合例子,能清楚的分辨哪些是前后端代码要做的事情,哪些是我设置了头字段客户端会处理的情况。

  最后,终于结束了~