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

## 目标与前置

团队助手从本地工具变成服务，意味着另一个进程可以通过网络请求它。前端经验提供了 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。四千零九十六字节上限限制本例累计输入。

```js 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`。程序完全在本机运行，响应正文很小且由本例控制，客户端完整消费正文后才发起下一次请求。

```js 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，显式超时信号则使等待拒绝。所有计时器与连接必须在实验结束时清理。

<details><summary>参考答案：完整状态与超时实验</summary>

```js 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` 返回会在指定时长后中止的信号，超时预算不是精确实时保证；事件循环被阻塞时，通知也会延迟。最后强制关闭的仅是本实验新建服务器上的连接，不应把这段测试清理当作生产服务正常关闭的完整策略。

</details>

## 验收与自测

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

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

## 本章实际验证范围

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

## 官方参考

连接与字节流见 [Net](https://nodejs.org/docs/latest-v24.x/api/net.html)，HTTP 请求、连接池和超时见 [HTTP](https://nodejs.org/docs/latest-v24.x/api/http.html)，加密请求见 [HTTPS](https://nodejs.org/docs/latest-v24.x/api/https.html) 与 [TLS](https://nodejs.org/docs/latest-v24.x/api/tls.html)，解析差异见 [DNS](https://nodejs.org/docs/latest-v24.x/api/dns.html)。完整实验只验证本地 TCP 与 HTTP，未验证公网 DNS、HTTPS 证书或代理部署。
