做 Node 后端开发的朋友,日常打交道最多的其实是 Express、Koa 这类框架。可真到排查线上问题、做内部 mock 服务、或者写一些轻量级接口的时候,绕不开的还是 Node.js HTTP 模块本身。这个模块看着 API 少,但它是所有 Node Web 框架的地基,createServer 创建服务器、req/res 两个对象处理请求和响应、http.request 发起客户端调用,这些知识点放在一起,基本就能串起一个完整 HTTP 服务的生命周期。这篇文章我会按我实际使用的顺序,把这些内容从原理到坑位讲一遍,适合已经会写基础 Node 脚本、但想脱离框架重新理解底层实现的开发者。
1. 整体设计思路:为什么先掌握原生HTTP模块
1.1 Web框架和原生模块是什么关系
很多框架文档会跟你说“Express 就是对 http 模块的封装”,这话对,但不够具体。你打开 Express 源码,核心就是基于 Node 的 HTTP 模块再包了一层 Router、中间件机制和视图处理。Koa 也一样,只是把中间件模型改成了洋葱圈。它们替你做了路由匹配、body 解析、错误拦截这些事,但底层干的活,还是把 socket 上收到的字节流解析成 HTTP 报文,再给你一个 req 和一个 res。
这就带来一个很实际的问题:如果不懂原生 http 模块,出问题的时候你就不知道去哪一层排查。比如线上某个接口响应特别慢,你查了业务代码没毛病,实际上可能是 keep-alive 连接一直不释放,或者响应头里的 Content-Length 算错了导致客户端一直等服务端 close。这些现象不会出现在框架文档里,但全部能追溯到 http 模块的行为细节。
原生模块的价值不在于让你丢掉框架,而在于让你在框架失灵或者需要精细控制时,知道自己手里还有哪些零件可以用。公司网关、内部日志采集服务、临时 mock 服务,我用原生 http 模块写过不少,代码量少、行为直接、不依赖一坨 node_modules,部署的时候特别轻。
1.2 从事件驱动模型理解HTTP模块的运行机制
我们经常说 Node.js 是单线程事件驱动的,这个“事件驱动”在 http 模块里体现得特别明显。你写的回调函数不是被 Node 启动时立刻执行的,而是当某个请求到来、某个数据块到达、某个连接关闭时,由事件循环去触发对应回调。
当你调用 http.createServer(callback) 的时候,Node 其实是在内部创建了一个 net.Server,然后监听 connection 事件,每个进来的 TCP 连接都是一个 socket。HTTP 协议属于应用层,Node 会在这个 TCP 流上做一层解析:不断读取数据,直到解析出一个完整的请求头,然后触发 request 事件,也就是你写的那个 callback。
理解这个链路,你就能明白几个现象。第一,Node 能同时处理大量请求,不是因为开了很多线程,而是因为线程数量少,等待网络 I/O 时不占用 CPU。第二,如果你的回调函数里有耗时的同步操作,比如大文件读进来之后在内存里做复杂计算,那所有其他请求都得排队,因为同一时间事件循环只能做一件事。第三,请求里的 body 不是一次性出现在回调里的,而是分块到达的流,你需要显式去收集。这些原理听起来抽象,一旦你理解了,后边写代码时很多“怪现象”就自通了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 创建HTTP服务器:核心API与路由处理
2.1 最小可运行服务(createServer 与 listen)
创建一个 HTTP 服务器,核心就两个方法。createServer 接收一个请求监听函数,listen 让服务器开始监听某个端口。我见过不少朋友把 createServer 的回调叫成“路由函数”,严格说它叫 request listener,任何一个请求都会进入这里。
直接看最小实现:
js复制const http = require('node:http');
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('hello world');
});
server.listen(3000, '127.0.0.1', () => {
console.log('server is running at http://127.0.0.1:3000');
});
这段代码跑起来,浏览器打开 http://127.0.0.1:3000 就能看到文本。但几个细节我要单独说一下。
第一个,listen 的第二个参数是 host。写成 127.0.0.1 表示只允许本机访问,写成 0.0.0.0 才会监听所有可用网络接口,局域网内其他机器才能通过你这台机器的 IP 访问。如果你的服务部署在虚拟机、容器或者 WSL 环境里,经常出现“容器里能访问,宿主机访问不了”的情况,90% 和监听地址有关。我前面踩过一次,WSL 里跑了个 Node 服务,只监听了 localhost,宿主机 Windows 的浏览器始终打不开,后来改成 0.0.0.0 并确认端口映射,问题马上消失。这个在本文第五节还会展开。
第二个,listen 回调只表示端口已经成功监听,不代表服务器不会出错。假如 3000 端口已经被占用,listen 会触发 error 事件,你需要在 server 对象上捕获它,否则进程直接抛异常退出,对新手特别不友好。
js复制server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error('端口 3000 已被占用,请换一个端口');
}
});
第三个,res.end('hello world') 这句话其实是“写入响应体并结束响应”。如果不调用 end,客户端会一直等待,直到超时。
2.2 理解req和res:最关键的对象
req 是 IncomingMessage 的实例,res 是 ServerResponse 的实例。我们和 HTTP 协议打交道,本质就是读写这两个对象。
req 上高频用的属性,我整理成一个速查表。
| 属性 | 作用 | 注意事项 |
|---|---|---|
| req.method | 请求方法,比如 GET、POST | 全大写 |
| req.url | 原始 URL,包含路径和查询字符串 | 不会包含协议和域名 |
| req.headers | 请求头对象 | 所有键名自动转小写 |
| req.httpVersion | HTTP 版本,比如 1.1 | 极少直接关注 |
| req.socket | 底层 TCP socket | 可用来拿远端 IP,但不建议长期持有 |
你从 req.url 拿到的字符串是带查询参数的,比如 /api/users?page=1。要解析它,最干净的方式是使用 WHATWG URL 对象:
js复制const http = require('node:http');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:3000');
console.log('路径:', url.pathname);
console.log('查询参数:', url.searchParams.get('page'));
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ path: url.pathname, query: Object.fromEntries(url.searchParams) }));
});
server.listen(3000);
我在实际项目中用 new URL(req.url, 'http://localhost') 这种写法比较多,第二个参数填什么 base 其实无所谓,因为 req.url 本身就是 path + query 的格式,补一个假的 base 只是为了能顺利解析成 URL 对象。
res 上高频用的 API 也是那几个:setHeader 设置响应头,statusCode 修改状态码,writeHead 一次性写状态码和多个响应头,write 写一部分响应体,end 结束响应。writeHead 和 setHeader 是可以混合使用的,执行顺序是先 setHeader 再 writeHead,writeHead 里没有的 header 会保留 setHeader 设置过的。但如果你先 write 再 writeHead,会抛错,因为响应头必须出现在正文之前。
关于响应体,有个很多人忽略的细节:res.write 可以调用多次,比如你要边读数据库边把数据推给客户端,就适合分块写。但几乎所有场景最后都要有 res.end(),否则响应永远不结束。不传参数调用 res.end() 表示结束,传字符串则代表写入最后一段数据并结束。
2.3 路由处理和参数获取
原生 http 模块没有路由表的概念,所以收到任何 URL 都得自己去判断。判断方式其实很朴素:先比对方法,再做路径匹配,路径参数用正则或者字符串拆分。
我来写一个常见的用户接口示例:
js复制const http = require('node:http');
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const pathname = url.pathname;
// 简单路由:GET /users
if (req.method === 'GET' && pathname === '/users') {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ code: 0, data: [{ id: 1, name: '张三' }] }));
return;
}
// 参数路由:GET /users/123
const match = pathname.match(/^\/users\/(\d+)$/);
if (req.method === 'GET' && match) {
const userId = match[1];
res.end(JSON.stringify({ code: 0, data: { id: userId } }));
return;
}
res.statusCode = 404;
res.end('Not Found');
});
server.listen(3000);
这里背后有几个决策点。第一,为什么用正则而不是 split?因为 /users/123/extra 也会被 split 误判,正则能精确表达路径形状。第二,为什么每个 if 后面都 return?避免后续逻辑重复执行,也避免一个请求匹配多个路由后 res.end 被调用多次。
你可能会问,如果项目大了路由很多,手写这套会不会太累?会。这也是为什么框架存在。但从另一个角度看,当你用 Express 时,本质也是给 app.get('path', handler) 挂函数;一旦你明白原生写法,去阅读框架文档时就不是背 API,而是看它如何封装你已掌握的套路。
3. 请求体解析与响应输出进阶
3.1 把body安全地读完整
在真实业务里,POST 请求几乎都要读 body。但原生 req 是个流,它不会主动帮你把数据攒好。你需要监听 data 事件收集 Buffer 片段,监听 end 事件表示全部到达。新手最容易踩的坑是以为回调参数里直接带 body,结果 data 事件回调触发一次就开始处理,导致 body 被截断。
简单的读取方法:
js复制const http = require('node:http');
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify({ received: body }));
});
req.on('error', (err) => {
console.error('读取请求体失败', err);
res.statusCode = 400;
res.end('Bad Request');
});
});
server.listen(3000);
这段代码能用,但不适合直接上生产。因为如果你不给 body 大小设上限,攻击者或者异常客户端可以源源不断往你服务器发数据,内存就会一路涨上去。我给内部服务写原生接口时,一定会加一个大小限制,超过限制直接返回 413:
js复制const MAX_BODY_SIZE = 1024 * 1024; // 1MB
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_SIZE) {
reject(Object.assign(new Error('Request body too large'), { code: 413 }));
return;
}
chunks.push(chunk);
});
req.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', reject);
});
}
在使用时,如果 reject 发生了,你需要在 catch 里返回 413 并结束响应。有些朋友在这个环节直接 req.destroy(),把整个连接断了,其实客户端连状态码都收不到,体验很差。比较优雅的做法是先拿到错误码,在错误码为 413 时正常写一个响应头,再关闭连接。
另外,如果你确定请求体是 JSON,后端还需要处理“空 body”的情况。很多客户端比如 curl 发 POST 但不带数据时,body 是空字符串,直接 JSON.parse('') 会抛错。实际项目里我习惯先判断 body 是否为空,再决定解析;解析失败了返回 400,而不是让进程崩溃。
3.2 响应头与MIME的正确输出
res.end 只能接收 string 或 Buffer,你要响应一段 JSON,就必须手动 JSON.stringify。同时,响应头里的 Content-Type 必须设置对。我见过不少人只设置 application/json,没有加 charset=utf-8。这在处理中文时会触发一个比较隐蔽的问题:响应体是 UTF-8 编码的,但客户端按 ISO-8859-1 解码,首页直接乱码。把 charset=utf-8 写全,几乎能省掉所有这类排查时间。
我习惯封装两个响应函数:
js复制function sendJSON(res, statusCode, data) {
const body = JSON.stringify(data);
const buffer = Buffer.from(body, 'utf8');
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': buffer.length,
});
res.end(buffer);
}
function sendText(res, statusCode, text) {
const buffer = Buffer.from(text, 'utf8');
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': buffer.length,
});
res.end(buffer);
}
注意里面的 Content-Length 我用的是 Buffer.byteLength 以后再写入,而不是直接传 body.length。原因是一个中文字符在 string.length 里只占 1 位,但在 UTF-8 缓冲区里占 3 个字节。如果 Content-Length 比真实字节数少,客户端会一直等后续数据,直到连接超时。这个坑很常见,尤其是在返回中文字符串时,症状是浏览器内容显示不出来,网络面板显示 pending。
除了 JSON 和纯文本,常见的 MIME 类型还有 text/html、image/png、application/octet-stream 等。如果你要返回一个静态文件,最省事的办法是直接用 fs 读完文件内容,再根据扩展名设置 Content-Type。那种方式能跑,但大文件会占内存。更好的做法是配合流来输出,这里先不多说,后续需要再单独开文章。
3.3 响应过程中的异常拦截
响应过程中最容易出现的异常有两类。一类是 write 之后底层连接已经断开,你继续调用 res.end,会触发 ERR_STREAM_WRITE_AFTER_END 之类的错误。另一类是客户端只发一半请求就断开,比如移动端弱网场景下,req 上会触发 aborted 事件,如果你还在读请求体,会得到一个错误。
处理思路分两层。第一层,细粒度地监听 res 的 close 事件。当响应关闭时,设置一个标识位,后续逻辑判断是否还能继续写。第二层,把业务处理包在 try/catch 里,遇到未知异常时,如果不能确定响应是否已经结束,尽量使用 res.destroy() 而不是强行 writeHead。
js复制res.on('close', () => {
if (!res.writableEnded) {
console.log('客户端提前断开,响应已中断');
}
});
这个检查对审计日志特别有用。服务端日志里看到某个请求超时,并不一定是处理逻辑慢,很可能是客户端断连了,而你还在慢吞吞处理业务数据。把这类信息打出来,排查问题能节省大量时间。
4. 客户端请求:如何从你的Node进程往下走
4.1 http.request与http.get的用法
Node 的 http 模块不仅能当服务器,也能当客户端去请求其他接口。这在微服务架构里几乎是必需技能:A 服务在处理用户请求时,可能要调用 B 服务的接口获取数据,这里就用到了 http.request 或 http.get。
http.get 是 request 的简化版,专门用于不带请求体的 GET 请求,默认就会调用 req.end(),不用你手动结束请求。
js复制const http = require('node:http');
const req = http.get('http://127.0.0.1:3000/api/users', (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
console.log('状态码:', res.statusCode);
console.log('响应体:', body);
});
});
req.on('error', (err) => {
console.error('请求出错:', err.message);
});
注意 http.get 的第一个参数可以是 URL 字符串,也可以是一个 options 对象。如果传字符串,Node 自动帮你解析 host、path。如果你的接口是 HTTPS,那就不能用 http 模组,需要换 https 模块,API 结构几乎一样。
需要更精细控制请求头、方法、超时的时候,我会直接写 http.request:
js复制const http = require('node:http');
const options = {
hostname: '127.0.0.1',
port: 3000,
path: '/api/orders',
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': 'internal-node-client/1.0',
},
timeout: 5000,
};
const req = http.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
console.log('接口返回:', Buffer.concat(chunks).toString('utf8'));
});
});
req.on('timeout', () => {
console.error('请求超时');
req.destroy();
});
req.on('error', (err) => {
console.error('请求失败:', err.message);
});
req.write(JSON.stringify({ orderId: 123 }));
req.end();
这段代码里有几个我特别想强调的点。timeout 选项设置的是 socket 超时时间,到时不一定会自动报错,需要你监听 timeout 事件再手动 req.destroy(),否则请求可能半悬空。还有 req.write 之后一定要调 req.end(),服务端才会认为请求体发送完毕,才能开始处理并返回响应。如果你只写不 end,服务端会一直在等你会不会还有后续数据,直到它的超时时间到了才断开,表现为你的客户端接口“卡了十几秒”。
4.2 keep-alive、连接复用与服务间请求场景
在服务端发起客户端请求时,性能瓶颈经常出现在频繁创建新连接上。HTTP 本身基于 TCP,每发起一次请求,如果没有复用连接,就要走一遍 TCP 三次握手,再加上 TLS 握手(如果是 HTTPS),开销非常大。
Node 的 http 模块默认带了一个全局 Agent,它的 keepAlive 属性默认是 false。也就是说,你直接用 http.request 请求同一个服务器多次,每次可能都要新建连接。在内部服务互相调用的场景里,这个开销不能忍。解决办法是显式设置 Agent:
js复制const http = require('node:http');
const agent = new http.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 20,
timeout: 60000,
});
const options = {
hostname: 'order-service.internal',
port: 8080,
path: '/api/orders',
agent,
};
keepAlive: true 表示 TCP 连接在完成一次请求后不立即关闭,而是放进空闲池里,继续给下一个到同一 host 的请求复用。maxSockets 控制同一时间一个 host 最多建立多少并发连接,超过的请求会排队。maxFreeSockets 控制空闲连接最多保留多少。这几个参数调好后,内部服务之间的高耗时连接开销能明显下降。
如果只是偶尔发一两次请求,用默认 agent 问题不大;但如果你在写一个定时任务,每秒钟要轮询 100 个用户的状态,最好把 keep-alive agent 提前配好,否则连接建立的时间可能比实际请求处理时间还长。
4.3 Node 18之后的选择:全局fetch和第三方库
上边都是基于 http 老牌 API。如果你用的是 Node 18 或更高版本,其实还有一个选择:全局 fetch。它在内部基于 undici 实现,接口样式和浏览器一致,代码写起来要现代很多。
js复制async function getUser(id) {
const res = await fetch(`http://127.0.0.1:3000/api/users/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
}
我实际项目里用 fetch 的场景是快速搭业务脚本、做接口联调测试。但要提醒的是,fetch 的默认行为也有些不那么“眼见为实”的点。比如连接复用,fetch 底层默认会复用连接,但要配置 keep-alive 的时长,需要手动创建 undici 的 Agent 才能精细控制;再比如超时,fetch 没有默认超时,必须配合 AbortController 实现取消。如果一段代码只需要请求一次,顺手用 fetch 很舒服。如果做高并发、需要精细超时控制,我个人还是偏向用 http.request 底层 API,这种模块行为最可控。
第三方库方面,axios 在 Node 里的适配器同样基于 http 或 https 模块。所以你不必因为看到 axios 就以为它是另一个网络引擎,它只是把 http.request 换成了 Promise 和一些拦截器语法。
5. 部署试用与排错的关键几点
5.1 监听地址与EADDRINUSE:最常见的启动问题
有朋友在本地跑 Node 服务一切正常,但放到虚拟机或容器、WSL 环境里就各种连不上。我要先把监听地址的坑再强调一次。server.listen(3000) 这种写法在不传 host 时,等价于监听 :: 或 0.0.0.0,取决于系统默认,通常所有接口都能访问;但一旦你写成 server.listen(3000, '127.0.0.1'),那就只有本机能访问。
在 WSL2 里,Windows 和 Linux 之间有独立的网络命名空间,Linux 服务监听 localhost,宿主机浏览器通常无法直接通过 localhost 访问。常见解法是改监听 0.0.0.0,再确认 Windows 侧能映射到 WSL 的 IP。如果还不行,检查 Windows 防火墙有没有拦截相关端口。这是环境网络配置问题,代码本身不需要改动。
启动阶段另一个高频错误是 EADDRINUSE。这种情况十有八九是上一个进程没退干净,或者你关掉终端后服务还在后台。排查顺序是:
bash复制lsof -i :3000
或者:
bash复制netstat -tunlp | grep 3000
看到进程 PID 后,执行 kill -9 PID。如果这是线上机器,不要直接 kill,先看一下进程命令行是不是你自己的服务。我见过同事误杀了别人正在跑的任务,教训很深刻。
5.2 请求日志与状态码排查
服务写完了,怎么确认它确实按预期响应了?我的习惯是在入口处打一行结构化日志:
js复制const http = require('node:http');
const server = http.createServer((req, res) => {
const start = Date.now();
res.on('finish', () => {
console.log(JSON.stringify({
time: new Date().toISOString(),
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start,
}));
});
// 业务逻辑...
});
这里监听的是 res 的 finish 事件,这个事件在响应完整发送给操作系统后触发,比业务代码里主动打日志要准。这样你在终端看到的日志,基本能还原出一行行完整的“客户端 -> 服务器”请求记录,包括哪个 URL、多少毫秒、返回什么状态码。定位性能问题时,先看 duration 字段,如果单个请求特别慢,再往下钻业务代码,效率高很多。
如果你只想用命令行为主测试一下,curl 是万能的:
bash复制curl -i -X POST http://127.0.0.1:3000/api/users -H 'Content-Type: application/json' -d '{"name":"张三"}'
-i 会把响应头也显示出来,能直观看到 Content-Type、Content-Length 是不是符合预期。
5.3 一个小压测工具与并发观察
原生 http 模块经常被人拿来做“极简服务器”,但它能撑多少并发?这个最好实测。压测工具常用的有 autocannon、ab、wrk。autocannon 是 Node 生态的,安装和使用都很简单:
bash复制npx autocannon -c 50 -d 10 http://127.0.0.1:3000/api/users
这条命令表示模拟 50 个并发连接,持续打压 10 秒。执行完会报告每秒请求数、延迟分布。如果压测时发现 Node 进程 CPU 占用率特别高,日志打印密集,并且吞吐量很低,大概率是你的回调函数里存在同步阻塞逻辑。这时候回到第一部分的“事件驱动模型”去思考,找到耗时的那段操作,看能不能改成异步,或者拆给其他队列处理。
压测时还有个细节:直接压生产服务要小心,最好在预发布环境或者本机做。原生 http 服务如果不额外做保护,很容易被高并发打满文件描述符或内存,我吃过几次亏,都是压测完服务进程被系统 OOM Kill。
6. 日常编码里的几个额外建议
6.1 设置超时与保护资源
默认情况下,一个 HTTP 请求如果既不结束,也不断开,Node 不会主动帮你清理。体现在服务端,就是大量半打开的长连接占用着文件描述符。所以我的经验是,创建真实服务时手动设置 server.timeout 和 keepAliveTimeout。
js复制server.requestTimeout = 30000;
server.headersTimeout = 30000;
server.timeout = 0;
server.keepAliveTimeout = 5000;
headersTimeout 防止客户端慢慢悠悠发送请求头占着连接,requestTimeout 防止请求体长时间发送不完,keepAliveTimeout 决定空闲连接多久关闭。这几个参数在 Node.js 高版本里的默认值已经比较安全,但如果你想精细控制,手动设置最稳。
6.2 用流响应大文件
我在第三节提到大文件响应最好不要直接 readFile 塞到一个变量里,更优雅的做法是把文件流接到 res 上:
js复制const fs = require('node:fs');
const http = require('node:http');
const server = http.createServer((req, res) => {
const filePath = './public/readme.md';
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
fs.createReadStream(filePath).pipe(res);
});
server.listen(3000);
原理很简单:文件流按块从磁盘读进内存,再通过管道直接写到响应 socket,不需要把整个文件放在内存里。这样处理几百 MB 的文件时,Node 进程的常驻内存不会跟着文件一起膨胀。这也是事件驱动配合流的典型用法,比 res.end(fs.readFileSync(...)) 高级在内存效率上。
6.3 架构上的小小取舍
最后说说我现在的个人选择。日常业务接口,我会继续用框架,因为路由、鉴权、参数校验这些功能自己手写一遍成本太高。但如果是内网小工具、临时调试服务、需要极低依赖的模块,我会直接使用原生 http 模块。同时,服务间的组件我也会动手写一些原生的客户端请求封装,因为这样才能在超时、重试、连接池上做到完全可控。
自从理解了 Node.js HTTP 模块,再看框架文档时我发现自己的视角变了,不再停留于“这样调就能跑”,而是能判断框架里哪些设计在解决什么问题。比如中间件机制中的 next 调用,其实就是为了让请求依次经过多个处理函数,最终把响应返回给客户端。这个认知框架会一直陪伴你排查 node 服务端问题,我确信哪一层出问题需要先看这层的日志和统计。
