真正「搞」懂HTTP協定07之body的玩法(實踐篇)

2023-01-07 18:01:23

  我真沒想到這篇文章竟然寫了將近一個月,一方面我在寫這篇文章的時候陽了,所以將近有兩週沒幹活,另外一方面,我發現在寫基於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作為例子,看看前後端的互動處理是怎樣的,很簡單。

  然後,我們著重學習了以視訊資料為例子的分塊傳輸和範圍請求。在文章的最後,我們用一個簡單的例子,來實現了分段傳輸。

  我要強調的是,大家在學習這篇文章的時候,一定要結合例子,能清楚的分辨哪些是前後端程式碼要做的事情,哪些是我設定了頭欄位使用者端會處理的情況。

  最後,終於結束了~