本节摘要:http 模块是 Express/Koa 们脚下的地基。本节不借任何框架,手写一个能路由、能读请求体、能返回 JSON 与文件的完整服务器,再做 http 客户端与并发压测实验,最后讲透 keep-alive 与安全头这两个工程必知点。读完再看任何 Node Web 框架,你看到的都是本节代码的再包装。
第 1 章的七行服务器只有一句问候。真实的接口至少要区分方法、路径,并返回结构化数据。先看请求对象里有什么:
const http = require('http'); const server = http.createServer((req, res) => { // req:http.IncomingMessage,可读流 console.log(req.method); // GET / POST / PUT ... console.log(req.url); // 路径与查询串,如 /api/user?id=7 console.log(req.headers); // 全部请求头(注意都是小写键) // res:http.ServerResponse,可写流 res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok: true })); }); server.listen(3000);
req 是流这件事是全节的钥匙:请求体要靠监听 data 事件攒出来,不存在 req.body 这种现成属性(Express 的 body-parser 中间件干的就是这件事)。读取 POST JSON 的标准姿势:
function readBody(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch (e) { reject(e); } }); req.on('error', reject); }); }
组装成带路由表的服务:
const routes = { 'GET /api/time': (req, res) => { res.end(JSON.stringify({ now: Date.now() })); }, 'POST /api/echo': async (req, res) => { const body = await readBody(req); res.end(JSON.stringify(body)); }, }; const server = http.createServer(async (req, res) => { const u = new URL(req.url, 'http://localhost'); const handler = routes[`${req.method} ${u.pathname}`]; if (!handler) { res.writeHead(404).end('没有这个接口'); return; } try { await handler(req, res); } catch (e) { res.writeHead(500).end('服务器内部错误'); } });
一个容易忽视但必须养成的习惯:handler 一旦抛错,必须兜底返回 500。否则响应悬而不决,客户端等到超时,而你只在服务端日志里看到未捕获异常。
返回磁盘上的文件,正确做法是把文件流接到响应流上,而不是先 readFile 再 end:
const fs = require('fs'); const path = require('path'); function serveFile(res, filePath) { const stream = fs.createReadStream(filePath); stream.on('error', () => { res.writeHead(404).end('文件不存在'); }); res.writeHead(200, { 'Content-Type': guessType(filePath) }); stream.pipe(res); // 数据分段流过去,内存恒定 }
pipe 一行完成了「data 事件 → res.write」的自动搬运,并顺带在源流结束时自动 end 响应。这里埋一个伏笔:当客户端带宽远小于磁盘速度时,pipe 内部其实在默默做背压调节——细节是 3.4 节的主菜。

Node 也常当「调用方」——BFF 聚合、爬虫、压测。内置 fetch 已够用,配合 Promise 池控制并发:
async function fetchPool(urls, limit = 5) { const results = []; const executing = new Set(); for (const url of urls) { const p = fetch(url).then(r => r.json()); executing.add(p); results.push(p); p.finally(() => executing.delete(p)); if (executing.size >= limit) { await Promise.race(executing); // 等最早完成的一个腾出空位 } } return Promise.all(results); }
这个「等待最快的那个」的小模式是限流的核心,面试与实战双高频。用它可以立刻做一个自压测:起 200 个并发打自己的服务器,你会直观看到事件循环模型的吞吐表现,以及一个同步阻塞 handler 如何把整条队伍拖垮——这个实验留给读者,第 7 章我们会给出量化方法。
回看本节代码:路由表 → 中间件的前身,readBody → body-parser,serveFile → send 静态服务,try/catch 兜底 → error-handler 中间件。第 5 章的 Express 和 Koa 没有魔法,它们只是把这套骨架扩展成了可插拔的管道。
💡 关键直觉:学框架之前手写一次地基,之后框架报错时你才知道错在地基哪一层——是路由没匹配、体没读完,还是响应流被背压卡住。
裸用 http 模块还要自己补两课。超时:慢客户端可以拖住连接不放手,必须给连接与请求分别设超时并主动断开,否则连接数会被恶意或故障的客户端占满。优雅关闭:进程收到退出信号时,先停掉对新增连接的接纳,给存量请求一个完成窗口,再关闭服务器——发布重启时才不会出现「正在处理的支付请求被拦腰切断」的事故。这两件事框架与守护工具各有方案,但底层机制都在本节这些原生对象上。
给读者留一个自查练习:把本节的服务器加上两个中间件式的封装函数——一个记录每个请求的方法、路径与耗时,一个校验请求头里的令牌并拒绝非法访问——不引入任何框架完成。写完后你再看 Express 的中间件设计,会有一种「我也写过这个东西」的亲切感,这正是本节的目的。
原生服务器调试有个小技巧:在回调里打印 socket 的远端地址与请求 id,把一次请求的全链路日志串起来;配合第 2 章的顺序推演,多数「时序诡异」问题都能在日志里现形。