2

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

 1 year ago
source link: https://www.cnblogs.com/zaking/p/16966545.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

真正“搞”懂HTTP协议07之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}/`);
});

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

1184971-20221209134800840-1730926202.png

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

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

1184971-20221220164612183-1627093148.png

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

1184971-20221221154706033-1121824619.png

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

1184971-20221221154957132-1544666803.png

   是不是很熟悉的乱码,然后,我们就可以通过前端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>

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

1184971-20221226155839829-1404060318.png

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

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

1184971-20221226160249925-1607732696.png

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

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

1184971-20221226160756275-575940329.png

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

1184971-20221226161049764-1525790062.png

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

  好吧~接下来我们再写一个小服务吧,文件名叫做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信息: 1184971-20221226161600456-365516375.png

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

1184971-20221226161707741-827365534.png

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

1184971-20221226162133111-86699006.png

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

1184971-20221226162215867-80076534.png

   再打开HTTP chunked response:

1184971-20221226162244115-1830892314.png

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

1184971-20221226162519707-1449835912.png

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

1184971-20221226162707706-749883118.png

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

1184971-20221226162754623-1505403704.png

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

二)分块传输的数据格式

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

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

1184971-20221226163726448-996934168.png

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

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

1184971-20221226164658982-427289788.png

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

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

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

三)范围请求可以这样玩

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

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

1184971-20221226172454413-728955530.png

   你看到,实际上在使用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();
}

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

1184971-20230103212235580-1132121766.png

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

1184971-20230103214233859-942373693.png

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

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

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();
}

  我们看下结果:

1184971-20230107155447603-774982448.png

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

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

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

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

1184971-20230107161421977-694436600.png

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

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

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

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

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

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

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

  最后,终于结束了~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK