没开玩笑,搞懂http模块,是理解整个Node.js服务的根基。
很多同学一上来就撸 Express、Koa,脚手架一键生成项目,服务跑起来确实爽。但有个问题:一旦线上出了怪毛病——请求莫名挂起、内存涨着不降、代理层报奇怪的响应头——翻框架源码翻到头秃,最后定位到是底层HTTP解析的行为差异,这时候回头补 http 模块的课,代价就大了。
这篇不是要你以后用原生模块手写生产服务器,而是把地基打牢。我会用 http 模块从零写一个可用的 Web 服务器,包括路由、静态文件、JSON 接口、POST 请求处理,把每一步的原理、参数、踩坑都摊开讲。适合刚学完 Node.js 语法、想深入理解服务端运行机制的初学者,也适合用框架写了一阵子、想回来补基础的同学。
1. 为什么要手写一个http服务器
1.1 框架用多了,反而不认识协议
现在建一个 Node 服务太容易了。装个框架,几行代码,监听端口,完事。但“会用”和“理解”之间,隔着一整个 HTTP 协议层。
我用框架写过不少项目,一度以为 HTTP 请求就是框架给的那个req.body对象和res.json()方法。直到有一次排查线上接口偶发超时,抓包看到客户端发了Connection: keep-alive,而后端响应头里没有Content-Length,连接迟迟不释放,才意识到我根本不清楚 Node 底层是怎么解析 HTTP 报文的。
手写 http 模块服务器,不是为了返璞归真,而是强迫自己面对那几个核心问题:
- 一个请求进来,Node 是怎么从 TCP 流里切分出请求行、请求头和请求体的?
req对象上的url、method、headers是从哪儿来的?- 为什么
res.end()之后连接就关闭了?Content-Length和Transfer-Encoding: chunked对连接复用有什么影响?
这些问题用框架的时候几乎不会注意到,但恰恰是它们决定了服务器的行为边界。自己用 http 模块写一遍,这些问题会自动浮出来。
1.2 http模块的边界:能做什么,不擅长什么
http 模块是 Node.js 内置的,不依赖任何第三方包。它基于net模块(TCP)之上,实现了 HTTP/1.1 协议的解析和序列化。简单说,你给它一个 TCP 连接,它负责:
- 把字节流解析成结构化请求(请求行、请求头、请求体)
- 把 JavaScript 对象序列化成符合规范的 HTTP 响应(状态行、响应头、响应体)
但它不是一个 Web 框架。它不提供路由匹配、中间件机制、模板渲染、参数校验这些上层能力。路由器、静态文件服务、body 解析统统要自己写或借助扩展库。
这点要心里有数。用 http 模块手写服务器,目标不是替代框架,而是理解框架替我们做了什么。写一遍之后再看 Express 的源码,会发现它不过是在 http 模块之上封装了路由表和中间件执行链,所有请求最终还是会落到http.createServer那个回调里。
2. 核心概念与原理拆解
2.1 createServer回调里到底发生了什么
先看最小可运行的服务端代码:
const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Hello World'); }); server.listen(3000, () => { console.log('Server running at http://localhost:3000/'); });就这一段,背后发生了不少事。http.createServer传入的回调,本质上是一个request事件的监听器。server.listen(3000)之后,Node 会创建一个 TCP 服务器,监听 3000 端口。
当一个 TCP 连接建立后,http 模块会为这个连接维护一个解析器(parser)。这个解析器以流的方式读取数据:先读请求行,再读请求头,直到遇到空行(\r\n\r\n),请求头和请求行就解析完了。此时触发request事件,也就是执行你传入的回调。
所以“请求进来了”和“回调执行了”之间是有时间差的,取决于请求头是否完整到达。如果客户端只发送了部分请求头,回调不会立刻执行,而是等剩余字节到达。
这引出一个容易被忽视的问题:回调执行时,请求体往往还没完全到达。请求体是跟在空行之后的字节,http 模块在解析完请求头之后,并不会阻塞等待整个请求体读完,而是先把请求头交给回调,请求体通过req(一个 Readable 流)逐步暴露出来。这也是为什么读取 POST body 必须监听data事件或req.on('end'),而不是在回调里直接拿一个完整的 body 字符串。
2.2 req和res是两个什么样的对象
req是http.IncomingMessage的实例,它本身是一个 Readable 流。常用属性有:
req.method:HTTP 方法,如GET、POSTreq.url:请求路径(含 query),如/index.html?a=1req.headers:请求头对象,键名全部小写req.httpVersion:客户端用的 HTTP 版本,如1.1req.socket:底层 TCP socket,可借此拿到客户端 IP、端口
注意req.url不是完整的 URL,而是路径加查询字符串。如果要拿到协议、主机名等,需要结合req.headers.host拼出来。
res是http.ServerResponse的实例,它本身是一个 Writable 流。核心方法有:
res.writeHead(statusCode[, statusMessage][, headers]):写入状态行和响应头res.write(chunk):写入一块响应体res.end([chunk]):结束响应,可附带最后一块数据res.setHeader(name, value):设置单个响应头res.getHeader(name):读取已设置的响应头res.statusCode:状态码,配合隐式响应头使用
一个关键的行为:不用res.writeHead,直接res.write或res.end时,Node 会隐式生成状态行和响应头。默认状态码是 200,默认Content-Type没有值。如果你不设置Content-Type,很多浏览器会把响应体当文本渲染,中文可能显示乱码。
2.3 一次完整请求的周期
从 TCP 连接到 HTTP 响应,完整过程是这样的:
- 客户端发起 TCP 连接(三次握手)
- 客户端发送 HTTP 请求报文,例如:
GET /index.html HTTP/1.1\r\n Host: localhost:3000\r\n User-Agent: curl/8.0\r\n Accept: */*\r\n \r\n- Node 的解析器逐字节解析,识别出请求行
GET /index.html HTTP/1.1和每个请求头 - 触发
request事件,你的回调被调用 - 回调里操作
res对象,写入状态行、响应头、响应体 - Node 将响应报文通过 TCP 发回客户端
- 如果请求头或响应头里有
Connection: keep-alive,TCP 连接保持复用,等待下一个请求;否则连接关闭
这里有个重要细节:HTTP/1.1 默认是持久连接(keep-alive),浏览器对一个域名通常会复用同一个 TCP 连接发送多个请求。Node 在响应结束时会检查请求头里的Connection值,如果是keep-alive(HTTP/1.1 默认),且响应头里有Content-Length或使用了 chunked 编码,连接就会保持,等待同一 socket 上的下一个请求。
这就是为什么响应体大小会影响连接是否复用。没有Content-Length且没有 chunked,Node 只能通过关闭连接来告知客户端响应体结束,此时 keep-alive 就失效了。
3. 实操:从零搭一个web服务器
3.1 最简版:三行代码跑起来
先写一个能响应文本的服务器,验证基础链路:
const http = require('http'); const server = http.createServer((req, res) => { res.end('Hello World'); }); server.listen(3000);运行node server.js,然后用浏览器或 curl 访问:
curl -i http://localhost:3000/-i参数把响应头也打印出来,结果类似:
HTTP/1.1 200 OK Date: Sat, 01 Jan 2025 00:00:00 GMT Connection: keep-alive Keep-Alive: timeout=5 Content-Length: 11 Hello World注意这里我故意没设置Content-Type,响应头里确实没有。Node 默认根据响应体内容猜了一个Content-Length: 11,传输完成且连接保持。
这一个版本虽然只有几行,但链路完整。多说一句:server.listen(3000)默认监听所有网卡地址(::,即 IPv6 通配,同时兼容 IPv4)。如果只想本机访问,可以传server.listen(3000, '127.0.0.1')。
3.2 进阶版:路由 + 静态文件 + JSON接口
接着往上加能力。目标:
/返回一个 HTML 页面/about返回 JSON/public/*返回静态文件(如 CSS、图片)- 其他路径返回 404
直接上代码,分段解释。
const http = require('http'); const fs = require('fs'); const path = require('path'); const server = http.createServer((req, res) => { const { method, url } = req; // 解析 URL,拿到路径和查询参数 const [pathname, queryString] = url.split('?'); // 路由分发 if (method === 'GET' && pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>Home Page</h1>'); return; } if (method === 'GET' && pathname === '/about') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ name: 'demo-server', version: '1.0.0' })); return; } // 静态文件服务 if (method === 'GET' && pathname.startsWith('/public/')) { serveStatic(req, res, pathname); return; } res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('404 Not Found'); });3.3 静态文件服务的实现与细节
serveStatic用一个辅助函数实现:
function serveStatic(req, res, pathname) { // 去掉前导 /public,拼到本地目录 const relativePath = pathname.replace(/^\/public\//, ''); const filePath = path.join(__dirname, 'public', relativePath); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('File Not Found'); return; } const ext = path.extname(filePath).toLowerCase(); const mimeMap = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', }; const contentType = mimeMap[ext] || 'application/octet-stream'; res.writeHead(200, { 'Content-Type': contentType }); res.end(data); }); }这个实现有个问题:每次请求都整文件读入内存再返回,小文件没问题,大文件会占内存、拖慢响应。更好的做法是用流:
const stream = fs.createReadStream(filePath); stream.on('error', () => { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('File Not Found'); }); res.writeHead(200, { 'Content-Type': contentType }); stream.pipe(res);pipe会把文件流接入响应流,边读边写,内存占用恒定。生产环境的静态文件服务要做得更好,还得考虑缓存响应头(ETag、Cache-Control)、range 请求、压缩等,不过那是另一篇文章的范围了。这里用流式传输已经把思路点透。
防目录穿越是个安全隐患。
path.join之后要检查filePath是否真的以public目录为前缀,否则客户端可以传../绕过限制读到任意文件。这是真实发生过的问题,务必处理。
const resolvedPath = path.resolve(__dirname, 'public', relativePath); if (!resolvedPath.startsWith(path.resolve(__dirname, 'public'))) { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Forbidden'); return; }3.4 处理POST请求:读取请求体
POST 请求体在回调触发时还没有完全到达,需要通过流的方式读取。这里演示表单和 JSON 两种常见格式:
const server = http.createServer((req, res) => { const { method, url } = req; if (method === 'POST' && url === '/api/submit') { let body = ''; // req 是 Readable 流,data 事件收数据 req.on('data', (chunk) => { body += chunk.toString(); // 注意:理论上要防大 body 撑爆内存,这里简化 }); req.on('end', () => { let parsedBody; const contentType = req.headers['content-type'] || ''; if (contentType.includes('application/json')) { try { parsedBody = JSON.parse(body); } catch (e) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: 'invalid json' })); return; } } else { // x-www-form-urlencoded,如 a=1&b=2 parsedBody = Object.fromEntries(new URLSearchParams(body)); } console.log('Received body:', parsedBody); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok: true, data: parsedBody })); }); return; } res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); });需要留意两点:
chunk是 Buffer。如果文本数据直接toString()没问题,但有极低概率在多字节字符处被截断,严谨做法是 Buffer 全部拼接后一次 toString 或StringDecoder。这个概率在本地测试几乎碰不到,但数据量大或字符复杂时确实会发生。我自己是一次日志偶发乱码才想起这条。- 如果
req上传过程中发生错误,比如客户端中断请求,req会触发error事件。不监听的话会直接把进程带崩。所以生产代码至少加一句req.on('error', () => { res.destroy(); });。
4. 踩坑记录与问题排查
4.1 端口被占了怎么办
最常见的错误:Error: listen EADDRINUSE: address already in use :::3000。
原因通常是之前启动的进程没退干净。排查步骤:
# Linux / macOS lsof -i :3000 # 拿到 PID 后结束进程 kill -9 <PID> # Windows netstat -ano | findstr :3000 taskkill /F /PID <PID>这个错误隐含一个实际教训:开发时窗口一关,Node 进程可能还活着。尤其用了 nodemon、pm2 之类的工具,子进程未必跟着主进程退出,端口被静默占用,下次启动就报错。
另外一个习惯建议:1000 以下的端口在 Linux/macOS 上需要 root 权限,开发调试一律用 3000、8080、8888 这类高位端口,避免权限问题。
4.2 中文乱码是咋回事
浏览器访问页面,中文显示成乱码,十有八九是响应头里Content-Type少了charset=utf-8。
我见过好几种写法,踩坑点各不相同:
// 正确 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); // 漏了 charset,浏览器可能按系统默认编码解析,中文乱码 res.writeHead(200, { 'Content-Type': 'text/html' }); // 设了页面 meta,但响应头里没有,某些浏览器依旧乱码 res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<meta charset="utf-8"><h1>首页</h1>');稳妥做法:所有文本类响应(html、json、css、js)一律在Content-Type里带charset=utf-8。JSON 接口也建议带上,避免某些老版本客户端按 ISO-8859-1 解析。
顺带一提:res.writeHead里设置的Content-Type会覆盖之前用res.setHeader设置的同名值。二者混用时要确定写入顺序,避免最后生效的响应头和自己预期不一致。
4.3 页面为什么加载了两次
写 Demo 时经常发现一条路由在日志里打印了两次,资深一点的可能已经猜到:是/favicon.ico。
浏览器自动请求网站图标,即使页面里没写<link rel="icon">。如果服务器没处理这个路径,它会在 404 日志里反复出现。
排查确认方法:
if (url === '/favicon.ico') { res.writeHead(204); res.end(); return; }顺手返回 204(No Content),浏览器就不较劲了。
这个现象有更广义的教训:抓日志看请求量的时候,先过滤掉 favicon、健康检查、爬虫这些非业务请求,不然数据都是脏的。我自己的一个小习惯:路由日志里给每个请求打上req.url和req.headers['user-agent'],对照着看非常容易定位这类问题。
4.4 响应结束之后不能再写
错误信息长这样:ERR_HTTP_HEADERS_SENT: Cannot set headers after they are sent to the client。
本质是res.end()之后又调用了res.writeHead或res.write。常见场景:
- 异步回调里重复执行了
res.end(比如读文件成功和失败两个分支都执行了结束响应) - 静态文件服务里文件流
error事件和data事件竞争
解决办法:每一次响应路径只允许走一次。接口入口处加一个标记:
let responded = false; function send(statusCode, data) { if (responded) return; responded = true; res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); }用统一入口封装业务接口,能避免大多数重复响应问题。
4.5 用curl和telnet做底层报文调试
写 http 服务器,最趁手的调试工具是 curl。几个常用参数:
# 完整显示请求和响应报文 curl -i http://localhost:3000/ # 只看响应头 curl -I http://localhost:3000/ # 指定请求方法 curl -X POST http://localhost:3000/api/submit -d 'name=test' # 用 JSON 体发 POST curl -X POST http://localhost:3000/api/submit -H 'Content-Type: application/json' -d '{"name":"test"}'想更底层观察 HTTP 报文,直接用 telnet 连上端口,手敲报文:
telnet localhost 3000连上之后输入(注意行尾是\r\n,telnet 会把回车发过去):
GET / HTTP/1.1 Host: localhost:3000连按两次回车(第一个回车结束请求头,第二个回车发出空行),就能看到服务器返回的原始响应报文。这种方式能直观看到Content-Length和Connection: keep-alive这些字段的真实存在,比浏览器开发者工具更接近协议本身。
5. 扩展:从Demo走向真实项目
5.1 进程管理与负载
手写的 http 服务器是单进程单线程模型,Node 的 JavaScript 执行是单线程的,但底层 I/O 是异步的。这意味着:
- CPU 密集型任务(大循环、加解密、图像处理)会阻塞事件循环,这个服务器在此期间无法处理任何其他请求
- I/O 密集型任务(读写文件、查数据库、调远程接口)恰恰是 Node 的强项
想让一台机器的多核 CPU 都跑起来,一个简单办法是cluster模块:
const http = require('http'); const cluster = require('cluster'); const os = require('os'); if (cluster.isMaster) { const cpuCount = os.cpus().length; for (let i = 0; i < cpuCount; i++) { cluster.fork(); } } else { const server = http.createServer((req, res) => { res.end('Worker ' + process.pid); }); server.listen(3000); }cluster的本质是:多个进程共享同一个端口,操作系统内核负责把连接分发到不同进程。Node 的 API 层面你感觉不到变化,但能利用到多核。
5.2 自制一个迷你框架的思路
理解了 http 模块,就可以尝试写个迷你框架,把常用逻辑抽出来:
- 路由表:
method + path映射到处理函数 - 中间件链:请求进来按序执行,每个中间件可以修改
req/res,或决定是否继续 - 参数解析:query、body、cookie 的统一解析
- 响应辅助:json、text、html 的快捷方法
核心结构大概是:
const routeHandlers = []; function use(handler) { routeHandlers.push(handler); } function handleRequest(req, res) { let index = 0; const next = (err) => { if (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); return; } const handler = routeHandlers[index++]; if (handler) { handler(req, res, next); } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } }; next(); } const server = http.createServer(handleRequest);这就是中间件模型的雏形。Koa 洋葱模型的底层,也类似于对next链的封装,加上了异步和更完善的错误传播。自己写一遍再回头看框架源码,会有豁然开朗的感觉。
后记
把 http 模块手写服务器这件事做完,最大的收获不是代码本身,而是终于建立起了对“一个请求从进入 Node 到响应返回”的全链路感知。
你会知道res.end()意味着什么,Content-Length为什么重要,req.body为什么框架里非要有 body-parser 这种东西。这些知识在框架文档里是看不全的,但排查线上问题的时候,每一块都会回来帮你。
有个小建议:这套 Demo 不用追求生产级完善,但值得独立存一个项目目录,随时加一点新东西进去。比如实现一下Cache-Control、ETag,或者给路由加一层简单的参数校验。每加一个特性,就对 HTTP 协议的理解深一层。做技术,这种底层地基的功夫没办法省,越早补上,后面越稳。