一、分类的意义不在于罗列,而在于划清边界
把Node.js的API分成HTTP、WebSocket、文件系统、数据库、流、错误处理六类,本身没有技术难度。有价值的问题是:每一类中,哪些能力由Node.js原生模块提供,哪些由框架或第三方库承担,两者之间的接缝在哪里。混淆这个边界,是许多项目在选型和排查问题时走弯路的根源。
以HTTP为例。Node.js的http模块提供的是协议层面的能力:创建服务端、发起请求、解析报文。它不提供路由、中间件、请求体JSON解析。Express的express.json()中间件负责的是“读取请求流并解析为req.body”这一层,它只在Content-Type匹配application/json时生效,其他类型的请求体不会自动解析。如果把“Node.js能处理HTTP”理解为“Node.js自带完整的Web开发能力”,就会在需要路由和校验时误以为缺失了某个内置模块。
二、HTTP方法:语义差异比实现细节更需要关注
GET、POST、PUT、PATCH、DELETE在Node.js服务端代码中的实现形式没有本质区别:都是req.method的字符串判断加上业务逻辑分支。真正的区别在语义层面,而语义决定了API契约的设计。
PUT和PATCH的混用是常见问题。PUT的语义是整体替换:客户端发送完整的资源表示,服务端用这个表示覆盖原资源。PATCH的语义是部分更新:客户端只发送需要变更的字段。用PUT实现“只改邮箱”的操作,在语义上要求客户端发送用户的其他所有字段,否则未发送的字段应当被视为删除或置空。如果服务端用PUT路由但只更新传入的字段,行为上接近PATCH,却挂着PUT的名称,对接方按标准语义理解时会出问题。
POST与PUT的另一个边界是幂等性。PUT是幂等的:同一个请求执行一次和执行多次,资源状态一致。POST不保证幂等。在重试逻辑普遍存在的客户端代码中,把非幂等操作放在PUT路由下,重试可能导致重复创建或重复扣款。这类问题不会在Node.js层面报错,但会在业务层面产生难以追溯的异常。
三、WebSocket:Node.js没有内置,Express也没有
http模块提供了'upgrade'事件,允许开发者接管HTTP升级请求。但WebSocket的帧协议(RFC6455)、连接管理、ping/pong心跳、消息分片,都不在Node.js核心模块中。Express同样不包含这些能力。
实际项目中的组合方式是:ws库挂载到同一个http.Server上,通过newWebSocketServer({server})或{noServer:true}接管upgrade事件。ws负责协议帧的解析和连接生命周期,Express继续处理普通HTTP路由。两者共享端口,互不干扰。
一个在鉴权设计上容易出错的地方:Express的中间件不会在WebSocket连接建立后继续运行。app.use(authMiddleware)只对HTTP请求生效,WebSocket握手完成后的消息帧不经过Express的中间件链。鉴权必须在upgrade事件或WebSocketServer的connection回调中完成,而不是依赖路由级别的中间件。同样,ws默认的maxPayload是100MiB,在消息体积可控的场景中应当下调这个值,避免单条异常消息耗尽内存。
心跳机制不是可选项。TCP半开连接(客户端断电、切换网络)不会触发close事件,连接对象会一直留在wss.clients中。定期的ping/pong检测和terminate()清理是长连接服务的必要逻辑。
四、文件系统与流:.pipe()的错误传播缺陷
文件系统API和流API经常一起出现,但两者在错误处理上的成熟度不同。
fs.createReadStream()和fs.createWriteStream()返回的流对象,在使用.pipe()连接时,错误不会自动从源流传到目标流。如果读取流在读取过程中触发error事件,写入流不会收到通知,也不会被销毁。数据可能在无提示的情况下截断。
stream.pipeline()的设计目标就是解决这个问题。它在所有流之间传播错误,并在结束时销毁未完成的流。官方文档的示例展示了从fs.createReadStream经过zlib.createGzip到fs.createWriteStream的压缩管道,并强调pipeline会在失败时调用stream.destroy(err)清理所有未完成的流。对于文件上传、下载、转换这类多阶段操作,使用pipeline而非裸.pipe()是更稳妥的选择。
pipeline的一个已知限制:在回调被调用后,它会在流上留下悬挂的事件监听器。如果失败后复用同一个流对象,可能造成监听器泄漏。但对于fs创建的流,通常是一次性的,这个限制在使用中影响有限。
五、数据库:原生驱动与ORM的职责分层
Node.js核心模块不包含数据库驱动。pg、mysql2、mongodb等驱动由社区维护,ORM(Prisma、Sequelize、MikroORM、Objection)构建在驱动之上。
两者的分工可以用一个简单的标准判断:如果业务逻辑的核心是“执行查询并处理结果”,驱动足够。如果核心是“维护数据模型的约束和关系”,ORM提供的是模型层的能力。但ORM的抽象层有成本。一项针对PostgreSQL和MySQL的基准测试显示,在深度抓取(关联查询并组装嵌套对象)场景中,原生驱动的吞吐量显著高于ORM,差距在PostgreSQL上达到约7倍,MySQL上约5倍。ORM的价值在于开发效率和模型约束,而非性能。
在选型时,一个务实的做法是:写操作密集、模型关系复杂的业务使用ORM或查询构建器;读操作密集、对延迟敏感的内部接口使用原生驱动或轻量查询层。混用不是禁忌,关键是清楚每一层在做什么。
六、错误处理:三类机制的职责不能混用
Node.js的错误处理至少有三种形态,分别对应不同的场景。
同步/异步回调错误:try/catch捕获同步异常,callback(err,result)处理异步操作失败。这是最基础的层面。
Promise拒绝:async/await配合try/catch,或.catch()。fs.promises、stream/promises的pipeline都走这条路径。
事件发射的error事件:流、Socket、HTTP请求对象在运行时错误通过error事件发出。如果没有注册监听器,Node.js会抛出未捕获异常并终止进程。这不是可以忽略的警告,而是进程级的失败信号。
把这三类混用的典型错误是:在pipeline返回的Promise上使用.catch(),但同时又在某个中间流上注册了error监听器。两套机制同时存在时,错误可能被其中一个捕获后不再传播到另一个,导致某些失败路径被静默吞掉。
Undici(Node.js内置的fetch实现所依赖的HTTP客户端)的错误体系提供了一个可供参考的设计:所有错误对象都有稳定的code属性,如UND_ERR_CONNECT_TIMEOUT、UND_ERR_SOCKET,文档明确建议使用error.code==='...'而不是instanceof来判断错误类型,以避免多版本或不同分发渠道导致的构造器不一致。这个建议在跨模块、跨版本的错误处理中同样适用。
七、示例:一条请求在各类API中的流转
以下示例用一个最小场景串起HTTP请求、流处理与错误传播。示例中的code_number为代码号学习编程的标识。
const http = require('node:http');
const fs = require('node:fs');
const { pipeline } = require('node:stream/promises');
const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/code_number/file') {
try {
await pipeline(
fs.createReadStream('./code_number_data.txt'),
res
);
} catch (err) {
// pipeline 会销毁 res,此处仅记录
console.error('传输失败:', err.code);
}
return;
}
if (req.method === 'PATCH' && req.url === '/code_number/user') {
let body = '';
for await (const chunk of req) body += chunk;
// PATCH 语义:仅更新传入字段
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ updated: Object.keys(JSON.parse(body)) }));
return;
}
res.writeHead(405);
res.end();
});
server.listen(3000);
这个示例中,GET路由使用pipeline而非readStream.pipe(res),理由在于当文件读取失败时,pipeline会销毁响应流,客户端收到的是连接中断而非一个不完整的200响应。PATCH路由的注释强调了语义层面的一致性,而非实现细节。
八、常见误区速查
以为Express内置WebSocket。不内置。需要ws或Socket.IO,并挂载到http.Server上。
以为.pipe()会自动传播错误。不会。源流的error事件不会触发目标流的清理。多流串联用stream.pipeline。
以为PUT可以替代PATCH做部分更新。语义上不行。PUT是整体替换,对接方按标准理解时会期望未发送字段被清除。
以为Express中间件能拦截WebSocket消息。不能。中间件只处理HTTP请求,WebSocket鉴权在upgrade或connection阶段完成。
九、练习与思考
-
一个使用
fs.createReadStream().pipe(res)提供文件下载的接口,在文件读取到一半时磁盘出错。客户端会收到什么?换成stream.pipeline后有什么不同? -
如果WebSocket连接在握手成功后、
connection事件触发前被中间代理断开,ws的wss.clients中是否会残留这个连接?心跳机制需要如何设计才能覆盖这个窗口? -
在数据库访问层,什么情况下应该绕过ORM直接使用驱动?给出一个具体的业务判断标准,而非“性能要求高”这样的笼统表述。
十、延伸阅读与参考文献
Node.js官方文档HTTP模块,r2.nodejs.org,包含Agent的keepAlive、maxSockets、freeSockets等连接池配置的原始定义,可作为理解HTTP客户端连接复用机制的底层参考。https://r2.nodejs.org/docs/v9.10.1/api/http.json
Node.js官方文档Stream模块pipeline部分,r2.nodejs.org,提供stream.pipeline的错误传播规则以及它调用stream.destroy(err)清理未完成流的行为说明。https://r2.nodejs.org/download/test/v15.0.0-test2020-07-24221167a148/docs/api/stream.json
MojoAuthDevGuides“Express WebSocket Server in Node.js”,说明Express不内置WebSocket支持,ws库挂载到http.Server的方式,以及中间件不作用于WebSocket消息帧的边界。https://mojoauth.com/dev-guides/create-a-websocket-server-with-expressjs
观测云“Express+WebSocket实时通信指南”,记录TCP半开连接不会触发close事件、心跳ping/pong清理僵尸连接的实际做法,以及wss.clients的连接管理注意事项。https://www.guance.com/learn/articles/express-websockets
MojoAuthDevGuides“Express JSON: express.json() and res.json()”,说明express.json()仅在Content-Type:application/json时解析请求体,以及Express5中未匹配时req.body为undefined的行为。https://mojoauth.com/dev-guides/serialize-and-deserialize-json-with-expressjs
Zenodo数据库驱动与ORM性能对比研究,提供PostgreSQL和MySQL上原生驱动与多种ORM在深度抓取场景的吞吐量数据,原生驱动领先约5至7倍。https://zenodo.org/records/21736481
Undici官方错误文档,列出UND_ERR_*系列错误码,并建议使用error.code而非instanceof做错误类型判断,避免多版本构造器不一致问题。https://undici.nodejs.org/v7.x/api/Errors