发送一个成功响应,逐步写入正文,然后正常结束消息。本 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() 才能完成。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 混入类、处理响应。
在服务器仍运行时,将以下完整客户端代码粘贴到第二个终端窗口:
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 超时文档。
该客户端会检查两件不同的事:正文是否已成功读取,以及内容是否以预期示例结尾。第二项检查是特定于应用程序的诊断。即使应用程序在调用 end() 之前意外缩短了正文,HTTP 仍可能成功传输这份文档。结束标记有助于发现这一特定错误,但它无法验证每个段落,也不构成通用 HTML 验证。
该客户端会将整个页面缓冲在内存中。对于非常大的文档,请选择增量处理,并明确文档格式和完成规则。不要假定每次读取都会得到一个完整段落,或一个具有独立意义的响应。
先诊断完成情况,再更改状态码
按以下顺序定位问题。这会将传输是否完成与应用程序预期的内容区分开来,避免看似成功的下载掩盖结尾内容缺失的问题。
| 观察结果 | 下一步操作 |
|---|---|
| 本地命令无法连接 | 确认服务器正在运行且端口匹配。 |
| Curl 超时 | 检查内容生成器是否完成并调用 end();保留错误输出。 |
| 传输结束,但结尾检查失败 | 检查生成的内容,以及应用程序结束响应的位置。 |
| 完整文档一次性到达 | 比较原始输出与已解码输出;不要根据显示时间推断存在多个响应。 |
普通的完整页面请求收到 206 |
检查是否意外应用了范围处理;渐进式传输本身并不构成使用它的理由。 |
这些完成检查是根据协议和 Fetch 行为提出的建议,而非独立的 HTTP 功能。如果读取正文的操作失败,上述客户端会报告失败并以非成功状态退出,而不会针对目前收到的内容输出成功消息。
这对公开页面和 Google 搜索意味着什么?
将有用的文档内容放在 HTML 响应本身中。Google 解释说,对于响应 HTML 中包含全部内容的页面,抓取和解析效果良好;此外,Google 建议使用服务器端渲染或预渲染,因为并非所有机器人都会执行 JavaScript。此示例遵循这一做法:文本直接包含在文档正文中。Google 搜索中心:了解 JavaScript SEO 基础知识。
Google 的抓取工具文档指出,200 响应会将收到的内容传递到下一处理阶段;这并不保证内容会被编入索引。引用的文档没有提供任何可确保页面被编入索引的特殊分块大小或延迟设置。请将流式传输视为一种传输实现方式,并单独验证最终内容。Google:HTTP 状态代码如何影响 Google 的抓取工具。
将此演示用于公开托管时,请针对实际文档端点重复这些检查。确认状态成功、读取完整正文,并将其最终内容与预期页面进行比较。无论文档通过多少次写入、多少个帧或多少次可见更新传输,都应使用同一套验收检查。
来源
- RFC 9110:HTTP 语义 — RFC 编辑部 / IETF。
- RFC 9112:HTTP/1.1 — RFC 编辑部 / IETF。
- RFC 9113:HTTP/2 — RFC 编辑部 / IETF。
- RFC 9114:HTTP/3 — RFC 编辑部 / IETF。
- Fetch 标准 — WHATWG。
- HTTP — Node.js。
- 全局对象 — Node.js。
- 下载 Node.js — Node.js。
- curl — 使用指南 — curl。
- 了解 JavaScript SEO 基础知识 — Google 搜索中心。
- HTTP 状态代码如何影响 Google 的抓取工具 — Google 开发者。