iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Modern Web

Learn HTTP With JS系列 第 12

HTTP Range Requests

  • 分享至 

  • xImage
  •  

使用時機

Range Requests,通常用在影片播放,當我們使用 <video> 載入影片時:

  • 若 Server 支援 Range,就可以實現跳轉功能
  • 若 Server 不支援 Range,則無法跳轉

Google 提供的 Public Test MP4 為例,當我們每次跳轉的時候,都會發起一個 Range Request,取得當前進度的影片內容
video-206

Range 相關的 Headers

同樣以 Google 提供的 Public Test MP4 為例:

  1. Client 會發送

first-range-request
2. Server 若支援 Range,則會回傳 206 Partial Content

first-range-response
3. 切換影片進度時,Client 會多發送一個 If-Range 的 Header

second-range-request
4. Server 會比對 If-Rangeetag 或是 last-modified 是否一樣

若一樣,則回傳 206 Partial Content
second-range-response
若不一樣,則回傳 200 + 整個文檔

HTTP/1.1 200
Accept-Ranges: bytes
Content-Length: 169612362

send 套件的實作

express.static 的底層是使用 serve-static 這個套件,而 serve-static 的底層則是用 send

send 這個套件處理了 range 以及 cache 相關的邏輯,我們來看看 If-Range 的實作,跟規範定義的一樣,If-Range 可以是 ETag 或是 Last-Modified

/**
 * Check if the range is fresh.
 *
 * @return {Boolean}
 * @api private
 */

SendStream.prototype.isRangeFresh = function isRangeFresh() {
  var ifRange = this.req.headers["if-range"];

  if (!ifRange) {
    return true;
  }

  // if-range as etag
  if (ifRange.indexOf('"') !== -1) {
    var etag = this.res.getHeader("ETag");
    return Boolean(etag && ifRange.indexOf(etag) !== -1);
  }

  // if-range as modified date
  var lastModified = this.res.getHeader("Last-Modified");
  return parseHttpDate(lastModified) <= parseHttpDate(ifRange);
};

再來看看怎麼達成資料切片的

SendStream.prototype.stream = function stream(path, options) {
  var self = this;
  var res = this.res;

  // pipe
  var stream = fs.createReadStream(path, options);
  this.emit("stream", stream);
  stream.pipe(res);

  // other code ...
};

其實 NodeJS 原生的 createReadStream 就可以指定 opions = { start: 0, end: 1023 },讀取指定 range 的 bytes

NodeJS HTTP 模組 + send

我們創建一個 NodeJS HTTP Server,針對 /article.txt 這個路由使用 send 套件,其中 /article.txt 是一篇 AI 隨機生成的 1000 字文章

import httpServer from "../httpServer";
import send from "send";
import { notFoundListener } from "../listeners/notFoundlistener";
import { faviconListener } from "../listeners/faviconListener";

httpServer.on("request", function requestListener(req, res) {
  if (req.url === "/favicon.ico") return faviconListener(req, res);
  if (req.url === "/article.txt") {
    res.setHeader("Content-Type", "text/plain;charset=utf-8");
    return send(req, String(req.url), { root: __dirname }).pipe(res);
  }
  return notFoundListener(req, res);
});

使用 Postman 發起一個 Range Request,可以看到確實有回傳對應的 Range
postman-range
postman-range-res-body

使用 Postman 發起 If-Range 的請求,發送正確的 ETag,Server 比對後,回傳對應的 Range
if-range-etag-correct
if-range-last-modified-correct

使用 Postman 發起 If-Range 的請求,發送錯誤的 ETag,Server 比對後,回傳 200 + 整個檔案
if-range-uncorrect

Range 的不同形式

  • 不指定尾巴,代表取到最後一個 byte Range: bytes=6-
    range-bytes=6-

  • 使用負數,代表從後面數過來 99 個 bytes Range: bytes=-99
    range-bytes=-99

  • 多個區間(但 send 套件不支援,會直接回傳整個檔案),Range: bytes=0-5,9-14
    range-bytes-multiple

multipart/byteranges

並不是所有 Application Server 或是 Web Server 都支援多個 Range 區間,但 Apache 有支援,所以我們來嘗試看看!

  1. 下載最新的 XAMPP
  2. 啟動 Apache Web Server 後,預設會在 C:\xampp\htdocs 有很多檔案,隨便挑一個 HTML 檔案
  3. 使用 Postman 發送 multi-range 請求
    multi-part-ranges-response-header
    multi-part-ranges-response-body
    💡要注意,這邊的 Range: bytes=0-5,9-14 不可寫成 Range: bytes=0-5, 9-14,Apache 會解析失敗💡

416 Range Not Satisfiable

當 client 端送了超出範圍的 range,Server 就會回傳 416,並且回傳 Content-Range: bytes */3641,告訴 client 這個檔案只有 3641 bytes。
416-range-not-satisfiable

NodeJS + HTTP 模組自行實作 Range

  1. 下載 Google 提供的 Public Test MP4
  2. 調整 NodeJS 程式碼

index.ts

httpServer.on("request", function requestListener(req, res) {
  if (req.url === "/favicon.ico") return faviconListener(req, res);
  if (req.url === "/article.txt") {
    res.setHeader("Content-Type", "text/plain;charset=utf-8");
    return send(req, String(req.url), { root: __dirname }).pipe(res);
  }
  if (req.url === "/ElephantsDream.mp4") return rangeListener(req, res);
  return notFoundListener(req, res);
});

const mp4file = readFileSync(join(__dirname, "ElephantsDream.mp4"));
const rangeListener: RequestListener<
  typeof IncomingMessage,
  typeof ServerResponse
> = (req, res) => {
  const range = req.headers.range;

  res.setHeader("Content-Type", "video/mp4");
  // invalid range
  if (!range || !range.startsWith("bytes=")) return res.end(mp4file);

  const [startStr, endStr] = range.split("bytes=")[1].split("-");
  const start = parseInt(startStr);
  const end = endStr === "" ? mp4file.byteLength - 1 : parseInt(endStr);

  // 416
  if (start > end || start > mp4file.byteLength) {
    res.statusCode = 416;
    res.setHeader("Content-Range", `bytes */${mp4file.byteLength}`);
    res.end(STATUS_CODES[416]);
    return;
  }

  const options = { start, end };
  const readStream = createReadStream(
    join(__dirname, "ElephantsDream.mp4"),
    options,
  );
  res.setHeader("Accept-Ranges", "bytes");
  res.setHeader("Content-Range", `bytes ${start}-${end}/${mp4file.byteLength}`);
  res.setHeader("Content-Length", end - start + 1);
  res.statusCode = 206;
  readStream.pipe(res);
};
  1. 瀏覽器打開 http://localhost:5000/ElephantsDream.mp4 ,都可以正常切換影片進度~
    localhost-range-implement

小結

本篇文章,帶大家了解 Range 請求,平常看影片/大型檔案下載時就會用到這個技術,深入了解才發現實作起來眉角很多,並且也翻了 send 套件跟 Apache Web Server,才把 Range 請求的行為都測試過一輪,算是收穫滿滿~

參考資料


上一篇
iframe security
下一篇
Retry After and Date
系列文
Learn HTTP With JS30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言