送出一個成功回應,逐步寫入主體,並以正常方式結束訊息。這份 Mac 終端機操作指南示範如何傳送完整的合成文件,而不把每個區塊當成獨立回應,也不使用 206 Partial Content。

此任務是提供一個供公開託管的 HTML 頁面,然後確認用戶端收到完整內容。示範在本機執行,不會發布任何內容。你需要終端機、curl,以及從 Node.js 官方下載頁面 取得的受支援 Node.js LTS 版本。

為何分塊不代表部分回應

回應的意義與傳送框架回答的是不同問題。對一般成功的 GET 而言,200 OK 表示成功擷取。206 Partial Content 表示伺服器透過傳送所選取的表示內容,以滿足範圍請求。把內容分成多次寫入傳送,並不會因此形成範圍請求。RFC 9110:HTTP 語意、206 Partial Content。

例如,一份包含引言、說明和結尾段落的合成指南,可以逐步送達,同時仍是一份文件。只傳送一次回應標頭,接著依序傳送這些主體段落。不要在每個段落前加上新的 HTTP 狀態行。

HTTP/1.1 分塊傳輸編碼會以十六進位長度包裝主體位元組,並以零長度區塊和必要的結尾空行結束。這些框架位元組用來界定傳送內容,不屬於文章內容。傳送端不得同時使用 Content-Length 和 Transfer-Encoding。RFC 9112:分塊傳輸編碼、Content-Length。

實用原則是讓 HTTP 程式庫負責訊息框架。應用程式提供文件內容,並在內容傳送完畢時發出訊號。

應該使用哪種串流機制?

支援的機制取決於 HTTP 版本。以下三種版本都能串流傳送完整主體,但框架方式各不相同。

HTTP 版本 主體內容的傳送方式 完成機制
使用分塊編碼的 HTTP/1.1 帶有長度前綴的區塊 終止用的零長度區塊,以及已完成的結尾標頭區段
HTTP/2 DATA 框架 在結尾框架上設定 END_STREAM,並完成任何標頭區塊
HTTP/3 DATA 框架 回應完成,且串流的傳送方向關閉

HTTP/2 明確禁止使用 HTTP/1.1 分塊傳輸編碼。HTTP/3 未定義傳輸編碼,並禁止使用 Transfer-Encoding 標頭。讓伺服器依其協定實作框架,不要強制在每個連線上都使用 Transfer-Encoding: chunked。RFC 9113:HTTP 訊息交換、RFC 9114:HTTP 訊息框架。

如果小型文件已經組裝完成,直接正常傳送通常是最簡單的實作方式。當內容會隨時間產生時,逐步寫入就很實用。以下範例刻意將三次寫入分開,以便檢視行為;這些延遲僅供示範,並非發布建議。

在 Mac 上啟動完整文件串流

開啟終端機並確認必要條件:

node --version
curl --version

如果無法使用 node,請透過 Node.js 下載頁面 安裝受支援的 LTS 版本,然後開啟新的終端機視窗。這個程式只使用內建模組,不需要 npm 套件。

將下方完整區塊貼到終端機。它會在示範網址 http://127.0.0.1:8080/document 啟動 HTTP/1.1 伺服器。三個字串合在一起會構成一個完整的合成頁面。

node <<'JS'
const http = require('node:http');

const parts = [
  '<!doctype html>\n<html lang="en"><head><meta charset="utf-8">' +
    '<title>Paper Shapes</title></head><body><main>\n' +
    '<h1>Paper Shapes</h1>\n',
  '<p>A circle, a square, and a triangle make this sample collection.</p>\n',
  '<p>End of the paper shapes guide.</p>\n</main></body></html>\n'
];

const server = http.createServer((req, res) => {
  res.on('error', error => console.error(error.message));
  if (req.method !== 'GET' || req.url !== '/document') {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('No sample document at this path.\n');
    return;
  }

  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  let index = 0;
  const timer = setInterval(() => {
    if (res.destroyed) {
      clearInterval(timer);
      return;
    }
    res.write(parts[index++]);
    if (index === parts.length) {
      clearInterval(timer);
      res.end();
    }
  }, 300);
  res.on('close', () => clearInterval(timer));
});

server.on('error', error => {
  console.error(error.message);
  process.exitCode = 1;
});
server.listen(8080, '127.0.0.1', () => {
  console.log('Sample: http://127.0.0.1:8080/document');
});
JS

應用程式會呼叫一次 writeHead()、三次 write(),以及一次 end()。Node 文件說明,連續寫入的內容屬於同一個回應主體,且必須呼叫 end() 才能完成每個回應。Node 會處理 HTTP 框架;不要在字串中加入區塊長度或終止用的零。Node.js HTTP 文件。

這是一個由三個小字串組成、範圍有限的示範。若內容產生器會輸出大量或連續內容,請遵守背壓機制:當 write() 回傳 false 時,暫停產生內容,直到 drain,同時也要處理連線關閉和錯誤。Node.js 回應寫入文件。

讓這個終端機視窗保持執行。如果顯示 EADDRINUSE,表示埠 8080 已被占用;請在伺服器中選擇其他可用埠,並修改下方每個用戶端指令中的埠號。完成後按下 Control-C 停止示範。

檢視解碼後的內容與 HTTP 區塊框架

在第二個終端機視窗中,要求文件:

curl --http1.1 --include --no-buffer --max-time 5 \
  http://127.0.0.1:8080/document

預期會收到一個 200 OK 回應、Content-Type 為 text/html; charset=utf-8,以及組裝完成的頁面內容。這個 HTTP/1.1 範例的回應應使用 Transfer-Encoding: chunked。Curl 通常會解碼傳輸框架,因此一般輸出顯示的是文件內容,而非十六進位區塊大小。

若要檢視框架,請加上 --raw 再次提出要求:

curl --http1.1 --include --no-buffer --raw --max-time 5 \
  http://127.0.0.1:8080/document

Curl 文件說明,--raw 會停用內部對內容編碼和傳輸編碼的解碼。--no-buffer 會停用 curl 的輸出緩衝,而 --max-time 5 則會為操作設定時間上限。這些選項讓指令成為短時間的診斷工具,而不是無限期等待。curl 手冊。

原始輸出中,內容前後會有大小行,最後則是終止用的零長度區塊。若缺少這個終止區塊,HTTP/1.1 分塊主體就不完整。若採用固定長度框架,收到的位元組少於宣告長度也代表內容不完整。RFC 9112:處理不完整訊息。

原始輸出只適合用來檢查傳輸。不要把其中的大小行複製到頁面檔案,也不要把它們當成文件段落。

完整接收文件後再處理

如果後續步驟需要完整頁面,請先讀取整個主體,再進行解析、儲存或其他處理。WHATWG 將回應主體定義為串流;其 text() 方法會完整讀取主體並回傳字串,若主體讀取失敗則會拒絕。只收到回應標頭,並不能證明主體已傳送完畢。WHATWG Fetch 標準:Body mixin、處理回應。

伺服器保持執行時,將以下完整用戶端貼到第二個終端機視窗:

node --input-type=module <<'JS'
try {
  const response = await fetch('http://127.0.0.1:8080/document', {
    signal: AbortSignal.timeout(5000)
  });
  if (response.status !== 200) {
    throw new Error(`Expected 200; received ${response.status}`);
  }

  const html = await response.text();
  const marker = 'End of the paper shapes guide.';
  if (!html.includes(marker) || !html.endsWith('</html>\n')) {
    throw new Error('Expected sample ending is missing');
  }

  console.log(html);
  console.log('Complete sample document received.');
} catch (error) {
  console.error('Document not accepted:', error.message);
  process.exitCode = 1;
}
JS

Node 提供與瀏覽器相容的 fetch() 實作。其 AbortSignal.timeout() 會建立一個在指定毫秒數後中止的訊號;此處的五秒是針對這個微型本機範例所設定的示範時間上限。Node.js 全域物件文件、AbortSignal timeout 文件。

用戶端會檢查兩件不同的事:主體是否成功讀取完畢,以及結尾是否符合預期。第二項檢查是應用程式專用的診斷。HTTP 可以成功傳送一份文件,但應用程式可能在呼叫 end() 前意外截短內容。結尾標記有助於抓出這種特定錯誤,但無法驗證每個段落,也不等同於一般 HTML 驗證。

這個用戶端會將整個頁面暫存在記憶體中。對於非常大的文件,請採用增量處理,並明確定義文件格式和完成規則。不要假設每次讀取都會取得完整段落,或取得可獨立解讀的回應。

先診斷內容是否完成,再更改狀態碼

依照以下順序找出問題。這能將傳輸完成狀態與應用程式預期內容分開檢查,避免看似成功的下載掩蓋結尾內容遺漏的情況。

觀察結果 下一步
本機指令無法連線 確認伺服器正在執行,且埠號一致。
Curl 逾時 確認內容產生器會結束並呼叫 end();保留錯誤輸出。
傳輸完成,但結尾檢查失敗 檢查產生的內容,以及應用程式結束回應的位置。
完整文件一次到達 比較原始輸出與解碼後的輸出;不要根據顯示時間推斷有多個回應。
一般完整頁面請求收到 206 檢查是否意外套用了範圍處理;逐步傳送本身不足以成為使用此狀態碼的理由。

這些完成檢查是根據協定和 Fetch 行為提出的建議,並非獨立的 HTTP 功能。如果讀取主體的操作失敗,上述用戶端會回報失敗並以錯誤狀態結束,而不會只因收到部分內容就印出成功訊息。

這對公開頁面和 Google 搜尋有何意義?

請將有用的文件內容保留在 HTML 回應本身。Google 說明,若回應 HTML 包含頁面的所有內容,爬取和解析效果良好;並建議使用伺服器端算繪或預先算繪,因為並非所有機器人都會執行 JavaScript。此範例採用這種做法:文字直接隨文件主體傳送。Google Search Central:了解 JavaScript SEO 基本概念。

Google 的爬蟲文件指出,200 回應會將收到的內容傳交給下一個處理階段,但不保證內容會被索引。引用的文件沒有提供任何特殊區塊大小或延遲,可確保頁面會被索引。請將串流視為一種傳送實作,並另外確認內容完整。Google:HTTP 狀態碼如何影響 Google 的爬蟲。

將此示範用於公開託管時,請對實際文件端點重複這些檢查。確認狀態碼成功、完整讀取主體,並將最終內容與預期頁面比較。接受條件應獨立於傳送文件時使用了多少次寫入、框架或可見更新。

來源