从请求流到响应流:理解 Node.js 的一次 HTTP 事务

理解 Node.js 的 HTTP 处理,可以从一对对象开始:request 负责接收客户端发来的信息,response 负责把信息写回客户端。前者是一条可读流,后者是一条可写流;方法、路径、头部、请求体和错误,都围绕这对对象展开。

本指南假定你已经大致了解 HTTP 请求,也接触过 Node.js 的 EventEmitter 与 Stream。若这些概念还不熟悉,可以先阅读对应 API 文档。下文按官方指南的顺序,从创建服务器一直讲到基于管道的 echo 服务。

原文:Node.js 文档贡献者的 Anatomy of an HTTP Transaction,2026年10月5日读取。原页提供 CJS 与 ESM 两种写法;本文合并解释,开头展示两种导入,后续完整示例统一使用 CJS。所有示例仅经静态审阅,未启动监听或发送请求。

HTTP事务从request读取方法、URL、头部与body,设置response状态和头部,再write或pipe并结束;请求和响应均有错误处理。
编辑原创示意图。对于流式 echo,可由请求流直接接到响应流,而不必先聚合完整 body。

创建服务器和请求处理函数

Node.js Web 服务首先需要一个服务器对象,通过 http.createServer 创建。CommonJS 写法如下:

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

const server = http.createServer((request, response) => {
  // magic happens here!
});

若项目使用 ECMAScript 模块,改用对应导入语法;处理函数本身相同:

import http from 'node:http';

const server = http.createServer((request, response) => {
  // magic happens here!
});

每当一个 HTTP 请求到达服务器,传给 createServer 的函数就会被调用一次,所以它称为请求处理函数。createServer 返回的 Server 本身也是 EventEmitter。把函数直接传进去,只是先创建对象、再监听 request 事件的简写:

const server = http.createServer();
server.on('request', (request, response) => {
  // the same kind of magic happens here!
});

处理函数收到 request 和 response 两个对象,分别承载事务的输入和输出。要真正接受连接,还必须在服务器对象上调用 listen。端口是常用参数,其他选项见 HTTP API。

编辑补充:原文完整示例使用 .listen(8080),未指定监听主机,这可能让服务通过外部网络接口可达。本文保留原代码以便核对;在本地隔离实验中,结尾应明确改成 .listen(8080, '127.0.0.1')。这只限制监听范围,并不补齐 TLS、认证、限流或资源管理。

读取方法、URL 和请求头

处理请求时,通常先查看方法和 URL,决定执行什么操作。两者可直接从 request 读取:

const { method, url } = request;

request 是 IncomingMessage 的实例。method 表示 HTTP 方法,例如 GET 或 POST。在原文讨论的常见请求形式里,url 是去掉协议、主机和端口后的请求目标,通常包含路径及查询串。

const { headers } = request;
const userAgent = headers['user-agent'];

请求头位于 headers 对象中,其键名统一转成小写,不受客户端发送时大小写的影响。某些重复头可能被覆盖或合并为逗号分隔的字符串,具体取决于头字段规则。如果需要检查原始顺序和形式,可以查看 rawHeaders;不要假定所有重复字段都有完全相同的合并规则。

请求体是一条流

POST 或 PUT 请求中,请求体通常是应用关心的内容。它不像头部那样直接作为完整对象提供:request 实现了可读流接口,可以监听事件,也可以连接到其他流。最直接的读法是监听 data 和 end。

每次 data 事件给出一个 Buffer。如果已经确定最终应按字符串解释,可以先收集这些块,等流结束后再拼接并解码:

let body = [];
request
  .on('data', chunk => {
    body.push(chunk);
  })
  .on('end', () => {
    body = Buffer.concat(body).toString();
    // at this point, `body` has the entire request body stored in it as a string
  });

先拼接 Buffer、再转换字符串,也避免了把一个跨块的多字节字符分别解码。toString() 的默认编码是 UTF-8,真实协议需要依据明确的内容类型和编码规则处理,二进制内容不应无条件转成文本。

这段逻辑比较琐碎,原文提到 npm 上的 concat-stream 和 body 可封装部分工作。理解底层发生了什么之后,再决定是否使用抽象更高的库。这些链接是原文提及的工具,不构成对当前维护状态或适用性的重新推荐。

编辑补充:数组会保留每个请求块,Buffer.concat 还会分配合并后的缓冲区,随后字符串又占用内存。原例没有请求体大小、读取时间或并发限制;攻击者或意外的大请求都可能耗尽资源。实际服务必须给这些资源设置边界,不能直接把这个教学写法用于公开接收任意数据。

别漏掉请求流的错误

可读流也是 EventEmitter。请求流发生错误时会发出 error 事件;如果没有监听器,未处理的错误可能让 Node.js 进程退出。因此,请求流需要显式错误处理,哪怕最初只是记录错误:

request.on('error', err => {
  // This prints the error message and stack trace to `stderr`.
  console.error(err.stack);
});

这里把错误信息与堆栈写到标准错误。实际应用还需要决定是否能向客户端发送合理的 HTTP 错误,以及怎样清理已占用资源。可以使用更高层的抽象,但不能因此假定错误不会发生。详情可查 Node.js 错误文档。

到这里,我们还没有发出响应

把创建服务器、读取方法、路径、头部和请求体组合起来,会得到下面这个中间阶段:

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

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // At this point, we have the headers, method, url and body, and can now
        // do whatever we need to in order to respond to this request.
      });
  })
  .listen(8080); // Activates this server, listening on port 8080.

这个例子故意只读取请求,没有向 response 写入数据,也没有结束响应。客户端会一直等,直到超时。不要把这一段截出来当作已经完成的服务器。

response 是 ServerResponse 的实例,也是一条可写流,提供状态码、响应头和响应体的写入方法。接下来逐一补上。

先设置状态码与响应头

没有另行设置时,响应状态码默认是200。若资源不存在,可以先改成404:

response.statusCode = 404; // Tell the client that the resource wasn't found.

响应头通过 setHeader 设置:

response.setHeader('Content-Type', 'application/json');
response.setHeader('X-Powered-By', 'bacon');

设置时,头字段名不区分大小写;对同一个头反复设置,最后的值会被发送。上面采用的是“隐式头部”:先保存状态和头部信息,让 Node.js 在开始写出响应体之前发送它们。

也可以使用 writeHead 明确提交状态码和头部:

response.writeHead(200, {
  'Content-Type': 'application/json',
  'X-Powered-By': 'bacon',
});

关键顺序不变:先确定状态和头部,再开始发送响应体。响应头已经发送之后,不能再依靠修改 statusCode 或 setHeader 改变客户端已经收到的内容。

写响应体,并结束响应

由于响应是一条可写流,可以分块调用 write,最后调用 end:

response.write('<html>');
response.write('<body>');
response.write('<h1>Hello, World!</h1>');
response.write('</body>');
response.write('</html>');
response.end();

end 也可以接收最后一段数据,因此上面的例子可简化为:

response.end('<html><body><h1>Hello, World!</h1></body></html>');

响应流同样可能发出 error 事件。对请求流的错误处理原则也适用于响应流:必须显式考虑并处理它,而不是只关注成功路径。

合在一起:以 JSON 回显请求

在原来的读取逻辑上加上响应,就能把收到的头部、方法、URL 和 body 组成一个对象,再用 JSON.stringify 发送给客户端:

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

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // BEGINNING OF NEW STUFF

        response.on('error', err => {
          console.error(err);
        });

        response.statusCode = 200;
        response.setHeader('Content-Type', 'application/json');
        // Note: the 2 lines above could be replaced with this next one:
        // response.writeHead(200, {'Content-Type': 'application/json'})

        const responseBody = { headers, method, url, body };

        response.write(JSON.stringify(responseBody));
        response.end();
        // Note: the 2 lines above could be replaced with this next one:
        // response.end(JSON.stringify(responseBody))

        // END OF NEW STUFF
      });
  })
  .listen(8080);

原文也指出,设置状态码与内容类型的两行,可以换成 writeHead(200, {'Content-Type': 'application/json'});最后的 write 加 end,可以换成 end(JSON.stringify(responseBody))。

编辑补充:这是观察 HTTP 数据流的教学工具。原样回传全部请求头和 body,可能暴露 Cookie、Authorization、口令或其他业务数据;不要把它部署成公开诊断端点,也不要把真实凭据发送给不受信任的 echo 服务。这里的错误监听仍以记录为主,不是完整的请求失败处理方案。

从聚合请求体改成流式 echo

先把上一段简化:服务器只把收到的请求体原样写回,不再构造 JSON:

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

http
  .createServer((request, response) => {
    let body = [];
    request
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        response.end(body);
      });
  })
  .listen(8080);

接着限定路由:只有方法为 POST 且 URL 恰好为 /echo 才回显,其他情况返回404:

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

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      let body = [];
      request
        .on('data', chunk => {
          body.push(chunk);
        })
        .on('end', () => {
          body = Buffer.concat(body).toString();
          response.end(body);
        });
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

依据 URL 决定处理方式,就是一种路由。路由可以简单到一个 switch,也可以由 Express 等框架提供;原文还提到只负责路由的 router 包。

编辑补充:这里比较的是完整 request.url 字符串,所以 /echo?x=1 不等于 /echo,会进入404分支。实际路由需要按明确的 URL 解析规则区分 pathname 与查询参数,并处理非法请求目标;不要简单去掉任意子串来“修复”匹配。

回忆一下,request 可读,response 可写。既然 echo 的目标就是把输入传到输出,那么用 pipe 直接连接它们即可,无需先把完整请求聚合成一个字符串:

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

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

管道会按流机制传递数据,省去了这里的整包聚合。不过这还没有结束:错误依然可能发生。原文的最终例子在请求错误时记录日志并尝试返回400,在响应错误时记录日志:

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

http
  .createServer((request, response) => {
    request.on('error', err => {
      console.error(err);
      response.statusCode = 400;
      response.end();
    });
    response.on('error', err => {
      console.error(err);
    });
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

在真实应用中,应该检查具体错误,再决定适合的状态码与消息,而不是把所有错误都解释成 Bad Request。尤其是流式输出已经开始之后,头部可能早已发出;若客户端断开,响应连接可能已经不可写,此时没有办法保证400还能送达。

编辑补充:错误处理要看响应所处阶段

下面是对原文请求错误监听器的局部修正示意:停止继续向响应管道传输,在尚未发送头、尚未结束且未销毁时才尝试写400,否则清理仍存活的响应。它用于说明状态检查,未经过运行验证,也不能替代完整的断连、超时、背压与资源回收设计。

request.on('error', err => {
  console.error(err);
  request.unpipe(response);
  if (!response.headersSent && !response.writableEnded && !response.destroyed) {
    response.statusCode = 400;
    response.end();
  } else if (!response.destroyed) {
    response.destroy();
  }
});

本段与原文的差异是增加了管道解绑和响应状态判断;原文最终完整例子仍保留在前面供核对。pipe 可以处理正常的数据传输节奏,但不代表它替你完成了所有错误传播、客户端取消、大小上限、超时和并发策略。日志同样需要控制敏感内容和体积。

现在可以沿着事务逐步推理

这篇指南覆盖了创建带请求处理函数的 HTTP 服务器、监听端口、读取请求头与方法路径、读取 body、做路由判断、设置状态与响应头、写入并结束响应、把请求流接到响应流,以及分别处理两端错误。遇到行为不确定的地方,应继续查阅 EventEmitter、Stream 和 HTTP API,并按目标 Node.js 版本验证。

原页未为这些片段指定固定 Node.js 版本;本文也未将其标为已经在当前 LTS 实测通过。把教学代码变成可公开提供服务的程序,还需围绕协议约束和资源限制完成设计。

来源、归属与翻译说明

原文:Anatomy of an HTTP Transaction;源页面编辑入口。归属 Node.js 文档贡献者,页面版权声明为“Copyright OpenJS Foundation and Node.js contributors. All rights reserved.”。当前页面未单独列出内容许可证,当前文档仓库首页也未展示许可证文件;不另行推断为 MIT 或 Creative Commons 许可。

中文翻译、模块语法合并、资源与错误边界提示、局部修正代码和示意图为本稿编辑工作,均不代表原作者对本稿或任何产品的背书。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容