☰
从零手写Node.js http模块服务器:搞懂HTTP协议与Web服务根基
2026/10/20 4:05:33 网站建设 项目流程

没开玩笑,搞懂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、POST
  • req.url:请求路径(含 query),如/index.html?a=1
  • req.headers:请求头对象,键名全部小写
  • req.httpVersion:客户端用的 HTTP 版本,如1.1
  • req.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 响应,完整过程是这样的:

  1. 客户端发起 TCP 连接(三次握手)
  2. 客户端发送 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
  1. Node 的解析器逐字节解析,识别出请求行GET /index.html HTTP/1.1和每个请求头
  2. 触发request事件,你的回调被调用
  3. 回调里操作res对象,写入状态行、响应头、响应体
  4. Node 将响应报文通过 TCP 发回客户端
  5. 如果请求头或响应头里有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 协议的理解深一层。做技术,这种底层地基的功夫没办法省,越早补上,后面越稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询