侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

手写 Node 服务的 9 项接口自检,跑一遍就知道漏了什么

2026-9-16 / 0 评论 / 3 阅读

手写 Node 服务的 9 项接口自检,跑一遍就知道漏了什么

用一个 http 模块起服务,能返回数据不等于接口是完整的。下面九条检查项都对着同一个最小实现跑过,脚本存档 tools/verify-api-checklist.mjs,服务监听 127.0.0.1:8399,跑完自己退出,环境是 Node v26.8.1。每条给一句结论和一行最小补救代码,完整输出放在最后。

1. HEAD 请求不会替你补长度

Node 收到 HEAD 时不会写出 body,但也不会替你算 Content-Length,客户端拿到的是一个没有长度信息的空响应,只能等连接关闭才知道结束。抓包时这类响应看着像被截断了。

res.setHeader("Content-Length", Buffer.byteLength(body));

2. 没有兜底分支,未知路径会一直挂着

handler 里用 if 判断路径、没写 else 时,请求不会被拒绝,连接就这么挂着,直到客户端自己超时。实测里客户端 1200 ms 就放弃了,服务端这边一条日志都没有,因为没有任何分支接住这个请求。

res.writeHead(404, { "Content-Type": "application/json" }).end('{"error":"not_found"}');

3. 只读接口收到 POST 会照常返回 200

只按路径判断的 handler 不管请求方法,POST /api/user 一样拿到用户数据,状态码 200。方法判断放在路径判断之后是最省事的写法,也最容易漏,补救时顺手带个 Allow 头,调用方才知道这个路径支持什么。

if (req.method !== "GET") { res.writeHead(405, { Allow: "GET" }).end(); return; }

4. 只允许 POST 的接口收到 GET 也是 200

写接口那条分支只看路径时,GET /api/order/create 会走到同一个 handler,读到的 body 长度是 0,接口照常返回成功。麻烦之处在于返回的结构和正常写入时一模一样,调用方很容易以为数据已经进去了。

if (req.method !== "POST") { res.writeHead(405, { Allow: "POST" }).end(); return; }

5. 跨域预检会被路径分支吞掉

OPTIONS 请求命中了业务分支,状态码 200,跨域相关的响应头一个都没有,浏览器那边的预检直接失败。这类错误通常只在浏览器控制台里冒出跨域提示,服务端不报错,日志也干净,查起来容易歪到前端去。

if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET,POST", "Access-Control-Allow-Headers": "Content-Type" }).end(); return; }

6. 客户端声明支持 gzip,服务端不表态就按原样发

请求头里的 accept-encoding 需要服务端主动回应,忽略它不会报错,只是每个响应都裸传。要不要压得看响应体大小,几百字节的 JSON 压完可能还更长,通常设一个 1 KiB 左右的阈值,级别用默认值就够。

if (/gzip/.test(req.headers["accept-encoding"] || "")) { body = zlib.gzipSync(body); res.setHeader("Content-Encoding", "gzip"); }

7. 没有 ETag,静态资源每次都是 200 全量

响应里没有 ETag 也没有 Cache-Control,浏览器就没有第二次条件请求的机会,同一个文件每次请求都重新传一遍。ETag 用内容的哈希比较稳,用文件修改时间在容器里会翻车,同一次构建产出的时间戳每次都不同,304 就永远命不中。

res.setHeader("ETag", etag); if (req.headers["if-none-match"] === etag) return res.writeHead(304).end();

8. 请求体没有上限,内存跟着请求体走

服务端不设阈值时,5 MiB 的请求体会被完整收下并累加进内存,接口返回的成功信息里还带着这个字节数。超限之后要主动断连接,只回一个 413 的话,客户端还会继续把剩下的字节发完。

if (size > 1 << 20) { req.destroy(); res.writeHead(413).end(); return; }

9. 慢接口不设客户端超时就会一直等

服务端 3005 ms 才回,客户端不设超时的话请求就守着这条连接;加上 300 ms 的超时以后,302 ms 就断开了。服务端的处理超时和客户端的等待超时两处都要设,而且服务端那个数字要更小,否则客户端先断开,服务端还在白跑一段没人要的计算。

const res = await fetch(url, { signal: AbortSignal.timeout(300) });

完整输出

服务已启动 http://127.0.0.1:8399

[1] HEAD 请求会不会被当成 GET 处理
  HEAD 状态码 200,content-length 头 null,实际读到 0 字节

[2] 路径写错时服务怎么回
  客户端 1200ms 超时:TimeoutError: The operation was aborted due to timeout

[3] 只读接口收到 POST
  POST /api/user 状态码 200,响应体 {"id":1,"name":"kim"}

[4] 只需要 POST 的接口收到 GET
  GET /api/order/create 状态码 200,响应体 {"ok":true,"received":0}

[5] 浏览器跨域预检
  OPTIONS 状态码 200,跨域响应头 一个都没有

[6] 客户端声明支持 gzip
  content-encoding 头 null,传输长度 21

[7] 静态资源的缓存头
  第一次 200,etag null,cache-control null
  第二次 200(没有 ETag 就没有 304 的机会)

[8] 请求体没有上限
  提交 5 MiB 请求体,服务端回 {"ok":true,"received":5242880}

[9] 慢接口与客户端超时
  不设超时:200,耗时 3005 ms
  设 300ms 超时:TimeoutError,耗时 302 ms

清单跑完,服务已关闭。

说明

这九条是自查项,不是安全审计,第八条和第九条只是提醒两头的边界都要自己设。清单按我自己的经验排,最容易漏的是缓存那条:本地开发看不清差别,挪到带 CDN 的环境才发现静态资源每次都在回源。把这份脚本挂进 CI 当冒烟测试,比每次手工点开浏览器翻一遍省事,它整趟跑下来只要几秒。

    🤞 分享