本页目录

从 TCP 到 HTTP:连接、消息与请求生命周期

用本机服务观察字节流与 HTTP 消息的差别,理解连接复用、DNS、TLS 和超时边界,不把请求成功等同于业务成功。

L2 · 能交付约 15 分钟阅读含示例、练习与验收

建议先读:流与背压:让快生产者等待慢消费者

本页内容

目标与前置#

团队助手从本地工具变成服务,意味着另一个进程可以通过网络请求它。前端经验提供了 URL、请求方法和响应状态的直觉,本章继续向下看一层:请求先依赖连接,连接传输字节,协议在字节上定义消息。前置是 Buffer、流和错误传播。目标是能够解释一次请求从地址解析到响应消费的过程,并定位失败发生在哪一层。

所有完整实验只监听本机回环地址,使用操作系统分配的临时端口,自动发起请求并关闭服务,不需要外网、证书或第三方依赖。HTTPS 部分解释现代 API 与生产边界,但不伪称已经运行公网证书或 TLS 部署实验。这样可以先验证机制,再在真实部署环境验证证书与代理配置。

TCP 提供字节顺序,不提供你的消息边界#

TCP 连接可以看作两端之间的有序字节通道。应用写入两次,并不保证接收端触发两次 data;一次大写入也可能分成多块。操作系统缓冲、网络分段和读取时机都会影响块的形状。因此用一次 data 回调直接 JSON.parse,实际上是假定数据块恰好等于完整消息,这个假定很容易失效。

应用协议需要另外定义边界,例如固定长度、分隔符或长度前缀。分隔符协议必须说明内容中出现分隔符时怎样转义;长度前缀协议必须限制声明长度并处理不完整头部。上一章的流解决数据怎样持续传递,本章的协议解决这些数据怎样组成可理解的消息,两者不能互相替代。

TCP 的有序可靠传递也不等于业务操作恰好执行一次。服务器可能已经保存任务,但响应返回途中连接断开,客户端只知道没拿到结果。重试就可能重复创建。网络层无法单独消除这种不确定性,后续幂等章节会通过请求标识与数据库约束控制重复效果。

net 的基本 API#

net.createServer(listener) 返回 Server,监听器在接受连接时收到 Socket。server.listen(port, host) 启动监听,端口零请求系统分配可用端口;通过 listening 事件或回调确认启动后,才能读取 server.address()。监听失败通过 error 报告,例如固定端口已被占用时的错误,不能仅在 listen 外层写同步 try 就认为覆盖所有情况。

net.createConnection(options) 返回正在连接的 Socket,连接建立后发出 connect,失败时发出 error。Socket 同时可读可写,使用 write、end 和 data 等流接口。setEncoding('utf8') 使读取提供解码后的字符串并维护字符跨块状态,但它不会替应用识别换行或 JSON 消息。

socket.end() 表示结束自己的可写方向,不等于立刻删除整个连接对象。对端仍可能发送响应,半关闭与最终关闭有区别。destroy(error) 则用于销毁连接并可报告错误,未发送的数据可能丢弃。选择哪一种取决于协议是否仍需要有序完成剩余数据。

实验一:两次写入与两条消息不是同一个概念#

保存为 tcp-lines.mjs,Node 22.22.0,无依赖。协议规定每行一条短标题,不允许标题内换行,客户端结束发送后服务端返回所有标题的 JSON。四千零九十六字节上限限制本例累计输入。

tcp-lines.mjs
import net from 'node:net';
import { once } from 'node:events';
import assert from 'node:assert/strict';

const server = net.createServer((socket) => {
  socket.setEncoding('utf8');
  let input = '';
  socket.on('error', () => {}); // 本地演示不把连接错误升级为进程错误。
  socket.on('data', (part) => {
    input += part;
    if (Buffer.byteLength(input) > 4096) socket.destroy(new Error('输入超限'));
  });
  socket.on('end', () => {
    if (!input.endsWith('\n')) { socket.end('invalid'); return; }
    socket.end(JSON.stringify(input.slice(0, -1).split('\n')));
  });
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const socket = net.createConnection({ host: '127.0.0.1', port: server.address().port });
try {
  socket.setEncoding('utf8');
  const response = new Promise((resolve, reject) => {
    let text = '';
    socket.on('data', (part) => { text += part; });
    socket.once('end', () => resolve(text));
    socket.once('error', reject);
  });
  socket.write('文档一\n文');
  socket.end('档二\n');
  assert.deepEqual(JSON.parse(await response), ['文档一', '文档二']);
  console.log('跨写入边界的两条标题解析成功');
} finally {
  socket.destroy();
  await new Promise((resolve) => server.close(resolve));
}

执行 node tcp-lines.mjs,预期通过。我们故意把第二条标题拆在两次 write 之间,但不对服务端 data 次数作断言,因为那不是 TCP 提供的保证。服务端这里为了保持实验短小,累计受限的全部输入,到发送结束才拆行;真正持续运行的行协议应边收到边提取完整行,并另外限制未结束行的长度。

服务器只绑定回环地址,因此其他机器不能通过正常网络接口访问它。端口零避免与已有服务争抢固定端口。客户端在连接完成前写入,Socket 会处理相应连接期间的写入排队;错误监听在发送前已建立。最后先销毁本次客户端,再等待服务器关闭,避免留下测试监听端口。

HTTP 把字节组织成请求与响应#

HTTP 在连接上定义方法、目标、头部和正文等结构。Node 的 http 模块负责解析协议外壳,你不需要自行从 TCP 数据里查找头部结束标记。服务端收到的 IncomingMessage 是可读流,ServerResponse 是可写输出对象。正文仍可能分块到达,JSON 解析仍然需要明确大小上限与完成边界。

一条 TCP 连接可以承载多次 HTTP 请求,因此连接数量与请求数量不是同一个指标。保持连接可以减少重复建立的成本,但占用连接也会消耗资源。连接池通过限制并发、复用空闲连接和回收失效连接,在延迟与资源之间权衡。不要为了减少握手就无限保留连接,也不要每次请求都强制新建而忽略复用收益。

HTTP 状态码属于协议结果,网络错误属于传输失败。收到四百或五百状态说明通常已经收到了一个 HTTP 响应,不一定触发客户端 Promise 拒绝。尤其 fetch 在这些状态下仍会兑现 Response,调用方要检查 ok 或 status。相反,名称解析失败、连接拒绝和取消可能使请求直接拒绝。

HTTP 客户端与连接池 API#

http.request(options, callback) 返回 ClientRequest,回调在收到响应头后得到响应流。请求只有调用 end 才表示正文提交结束;可通过 write 分块发送正文。请求对象的 error 监听处理连接等失败,响应流也可能在正文读取阶段失败,因此不能在拿到响应头后就宣告完整成功。

new http.Agent({ keepAlive: true, maxSockets: 1 }) 显式启用连接复用,并把每个源的并发连接限制为一。默认 Agent 与全局 Agent 的设置会随 Node 版本和模块有所不同,所以本例不依赖默认复用策略。结束实验后调用 agent.destroy,释放它管理的连接。

request.setTimeout(ms, callback) 主要提供套接字活动相关的超时通知,不会仅因为设置了数值就自动取消所有阶段。通常需要在回调中 destroy 请求,或使用明确的取消信号。整个请求预算、连接建立预算和无活动预算不是同一个概念,生产客户端应根据业务分别设计。

实验二:两个 HTTP 请求复用一个连接#

保存为 http-keepalive.mjs。程序完全在本机运行,响应正文很小且由本例控制,客户端完整消费正文后才发起下一次请求。

http-keepalive.mjs
import http from 'node:http';
import { once } from 'node:events';
import assert from 'node:assert/strict';

let connections = 0;
const server = http.createServer((request, response) => {
  response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
  response.end(JSON.stringify({ path: request.url }));
});
server.on('connection', () => { connections += 1; });
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
function read(path) {
  return new Promise((resolve, reject) => {
    const request = http.request({ host: '127.0.0.1', port: server.address().port, path, agent }, (response) => {
      response.setEncoding('utf8');
      let text = '';
      response.on('data', (part) => { text += part; });
      response.once('end', () => resolve({ status: response.statusCode, text }));
      response.once('error', reject);
    });
    request.once('error', reject);
    request.setTimeout(2000, () => request.destroy(new Error('请求无活动超时')));
    request.end();
  });
}
try {
  assert.equal((await read('/tasks')).status, 200);
  assert.equal(JSON.parse((await read('/documents')).text).path, '/documents');
  assert.equal(connections, 1);
  console.log('两个请求使用一个 TCP 连接');
} finally {
  agent.destroy();
  await new Promise((resolve) => server.close(resolve));
}

执行 node http-keepalive.mjs,预期通过。响应头出现时 Promise 还没有 resolve,因为代码继续读取到 end;只有这样才能确认正文完整消费,也让连接回到可复用状态。连接计数来自服务器 connection 事件,提供了比猜测请求速度更直接的复用证据。

本例不能直接作为任意外部响应读取器,因为它没有正文总量上限。这里服务器与返回值完全受控,目的是观察连接;面对外部服务时,应在 data 中累计实际字节、限制规模,并在提前放弃正文时正确取消或消费,避免连接池被悬挂响应占住。实验的简化边界必须与生产接口分开。

DNS、TLS 与 HTTPS 增加了哪些步骤#

域名请求通常需要先把名称解析成地址,再建立连接。dns.lookup 借助操作系统名称解析设施,可能考虑 hosts 等本地配置;dns.resolve 系列更直接面向 DNS 查询,两者不总是等价。地址可能有 IPv4 与 IPv6,顺序与选择会受环境和版本影响。调试连接失败时,应记录实际目标地址,而不是只记录域名。

HTTPS 在 HTTP 与传输之间加入 TLS,提供加密与对端身份验证。证书不仅是让浏览器不显示警告的文件,它帮助客户端确认自己连接的是预期服务。关闭证书验证可能让连接“成功”,却移除了重要身份保证,不能作为常规修复。自签名实验应明确配置受信任证书,而不是把拒绝验证的选项复制进生产。

https.request 与 HTTP 请求接口相近,但还需要 TLS 配置与证书验证。反向代理可以在入口终止 TLS,内部再转发 HTTP;这时应用需要知道哪些转发头来自可信代理,不能盲目信任客户端自己提交的来源地址或协议声明。Cookie 安全与重定向生成也会受到这层部署关系影响。

沿着请求生命周期定位延迟#

一次请求可以拆成等待连接池名额、名称解析、建立连接、TLS 握手、发送请求、等待响应头、读取响应体和本地解析。某些阶段会因连接复用而跳过,某些阶段会重叠。只记录一个总耗时,无法区分远端处理慢还是本地连接池已经排队。日志应该围绕你实际拥有的阶段边界,而不是编造无法测量的细分时间。

例如团队助手同时向模型服务发起很多请求,客户端连接池的并发上限较小,后续请求可能尚未发往远端就已等待很久。如果把全部时间都标成“模型推理耗时”,就会误导容量判断。相反,盲目扩大连接数可能把压力转移到远端限流。队列长度、在途数量和远端响应时间需要一起看。

服务端也有相似阶段:收到连接、解析请求头、读取正文、校验身份、执行业务、发送正文。上传很慢的客户端可能长时间占用连接,但几乎不消耗业务 CPU;巨大的同步解析则可能在短时间占满线程。不同瓶颈需要不同限制,例如头部和正文超时、字节上限、并发配额或计算隔离。

保持连接不等于连接永远有效#

服务器、代理或网络设备都可能在空闲一段时间后关闭连接。客户端从池中取出连接时,可能恰好遇到对端已经关闭或正在关闭,导致请求失败。连接复用降低建立成本,却带来空闲寿命与失效处理问题。应采用成熟客户端的池管理能力,并对可安全重试的操作设计有限重试,而不是把每次连接重置都当作未知灾难。

重试必须同时考虑请求内容是否可重放。一个已经消费掉的上传流不能简单再发送一次;需要重新打开可重复读取的源,或采用分片与恢复协议。创建任务这类写操作还要有幂等标识。网络错误、数据来源和业务副作用三者共同决定能否重试,不能仅根据错误码做通用循环。

连接池也不能替代应用并发限制。即使同时只有少量连接,调用方仍可能在内存中创建成千上万个等待请求对象和完整正文。池限制的是连接层资源,应用还需要限制排队任务数量,并在队列过长时提供明确失败或等待策略。这样系统在高负载下才不会通过无限内存换取表面上的“请求已接收”。

超时之后怎样处理资源#

一个完整请求预算到期后,调用方通常应停止继续等待,并向底层传递取消。若正文已经开始读取,取消可能关闭连接,使它无法复用;若只是设置了超时事件却没有销毁,请求可能仍在后台工作。需要在接口上明确超时意味着什么,以及谁负责释放响应体与连接。

远端不一定会因为客户端取消而停止业务。客户端关闭连接可能只能阻止后续传输,服务器的数据库写入或模型调用已经开始。重要任务应有可查询的任务编号与明确状态,前端取消等待后仍能重新查看结果。把连接生命与任务生命分开,是从普通页面请求走向后台作业的重要一步。

错误处理还要避免双重响应。请求已经发送一部分正文后发生失败,服务端不能再调用 writeHead 改成另一个状态并发送完整 JSON。此时应结束或销毁连接,记录失败,并让客户端识别响应不完整。开始输出之前尽可能完成可提前检查的身份、参数和资源归属,能减少这种无法体面改写结果的场景。

地址与来源的信任边界#

浏览器的跨源限制主要约束浏览器脚本如何读取响应,不是服务器保护数据的授权机制。任何能直接发 HTTP 的客户端都可能绕过页面上的按钮和路由守卫。因此团队助手的服务端仍需验证登录、资源归属和租户范围,不能因为只给自己的 Vue 页面调用就省略这些步骤。

服务端代用户请求一个 URL 时,还需要防止它变成访问内部地址的通道。仅检查 URL 字符串看起来像公网域名不够,解析、重定向和最终连接目标都可能改变实际地址。这里涉及专门的请求目标约束与网络策略,本章只建立分层意识,不提供一个假称能覆盖所有环境的简短正则。

练习:区分 HTTP 失败状态与请求取消#

建立本机服务,普通路径返回四百零四,慢路径延迟响应。验证 fetch 收到四百零四时正常返回 Response,显式超时信号则使等待拒绝。所有计时器与连接必须在实验结束时清理。

参考答案:完整状态与超时实验
http-status-timeout.mjs
import http from 'node:http';
import { once } from 'node:events';
import assert from 'node:assert/strict';

const server = http.createServer((request, response) => {
  if (request.url === '/slow') {
    const timer = setTimeout(() => response.end('done'), 200);
    response.once('close', () => clearTimeout(timer));
    return;
  }
  response.writeHead(404);
  response.end('missing');
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
try {
  const response = await fetch(`${base}/missing`);
  assert.equal(response.status, 404);
  assert.equal(response.ok, false);
  await response.text();
  await assert.rejects(fetch(`${base}/slow`, { signal: AbortSignal.timeout(20) }), { name: 'TimeoutError' });
  console.log('404 是响应,超时是拒绝');
} finally {
  const closed = new Promise((resolve) => server.close(resolve));
  server.closeAllConnections();
  await closed;
}

执行 node http-status-timeout.mjs,预期通过。AbortSignal.timeout 返回会在指定时长后中止的信号,超时预算不是精确实时保证;事件循环被阻塞时,通知也会延迟。最后强制关闭的仅是本实验新建服务器上的连接,不应把这段测试清理当作生产服务正常关闭的完整策略。

验收与自测#

验收要求 TCP 实验不依赖 data 次数,连接复用实验明确看到一次 connection,状态实验分别检查状态码和拒绝。自测一:两次 write 是否保证两次 data?答案:不保证。自测二:收到响应头是否表示正文完整?答案:不是,还要消费结束并处理错误。自测三:客户端没收到创建结果能否认定服务器没执行?答案:不能,响应可能在操作完成后丢失,需要幂等或状态查询处理不确定性。

协议层观察与业务层结果应分别记录。连接建立只能证明对端接受了连接,收到成功状态也仍需按接口约定检查正文。沿这些边界逐步验证,比把任何异常都归为网络不好更容易找到原因。

本章实际验证范围#

三个本机程序实际通过:TCP 标题跨写入边界解析、两个 HTTP 请求复用一个连接、404 响应与超时拒绝。测试只绑定回环地址;公网 DNS、TLS 证书与代理部署未运行。

官方参考#

连接与字节流见 Net,HTTP 请求、连接池和超时见 HTTP,加密请求见 HTTPSTLS,解析差异见 DNS。完整实验只验证本地 TCP 与 HTTP,未验证公网 DNS、HTTPS 证书或代理部署。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭