3.2 http模块实战:手写服务器与客户端


3.2 http 模块实战:手写服务器与客户端

本节摘要:http 模块是 Express/Koa 们脚下的地基。本节不借任何框架,手写一个能路由、能读请求体、能返回 JSON 与文件的完整服务器,再做 http 客户端与并发压测实验,最后讲透 keep-alive 与安全头这两个工程必知点。读完再看任何 Node Web 框架,你看到的都是本节代码的再包装。

学习目标

  1. 能解析 req/res 对象的关键属性并正确读取请求体
  2. 能实现带路由表的 JSON 接口与静态文件服务
  3. 能用 http.request / fetch 发起客户端调用并控制并发
  4. 能解释 keep-alive 与常用安全响应头的作用

一、从最小服务器到能用的小服务

第 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。否则响应悬而不决,客户端等到超时,而你只在服务端日志里看到未捕获异常。

二、静态文件服务:Stream 的第一次实战

返回磁盘上的文件,正确做法是把文件流接到响应流上,而不是先 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 章的顺序推演,多数「时序诡异」问题都能在日志里现形。

本节要点回顾

  • req 是可读流:请求体必须攒 data 事件,没有现成的 body 属性。
  • 错误必兜底:handler 抛错要有 500 兜底,否则客户端只能干等超时。
  • 静态文件用 pipe:内存恒定、自动 end,是 Stream 思维的第一课。
  • keep-alive 双端才生效:服务器默认开,客户端要用连接复用才受益。
  • 并发限流模式:race 最早的 Promise 腾空位,五行的并发池够用很久。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U