ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js原生HTTP服务实战:手写路由与JSON接口,彻底搞懂协议底层

Node.js原生HTTP服务实战:手写路由与JSON接口,彻底搞懂协议底层

很多学 Node.js 的人,上来第一件事就是装 Express,然后在教程的引导下用两三行代码跑起一个“Hello World”。Express 确实香,但问题也出在这里:你用框架用得太顺手,HTTP 协议就真的变成了一个黑盒。遇到 304、413、chunked 这些状态和机制的时候,你大概率只能去搜索引擎碰运气。

Day 6 这天,我特意没碰任何框架,用 Node.js 原生 http 模块从零写了一个带路由、支持 GET/POST、能返回 JSON 的服务。目的很简单——把 HTTP 原理和 Node.js 的底层 I/O 模型串起来,亲手看清楚一次请求从浏览器发出到服务器响应,中间到底发生了什么。这篇文章适合正在学 Node.js、却总觉得“框架会用、原理很虚”的开发者,当你亲手写过一遍原生实现,以后再排查线上接口问题,思路会完全不同。

1. 为什么我坚持拿原生写一遍HTTP服务

1.1 框架用久了,协议就变成了黑盒

我见过不少写了两三年业务代码的开发者,问起Content-Type和charset的关系、问起为什么接口返回的中文会乱码、问起Content-Length是按字符数还是字节数,回答都很含糊。这不怪他们,怪框架太体贴了。

res.json()、res.send()这些 API 用起来太顺手,顺手到没人追问:它到底做了什么?其实翻开源码就那几行逻辑——判断数据类型,设置对应的Content-Type,把对象JSON.stringify成字符串,计算一下字节长度,最后塞进res.end()。仅此而已。但如果你从来没自己写过这一套,那你对 HTTP 响应的理解就永远是“调个 API 就能返回”。

用框架不等于懂协议,懂协议才能用好框架。这也是我坚持在 Day 6 做原生实现的核心原因。

1.2 Node.js这门语言,天然就是为HTTP而生的

Node.js 能火起来,靠的并不是它处理 CPU 密集任务有多强,而是它处理 I/O 密集任务有一套——事件驱动、非阻塞、单线程。而 HTTP 服务恰恰是 I/O 密集任务的典型代表:大量请求同时进来,大部分时间都花在读取请求、查询数据库、写响应当中,CPU 本身反而不忙。

http.createServer(callback)这个 API 本身就体现了事件驱动的思想:启动一个服务,然后回调函数会在每次请求到达时被自动触发,你不用管底层怎么接 TCP 连接、怎么解析 HTTP 报文。Node.js 的http模块底层依赖net模块,net负责建立 TCP 连接,http模块在 TCP 之上解析出请求方法、URL、请求头,再交给你。

有人问:那为什么不直接用net.createServer?也行,但你就得自己拼 HTTP 报文的解析逻辑,属于重复造轮子。http模块就是 Node.js 官方帮你封装好的一层协议处理层,用它是“站在官方肩膀上”,不用框架才是“亲手摸到协议”。

1.3 这一天的学习目标和最终成品

Day 6 的任务很具体,我把它拆成了五条目标:

  • 用原生http模块创建 HTTP 服务,不依赖任何第三方库
  • 自己实现简单的路由分发,能区分 GET 和 POST
  • 能读取 URL 查询参数,并返回 JSON 格式响应
  • 能手动读取 POST 请求体,解析 JSON 数据
  • 用curl -v和浏览器开发者工具,实际观察一次请求的完整结构

最终成品是一个超简版留言板后端:GET /api/messages返回现有留言列表,POST /api/messages接收一条 JSON 格式的留言并返回保存结果。麻雀虽小,但路由、参数、请求体、响应格式全都覆盖了,做完之后 HTTP 原理在我脑子里不再是概念,而是一条条看得见的报文。

2. HTTP协议不是背出来的:把报文结构拆开看

2.1 请求报文:你每一行代码背后其实发了这些东西

HTTP 请求本质上就是一段有固定格式的文本,Node.js 的http模块要做的事,就是把这段文本解析成req对象。请求报文由四部分组成:请求行、请求头、空行、请求体。请求行在最上面,形如GET /api/users?name=zhangsan HTTP/1.1,它由三块组成——方法、路径、协议版本。请求头从第二行开始,每行都是“键: 值”的格式。

举个例子,在浏览器地址栏输入http://localhost:3000/api/users?name=zhangsan然后回车,浏览器实际发出的报文大致长这样:

GET /api/users?name=zhangsan HTTP/1.1 Host: localhost:3000 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Connection: keep-alive

GET 请求一般没有请求体,所以空行之后直接就是结尾。而你写fetch('/api/users')时,浏览器也会生成同样的报文,只是Accept这类请求头字段会根据场景自动调整。这就是为什么在 Node.js 服务里访问req.method能看到GET、访问req.headers能看到host和user-agent——它们不是魔法,是解析这段文本的结果。

2.2 响应报文:服务器回给客户端的完整对话

响应报文的格式跟请求报文是对称的,也由四部分组成:状态行、响应头、空行、响应体。状态行内容类似HTTP/1.1 200 OK,含义是协议版本 + 状态码 + 状态描述。响应头是服务器告诉浏览器的一些元信息,比如返回内容是什么类型、有多长、字符编码是什么。空行之后就是真正的响应体。

对照 Node.js 原生代码来看,响应报文和代码写法是一一对应的:

res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': 42 }); res.end('{"message":"Hello, HTTP!"}');

writeHead的第一个参数是状态码,第二个参数是响应头对象;end方法写入响应体并结束本次响应。浏览器收到这段报文,解析状态行和响应头,再根据Content-Length知道响应体有多长,然后完整读取响应体,渲染给你看。

关于状态码,不需要背全部,但常见的必须烂熟于心:

状态码含义典型场景
200OK请求成功,正常返回数据
201Created创建资源成功,常用于 POST
204No Content成功但无响应体
301/302重定向域名跳转、http 转 https
304Not Modified资源未变,走本地缓存
400Bad Request参数格式不对
401Unauthorized未登录或 token 失效
403Forbidden登录了但没权限
404Not Found路由不存在
500Internal Server Error服务端出异常

以后排查接口问题,第一个动作永远都是看状态码:2xx 基本不用慌,4xx 是调用方的问题,5xx 才是服务端背锅。

2.3 无状态、keep-alive和Content-Length,三者的联动关系

HTTP 是“无状态”协议,意思是服务器默认记不住你上一次请求干了什么。每次请求都是全新的,服务器不会自动保留你之前的身份信息。这也是为什么后来出现了 Cookie、Session、Token 这些机制——它们本质上都是在无状态的协议之上,人为制造“状态记忆”。

但无状态不代表连接每次都重开。HTTP/1.1 默认开启Connection: keep-alive,也就是在同一个 TCP 连接上可以连续发送多个请求,省去了反复握手的开销。这里就引出一个关键问题:多个响应都在同一个连接上返回,客户端怎么知道一个响应到哪结束、下一个响应从哪里开始?

答案就是Content-Length或Transfer-Encoding: chunked。服务器在响应头里告诉客户端响应体的字节长度,客户端读够这个长度就知道响应结束了。但如果响应体长度不确定——比如流式输出、边生成边发送——那就用分块传输编码,每个数据块前面标上长度,最后用长度为 0 的块表示结束。

理解这三者的关系后,再遇到“接口等了很久没响应”“响应不完整”之类的问题,你会多一个排查方向:是不是Content-Length和实际发送的字节数对不上?是不是 keep-alive 连接被服务端意外关闭了?这些细节在原生服务里全都暴露在眼前。

3. 从零手写Node.js原生服务:每一步都不经过框架

3.1 最基础的服务:http.createServer

第一个代码块可以精简到什么程度?四行:

const http = require('http'); const server = http.createServer((req, res) => { res.end('Hello, Day 6!'); }); server.listen(3000, () => { console.log('server is running at http://localhost:3000'); });

这段代码包含了两个关键部分。http.createServer(callback)创建了一个 HTTP 服务器实例,回调函数中的req是IncomingMessage实例,封装了本次请求的所有信息;res是ServerResponse实例,负责向客户端写回响应。每当有请求到达,回调就会被调用一次,这就是事件驱动的具象化——你不需要写死循环去轮询请求,框架层的模块会把请求“推”给你。

server.listen(3000, callback)是让服务器监听 3000 端口,第二个参数是监听成功后的回调。这里有个小细节:如果你的机器上装了多个 Node.js 版本或容器环境,要注意 3000 端口是否已经被别的进程占用,否则会直接抛EADDRINUSE错误,这件事我在第 6 节会详细展开。

启动后,在终端执行node server.js,然后浏览器访问http://localhost:3000,就能看到页面显示Hello, Day 6!。到这里,我们的原生服务已经“活”了。

3.2 路由与参数解析:自己当一次“路由器”

框架里的app.get('/path', handler)看着很爽,但它的本质就是判断“请求方法和路径是否匹配”。现在我用原生代码把这个逻辑写出来:

const http = require('http'); const server = http.createServer((req, res) => { const parsedUrl = new URL(req.url, 'http://localhost:3000'); const pathname = parsedUrl.pathname; const method = req.method; if (method === 'GET' && pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>Day 6 原生服务</h1>'); return; } if (method === 'GET' && pathname === '/api/users') { const name = parsedUrl.searchParams.get('name'); const data = { code: 0, data: { name: name || 'anonymous' } }; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); return; } res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('404 Not Found'); }); server.listen(3000, () => { console.log('server is running at http://localhost:3000'); });

这里值得展开几个点。第一,为什么用new URL(req.url, 'http://localhost:3000')而不是直接字符串切割?因为req.url可能是/api/users?name=zhangsan,也可能包含 hash,用字符串split手工解析容易漏边界情况,而URL标准 API 天然支持解析查询参数。第二个参数 base URL 是必须的,因为req.url本身不包含协议和域名,URL构造函数要求传一个完整的基准地址。

第二,路由的本质就是判断。很多人觉得原生写路由麻烦,但在一个只有十几个接口的小项目里,原生判断的性能和可读性完全够用。框架的路由只是把if/else换成了注册表和正则匹配,理解了这个,你在框架里写路由时会更清楚它到底匹配的是什么。

第三,响应头必须写明charset=utf-8。如果不写,浏览器可能默认按ISO-8859-1去解析,中文直接变成乱码。这个是新手最容易踩的坑,后面第六节我会单列出来讲。

3.3 POST请求的body读取:绕不开的data/end事件

GET 请求没有 body,但 POST、PUT 这类方法通常需要携带数据。Node.js 的 http 模块没有帮你存好 body,你必须自己监听流事件去收集。这也是很多从 Express 转原生的人第一个不适应的地方——原来req.body是框架帮你摘要好的,原生世界里只有流。

const http = require('http'); const server = http.createServer((req, res) => { if (req.method === 'POST' && req.url === '/api/message') { const chunks = []; req.on('data', (chunk) => { chunks.push(chunk); }); req.on('end', () => { const bodyBuffer = Buffer.concat(chunks); const bodyStr = bodyBuffer.toString('utf-8'); let parsed; try { parsed = JSON.parse(bodyStr || '{}'); } catch (e) { parsed = { error: 'invalid JSON' }; } const data = { code: 0, received: parsed }; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }); return; } res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('404 Not Found'); });

这段代码里有三个关键细节。第一个,data事件拿到的chunk是Buffer,不是字符串。很多教程会直接body += chunk,这在多数情况下能跑,但遇到多字节 UTF-8 字符被分块截断的场景,字符串拼接会把半个中文变成乱码字符。正确做法是把所有 chunk 收集到数组里,最后Buffer.concat(chunks)一次性还原。

第二个,JSON.parse必须用try/catch包住。因为客户端发来的内容不可控,非法 JSON 会让解析直接抛异常,不捕获的话服务进程可能直接被异常打断。

第三个,也是很多人容易忽略的——要给 body 加大小限制。如果不加,恶意客户端可以一直发数据,把你的内存打爆。加限制的办法不算复杂:在data事件里判断累计字节数超过阈值(比如 1MB)就主动断开请求、返回 413。

这些逻辑,就是 Express 里express.json()中间件背后替你做的事。它把流式读取、大小限制、JSON 解析、异常处理全部封装好,你写起来只有一行,但你不亲手做一遍,永远不会真正理解req.body是从哪来的。

4. 用curl和浏览器开发者工具,亲手“看见”HTTP

4.1 curl -v:把一次请求的每个字节都摊开

写原生服务最大的乐趣,就是你终于可以清楚地看到一条请求从发起到响应的全过程。最直观的工具就是curl -v,-v是 verbose 的意思,它会把整个 HTTP 交互过程打印到你面前。

启动上一节写好的原生服务,然后在另一个终端执行:

curl -v "http://localhost:3000/api/users?name=zhangsan"

你会在终端里看到类似这样的完整输出:

* TCP_NODELAY set * Connected to localhost (127.0.0.1) port 3000 (#0) > GET /api/users?name=zhangsan HTTP/1.1 > Host: localhost:3000 > User-Agent: curl/8.1.2 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: application/json; charset=utf-8 < Date: Sat, 01 Jan 2025 00:00:00 GMT < Connection: keep-alive < Keep-Alive: timeout=5 < Transfer-Encoding: chunked < {"code":0,"data":{"name":"zhangsan"}}

>开头的是客户端发出去的请求报文,<开头的是服务器回过来的响应报文。对照第 2 节的报文结构,你会发现自己亲手写的服务产出的响应,和课本上画的报文结构完全一致——状态行、响应头、空行、响应体,一样不少。

再测试 POST 请求:

curl -X POST http://localhost:3000/api/message \ -H 'Content-Type: application/json' \ -d '{"message":"hello day6"}'

-X POST指定方法,-H添加请求头,-d发送请求体。执行之后,服务端日志里能看到 data 事件被触发,客户端能收到{"code":0,"received":{"message":"hello day6"}}。通过这种方式,你能真切感知到“请求体以流的形式,分块传到了服务器”。

4.2 开发者工具里的Network:前端视角与后端视角对照

浏览器开发者工具对你来说并不陌生,但大多数时候你只关心响应结果,很少把 Network 面板里的信息和后端代码对应起来。这里我放一张思维对照表,帮你把两个视角打通:

前端开发者工具里的项原生代码里的位置
Request URLreq.url
Request Methodreq.method
Status Coderes.statusCode
Request Headersreq.headers
Response Headersres.getHeaders()
Payload / Request Bodydata/end 事件读取的缓冲内容

打开 Network 面板,点开一个请求,你能看到General、Request Headers、Response Headers三块。Request Headers 里的Host、User-Agent、Accept等字段,正是服务端通过req.headers拿到的对象。Response Headers 里的Content-Type、Content-Length或Transfer-Encoding,就是你用writeHead写出去的内容。

以后你排查接口返回乱码、返回格式不对、响应超时这类问题时,先别急着看业务代码。在 Network 面板里看一遍响应头,往往能定位到问题方向:Content-Type不对会导致前端解析失败,Content-Length异常会导致响应不完整,Cache-Control不对会导致缓存不生效。

4.3 实测keep-alive和chunked编码的差异

在 2.3 节讲过 keep-alive 和 Content-Length 的理论,这里用实测把它落地。还是回到curl -v的输出,注意响应头里写着Connection: keep-alive和Transfer-Encoding: chunked。

为什么我们的原生实现默认是chunked?因为响应体在res.end()被调用前,服务器并不知道它的确切长度。Node.js 在没设置Content-Length的情况下,会自动采用 chunked 编码,把响应体分成多个块发给客户端,每块前面标注长度,最后用一个长度为 0 的块收尾。

如果想手动指定长度,可以像下面这样写:

const body = '中文测试'; const bodyBuffer = Buffer.from(body, 'utf-8'); res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': bodyBuffer.length, }); res.end(bodyBuffer);

这里有个非常重要的细节:Content-Length的单位是字节,不是字符数。中文在 UTF-8 编码下一个字占 3 个字节,'中文测试'.length在 JavaScript 里返回的是 4(按 UTF-16 码元计算),但它的字节数是Buffer.byteLength('中文测试', 'utf-8'),结果是 12。如果用了Content-Length: 4,客户端会认为响应体还没发完,一直等着后续数据,表现为“接口响应很久”。

这个细节在框架里你基本碰不到,因为框架会自动帮你计算。但一旦遇到手动构造响应的场景,或者排查“请求挂起”的诡异问题,你会庆幸自己理解过字节级的概念。

5. 写完原生服务再回头看框架:框架到底帮你做了什么

5.1 express的res.send,本质就是几行原生代码

Day 6 的收尾,我特意从原生回到了框架,做了一次“反向拆解”。拿 express 最常用的res.send()来说,它的核心逻辑其实非常简单,我用原生代码模拟一下:

function send(res, data) { if (typeof data === 'object' && data !== null) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); } else if (typeof data === 'string') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(data); } }

判断类型、设置 Content-Type、序列化字符串、计算长度、end——就这么几步。你手动写也是这几步,只是框架把它封装成一个函数,省得你每次重复写writeHead。当你明白了这一点,你再去看框架文档,就不会觉得那些 API 是魔法,而是会自然而然地在心里把它们翻译成“原来是做了这么一件事”。

5.2 路由、中间件、body解析:框架的“糖”分别包在哪

框架的价值在于工程化,而不是魔法。把几个常用能力拆开看,它们对应的原生逻辑分别是:

框架能力原生实现框架封装
路由app.get('/path', fn)if (req.method === 'GET' && pathname === '/path')注册表 + 路径匹配算法
body 解析express.json()监听 data/end,拼接 Buffer,JSON.parse封装了缓冲、大小限制、类型判断
CORS 跨域手动在响应头写 Access-Control-Allow-* 字段cors中间件替你批量设置
静态资源托管手动读文件、设 Content-Type、返回内容express.static封装了路径映射和缓存

这些封装最大的意义是“一致性”。框架让你用统一的方式注册路由、挂中间件、解析参数,在多人协作的大项目里,这种规范比省几行代码重要得多。但在学习阶段,如果只知道封装接口而不知道封装对象,你遇到问题就会无从下手——因为你连“req.body是框架加的、原生根本没有”这种基本事实都不一定清楚。

5.3 什么时候该用原生,什么时候该上框架

写完原生服务并不意味从此不用框架,而是让你获得一种判断力:什么时候没必要上框架。我的建议是分场景来看:

  • 学习阶段、写 Demo、做脚本、搭本机小工具:原生模块就够了,不要为了几行代码引框架
  • 小型内部服务、接口数量个位数、团队你一个人维护:原生可控性其实更好,依赖更少
  • 大型项目、多人协作、需要大量中间件生态:果断上框架,别自己造轮子
  • 排查性能瓶颈和疑难 bug:先把框架这层剥掉,用原生手段定位是协议层、网络层还是业务层的问题

框架是给你用的,不是给你敬的。亲手写过一遍原生实现,你才会真正把 HTTP 原理变成自己的底牌。以后不管再用什么框架,遇到问题都不会慌,因为你知道底层是什么。

6. 这一天踩过的坑和排查思路(建议直接收藏)

6.1 中文乱码:没写charset=utf-8

原生服务第一次返回中文时,浏览器显示了一堆乱码。排查思路很简单:在 Network 面板里看响应头,发现Content-Type: application/json后面没有charset=utf-8。HTTP 协议里,Content-Type中的charset参数负责告诉客户端用什么编码解码响应体。如果不写,浏览器在不同场景下的默认解析规则不同,GBK、ISO-8859-1 甚至 UTF-8 都可能被选中,中文自然就乱了。

解决方法是统一在响应头里补齐; charset=utf-8:

res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });

这个习惯要养成了,因为框架虽然默认带上了 charset,但当你手动写响应头、或调用某些底层库时,这个问题会重新出现。不要觉得“我不用原生就不会踩”,理解原理永远是你的安全网。

6.2 favicon.ico这个“幽灵请求”

第一次访问首页,我明显感觉服务端日志里多打了一条请求,打开 Network 面板一看,发现浏览器自动请求了/favicon.ico。这是浏览器的默认行为——它会在每次访问站点时自动拉取网站图标,如果你没提供,它就发出一个GET /favicon.ico请求,最后被我们未匹配的路由逻辑打回 404。

影响是什么?服务端多一条无效请求,日志里多一条 404,看起来像是“系统有 bug”。解决办法是在路由里显式处理这个路径,直接返回 204 空响应,避免多余的响应体传输:

if (pathname === '/favicon.ico') { res.writeHead(204); res.end(); return; }

这个“幽灵请求”看起来很小,但它提醒我一个通用场景:收到不认识的请求路径,不要直接当作错误,先思考一下它是不是客户端自动发出的辅助请求。这一类经验,只能在实际跑服务的过程中积累。

6.3 EADDRINUSE端口占用与优雅退出

写代码调试时最常遇到的错误是Error: listen EADDRINUSE: address already in use :::3000,也就是端口被占用。排查链路很直接:先用命令看是哪个进程占了端口,再决定杀掉还是换端口。

macOS/Linux 下查看:

lsof -i :3000

Windows 下查看:

netstat -ano | findstr :3000

定位到进程 ID 后,直接kill -9 进程ID释放端口。但更值得做的,是在代码里给 server 挂一个 error 事件,让占端口的报错变得友好易读:

server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error('端口 3000 已被占用,请先释放进程或修改端口'); process.exit(1); } console.error(err); process.exit(1); });

注意,启用node --watch自动重启模式时,进程重启的间隔可能让端口处于 TIME_WAIT 状态,也会触发 EADDRINUSE。碰到这种情况,等一两秒再重启,或者干脆换一个端口。

6.4 手动拼接body时,别用字符串直接+=

这是我想着重提醒的一个坑。网上很多教程在解析 POST body 时写的是body += chunk,看起来简洁,但埋了一个隐患。前面说过chunk是Buffer,当你写body += chunk时,JavaScript 会隐式把 Buffer 转成字符串再拼接。表面看没啥问题,但遇到多字节 UTF-8 字符恰好被 TCP 分块截断时,单块转字符串的字节拼不出来,最后拼接出来的字符串就会有乱码或替换符,而且这种问题通常是偶发的——时好时坏,极难排查。

正确做法是攒 Buffer,最后一次性合并解码:

const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks).toString('utf-8'); // 继续处理 body });

Buffer.concat不做任何转换,直接按字节合并且不丢数据,最后统一按 UTF-8 解码,就能保证中文、表情符号这类多字节字符在任意分块边界下都能被正确还原。

这个坑在很多生产环境里真实发生过,症状表现为“接口有时候返回成功,有时候参数解析出来是�”。当你亲手踩过一遍,你就再也不会犯这个错误,也真正理解了为什么框架的 body 解析要维护一个 Buffer 数组。


Day 6 学完,我最直接的体会是:写几行原生代码的价值,远大于看十篇框架教程。那种亲手打开一个 TCP 端口、自己判断路由、手动拼装一次响应、用curl -v亲眼看到请求报文结构的掌控感,是框架给不了的。以后写代码,我会先问自己一个问题:这个功能如果不用框架,我知不知道它在底层是怎么实现的。如果答案是不知道,那我先去补这个空白,再回来写业务。也建议你用同样的方式,把curl -v变成日常习惯——每次接口不对劲,先看一遍完整报文,你离真相的距离,往往比你想的还近。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进