玩Node.js如果只让我选一个内置模块来讲透,我肯定选http。这个模块是整个Node生态的网络基石,Express、Koa、NestJS这些框架的底层,本质上都是对它做了一层封装。很多人学Node时一上来就奔着框架去,结果遇到线上问题只能靠猜,连最基本的“请求—响应”模型都说不清楚,更别提自己排查问题了。
这篇文章不讲虚的,直接围绕Node.js原生HTTP模块的三大核心场景展开:创建服务器、响应请求、客户端请求。我会把createServer的运作原理、req/res两个对象的真实结构、GET/POST请求的完整处理流程、以及用http模块主动发起请求的写法,一步步拆开讲。适合正在学Node基础的人,也适合那些用框架久了想补底层功底的同学。看完你不仅能手写一个能用的HTTP服务器,还能理解请求从进入到返回的完整链路。
1. HTTP模块的定位:为什么它是Node生态的地基
1.1 原生HTTP模块到底解决了什么问题
HTTP模块解决的事情很纯粹:让Node进程能够基于HTTP协议跟外界通信。通信分两个方向,一个是作为服务端接收别人的请求,另一个是作为客户端去请求别人。这两个方向在Node里用的是同一套API体系,这点设计得相当优雅。
服务端方向,HTTP模块提供了http.createServer(),它创建的是一个事件监听器,本质上是把TCP层收到的数据按照HTTP协议解析成请求对象,然后交给你写的回调函数处理。客户端方向,HTTP模块提供了http.request()和http.get(),它们负责把你要发送的数据按照HTTP协议封装成请求报文,发出去之后再解析响应回来。
理解这层含义很重要。HTTP模块不是帮你“写业务逻辑”的,它是帮你“解析和构造HTTP报文”的。业务逻辑是你在回调函数里写的。所以同样一段代码,换不同的处理逻辑,就能变成接口服务、静态文件服务、代理服务或者API转发服务。
在实际项目中,框架帮你省掉的是路由匹配、中间件组织、参数解析这些重复劳动,但HTTP协议本身的行为——连接如何建立、报文如何解析、响应如何结束——依然是Node原生模块在管。框架出问题的时候,最终还是要回到这一层来排查。
1.2 为什么建议先学原生http,而不是直接上框架
我见过不少新手,学Node.js第一周就直接用Express写接口,写得很顺,但遇到一个诡异问题:请求偶尔会挂起,页面一直转圈。查了半天发现是没有正确调用res.end(),响应没有结束,连接一直挂着。这个问题的根源就是对HTTP模块的响应机制理解不够。
在框架里,很多细节被隐藏了。Express帮你自动设置了Content-Type,帮你处理了JSON序列化,帮你把路由匹配好了。这些方便是好事情,但代价是你不知道底层发生了什么。等出了线上故障,你会无从下手。
先学原生http有实打实的好处:你会亲手写res.writeHead()设置状态码和响应头,你会亲自解析URL路径做路由分发,你会处理POST请求体并自己解析JSON。这些做完一遍,再去用框架,你看到的就不是“魔法”而是“封装”。出了任何问题,你能顺着思路往下追。
我个人的建议是,框架可以用,但原生http这一课必须补。不用花太久,实现一个小服务器、处理几种请求类型、再写个客户端请求,半天时间就能建立完整的认知。这笔时间花得非常值。
2. 创建服务器:createServer的每个细节都在干什么
2.1 一个最小服务器背后发生了什么
直接上一段最精简的代码:
const http = require('http'); const server = http.createServer((req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('Hello World\n'); }); server.listen(3000, () => { console.log('server running at http://localhost:3000/'); });这段代码是所有Node HTTP服务器的起点。我拆开讲每一行到底干了什么。
http.createServer(callback)做的事情是创建一个Server实例,并注册了一个request事件监听器。当有客户端连接进来并发送HTTP请求时,Node内部会解析请求报文,构造出req和res两个对象,然后调用你的回调函数。
res.statusCode = 200是设置响应状态码。这里要理解,你设置的并不是一个抽象概念,而是要写入到响应报文状态行里的具体数字。HTTP响应报文的起始行长这样:HTTP/1.1 200 OK,Node会把你设置的状态码和对应的状态文本拼好写进去。
res.setHeader('Content-Type', 'text/plain')设置的是响应头。响应头是键值对格式,每个字段都封装了关于本次响应的元信息。这个例子里的text/plain是告诉客户端“返回的内容是纯文本”,浏览器拿到后会按纯文本渲染而不是当成HTML解析。
res.end('Hello World\n')这行最关键。写数据结束,必须调用end()来告诉Node:响应内容已经完整发送,可以结束这次响应了。end()可以接收一个可选参数作为最后一段要发送的数据。如果你不调用end(),客户端会一直等待,连接不会关闭,这就是请求挂起的常见原因之一。
server.listen(3000, callback)是让服务器开始监听3000端口。端口就是操作系统分配给网络服务的编号,客户端要访问你的服务,必须连到IP加端口这个组合上。监听成功后callback触发,打印启动日志。
2.2 请求对象req里到底藏了哪些信息
req是http.IncomingMessage的实例,它包含了客户端发来的所有信息。我在实际开发中最常用的字段有这些:
req.method:HTTP请求方法,比如GET、POST、PUT、DELETE。路由分发时判断方法很常用。req.url:请求的URL路径,注意它包含路径和查询字符串,比如/api/user?id=123。要拿到纯路径和参数,需要解析。req.headers:请求头对象,包含User-Agent、Content-Type、Cookie等所有请求头字段。req.httpVersion:客户端使用的HTTP版本,通常是1.1或2.0。req.socket.remoteAddress:客户端IP地址,做日志或限流时需要用到。
req本身还继承自流(Stream),也就是说,请求体(body)是以流的形式到达的。这意味着你不能简单地用一个变量去接POST请求的数据,而是要监听data事件和end事件来接收。
const http = require('http'); const url = require('url'); const server = http.createServer((req, res) => { console.log('请求方法:', req.method); console.log('完整URL:', req.url); console.log('请求头:', req.headers); const parsedUrl = url.parse(req.url, true); console.log('路径:', parsedUrl.pathname); console.log('查询参数:', parsedUrl.query); res.end('ok'); }); server.listen(3000);用url.parse(req.url, true)解析URL是传统写法。第二个参数传true表示把查询字符串解析成对象,这样parsedUrl.query就可以直接用。不过在较新的Node版本中,URL模块有了新的实现方式,我更推荐用new URL(req.url, 'http://localhost:3000')这种方式,兼容性和语义都更好。在网络热词里能看到大量关于Node.js版本的问题,其实很多都跟API的更新有关,用新写法能少踩很多坑。
2.3 响应对象res的正确使用姿势
res是http.ServerResponse的实例,它代表服务器将要发回客户端的响应。这个对象有几种写数据的方式,灵活运用很重要。
最传统的方式是res.writeHead(statusCode, statusMessage, headers):
res.writeHead(200, 'OK', { 'Content-Type': 'application/json', 'X-Powered-By': 'Node.js' });这种方式一次性设置状态码、状态描述和多个响应头,之后写数据就不会再改了。另一种方式是分别设置:
res.statusCode = 201; res.statusMessage = 'Created'; res.setHeader('Content-Type', 'application/json');两种方式效果差不多,但要注意writeHead和setHeader不能混用同一个头字段,否则可能抛出错误。我自己的习惯是:如果响应头很固定,用writeHead;如果后面可能根据业务动态调整,用setHeader。
数据本身就是流的写入过程。res.write(chunk)可以多次调用,用于分块发送数据,比如响应一个大型文件或流式内容。res.end([data])结束响应,也可以顺带发送最后一块数据。需要注意的是,end()之后不能再调用write(),否则会抛错。
还有两个实用方法推荐大家重视。res.writeHead设置Content-Length时,Node会根据实际写入的数据长度自动处理。res.flushHeaders()方法可以强制把已设置的响应头发送给客户端,这在长连接或SSE场景下很关键,能让客户端提前开始处理,而不是等整个响应结束。
3. 响应请求的完整实操:路由、状态码与数据格式
3.1 手写一个极简路由分发
Node原生http没有路由的概念,所有请求都进同一个回调。要区分不同接口,就得自己解析req.url和req.method。我写一个实用的分发器:
const http = require('http'); const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost:3000'); const path = url.pathname; const method = req.method; // 简单路由表 if (method === 'GET' && path === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('<h1>首页</h1>'); return; } if (method === 'GET' && path === '/api/users') { const users = [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(users)); return; } if (method === 'GET' && path.startsWith('/api/user/')) { const id = path.split('/').pop(); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ id, name: `用户${id}` })); return; } // 兜底404 res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); }); server.listen(3000);路由设计有几个关键点。第一,path.startsWith()做前缀匹配比精确匹配更灵活,适合带参数的RESTful接口。第二,每个分支处理完必须return,否则代码会继续往下走,造成重复响应。这个错误太常见了,新手经常踩。第三,兜底404一定要写,否则所有未匹配的请求都会得到200空响应,前端调试时会非常困惑。
路由表膨胀之后,可以把它整理成配置数组批量注册,本质上就是框架路由的原型。理解了这种思路,以后看Express的路由源码会轻松很多。
3.2 状态码和响应头的正确打开方式
状态码不是随便写的,它跟客户端行为直接相关。比如返回204 No Content时,客户端会认为没有响应体;返回301 Moved Permanently时,浏览器会自动跳转到Location指定的地址。我用一张表整理常用状态码及推荐使用场景:
| 状态码 | 含义 | 推荐使用场景 |
|---|---|---|
| 200 | OK | 请求成功,正常返回数据 |
| 201 | Created | 资源创建成功,如POST新增数据 |
| 204 | No Content | 请求成功但无返回体,如DELETE操作 |
| 301 | Moved Permanently | 永久重定向,SEO场景常用 |
| 302 | Found | 临时重定向 |
| 304 | Not Modified | 协商缓存命中,返回缓存的资源 |
| 400 | Bad Request | 客户端参数错误或格式不对 |
| 401 | Unauthorized | 未认证,如缺少登录凭证 |
| 403 | Forbidden | 已认证但无权限访问 |
| 404 | Not Found | 资源不存在 |
| 500 | Internal Server Error | 服务器内部错误 |
| 503 | Service Unavailable | 服务器过载或维护中 |
响应头方面,除了Content-Type,有几个字段值得特别留意。Cache-Control控制浏览器缓存策略,接口和静态资源需求不同,静态资源可以设max-age=31536000,接口一般设no-store。Access-Control-Allow-Origin处理跨域,前端调用接口报跨域错误时,要在这里配置。Location配合3xx状态码做跳转地址。
一个要注意的细节是:Content-Type里带上charset=utf-8。如果只写application/json,有些客户端在解析中文时可能按ISO-8859-1解码,导致乱码。加上charset后,客户端会明确用UTF-8解码,这是中文场景下的硬性要求。
3.3 处理POST请求与请求体解析
POST请求的数据在请求体里,不是一次性到达的。因为HTTP报文可能很大,Node出于性能考虑,用流的方式传递。接收请求体的代码如下:
const http = require('http'); const server = http.createServer((req, res) => { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Method Not Allowed'); return; } let body = ''; req.on('data', chunk => { // 注意控制大小,防止内存被撑爆 if (body.length > 1e6) { req.destroy(); // 请求体过大,直接断开连接 return; } body += chunk; }); req.on('end', () => { try { const data = JSON.parse(body); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ received: data })); } catch (err) { res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Invalid JSON'); } }); req.on('error', err => { console.error('请求体读取错误:', err); res.writeHead(400); res.end('Bad Request'); }); }); server.listen(3000);这段代码里有几个容易踩坑的地方。
第一,body += chunk在全量接收小请求体时是够用的,但对于大文件上传这种场景,不应该拼接字符串,应该用Buffer数组收集,再通过Buffer.concat()合并,避免反复创建字符串造成的内存开销。第二,请求体大小一定要做限制。如果不限制,恶意客户端可以持续发送数据让你的进程内存占用飙升,直接OOM。我习惯是JSON接口限制1MB以内,文件上传用单独的流式方案处理。第三,JSON解析一定要try-catch。客户端传来的不是合法JSON时,JSON.parse会抛异常,不捕获就会导致进程崩溃或者返回500。
3.4 在响应头里带上Request ID做全链路追踪
热词里有一条很有意思的记录,提到了“连接:客户端->服务器 请求ID:x”。这在生产环境是标配操作。在服务器上,给每个请求分配一个唯一的Request ID,并在处理过程中传递这个ID,排查问题时能串联起日志。
实现起来不复杂:
const http = require('http'); const crypto = require('crypto'); const server = http.createServer((req, res) => { // 优先用客户端传的Request ID,没有就自己生成 const requestId = req.headers['x-request-id'] || crypto.randomUUID(); res.setHeader('X-Request-ID', requestId); const timestamp = new Date().toISOString(); console.log(`[${timestamp}] [${requestId}] ${req.method} ${req.url}`); // ... 后续处理 res.on('finish', () => { console.log(`[${timestamp}] [${requestId}] 响应完成,状态码: ${res.statusCode}`); }); });这样做的核心价值在于:当一个请求经过Nginx、Node服务、数据库等多个环节时,只要每一层都传递同一个Request ID,就能把所有相关的日志串起来,快速定位问题发生在哪一跳。没有这个ID,在并发高的日志里找一条请求的全链路记录简直就是大海捞针。
4. 客户端请求:用http模块发起HTTP调用
4.1 http.get发起GET请求的完整流程
服务端写好了,Node同样可以扮演客户端的角色去请求其他服务。http.get()是发起GET请求最直接的方式:
const http = require('http'); http.get('http://localhost:3000/api/users', res => { let data = ''; res.on('data', chunk => { data += chunk; }); res.on('end', () => { try { const json = JSON.parse(data); console.log('请求成功:', json); } catch (err) { console.error('响应数据不是合法JSON:', err); } }); }).on('error', err => { console.error('请求失败:', err.message); });这里有一个关键点需要理解:http.get()返回的res是客户端收到的响应流,和服务端里的res是两种不同的对象。客户端这里同样要监听data和end事件来接收完整的响应体,因为响应体也是流式到达的。
关于请求错误处理,容易忽略两个场景。一是DNS解析失败、连接被拒绝时,error事件会触发;二是在接收响应数据的过程中如果连接中断,res上的error事件也会触发。所以稳妥的做法是同时监听req上的error和res上的error,避免错误被吞掉导致进程异常。
http.get内部其实调用了http.request(),只是默认把方法设为GET,并自动调用req.end()。所以http.get是http.request的语法糖。如果你只是简单抓取一个GET接口,用http.get最省事;要完全控制请求,就用http.request。
4.2 http.request发起POST请求与自定义header
POST请求需要写请求体,还要设置Content-Type和Content-Length,用http.request更合适:
const http = require('http'); const postData = JSON.stringify({ name: '新用户', email: 'user@example.com' }); const options = { hostname: 'localhost', port: 3000, path: '/api/users', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData), 'User-Agent': 'node-http-client/1.0' } }; const req = http.request(options, res => { let body = ''; res.on('data', chunk => { body += chunk; }); res.on('end', () => { console.log('状态码:', res.statusCode); console.log('响应头:', res.headers); console.log('响应体:', body); }); }); req.on('error', err => { console.error('请求失败:', err.message); }); // 写入请求体并结束请求 req.write(postData); req.end();有几个细节必须重视。Content-Length的计算要用Buffer.byteLength(postData),而不是postData.length。因为字符串的长度是字符数,而HTTP报文里的Content-Length是字节数。如果内容包含中文,两者不一致会导致请求被对端认为不完整,表现为请求一直挂起或者解析错误。
req.end()必须调用,它表示请求体发送完毕。忘记调用end()的后果是服务器一直在等待请求体结束,连接永远不会释放。这个错误在线上最容易发生,很多超时问题其实都是这个原因。
如果要发送文件上传类型的数据,Content-Type要改成multipart/form-data,还要设置boundary。手写会比较繁琐,实际项目中通常用form-data库或者直接交给框架处理。但理解手动构造的过程有助于后续排查问题。
4.3 超时、重试与并发的几个真实经验
客户端请求最怕两个问题:请求挂起不返回、请求失败没有重试。Node原生的http.request默认没有超时机制,如果对端服务响应很慢或者根本不响应,你的请求会一直挂着。设置超时是必须的:
const req = http.request(options, res => { // ...处理响应 }); // 设置请求超时时间,单位毫秒 req.setTimeout(5000, () => { console.error('请求超时,主动终止'); req.destroy(); });req.setTimeout(ms, callback)设置的是请求空闲超时,也就是说如果一段时间内没有任何数据活动,就触发回调。在回调里调用req.destroy()主动销毁请求,避免连接悬挂。这里有一个细节:超时触发后,请求对象会进入错误状态,后续的error事件也会触发,所以不要在销毁后再去写req.write或者继续处理数据。
关于重试,我总结了一个简单的策略:对幂等请求(GET、PUT、DELETE)可以放心重试,对非幂等请求(POST)必须谨慎。如果重试不小心造成重复下单或者重复扣款,后果很严重。稳妥的做法是给请求加上唯一业务ID,服务端做幂等校验,或者只在收到明确的网络错误时才重试,而不是在收到400/500这类业务状态码时盲目重试。
关于并发,Node的事件循环模型决定了它在处理大量并发请求时不需要像传统服务那样一个连接一个线程。但这也意味着一旦某个操作阻塞了事件循环,所有请求都会被堵住。客户端发送批量请求时,Promise.all加并发限制是常用手段:
const urls = [...Array(100).keys()].map(i => `http://localhost:3000/api/user/${i}`); // 限制并发为10 async function fetchWithLimit(urls, limit = 10) { const results = []; const queue = [...urls]; async function worker() { while (queue.length) { const url = queue.shift(); const data = await fetchUrl(url); results.push(data); } } const workers = Array.from({ length: limit }, () => worker()); await Promise.all(workers); return results; }并发限制是为了保护目标服务不被瞬间打爆。线上出过真实案例:批量请求100个接口,并发全开,目标服务直接503。限制并发数之后,服务稳定,速度反而更快,因为避免了目标服务的排队和超时重试。
5. 常见问题与排查技巧实录
5.1 端口占用:EADDRINUSE的快速定位
启动服务器的瞬间报Error: listen EADDRINUSE: address already in use :::3000,这说明3000端口已经被别的进程占用了。这个错误几乎每个Node开发者都遇到过。
在Linux或macOS上,用lsof -i :3000查看占用进程,然后kill -9 PID清掉。Windows上用netstat -ano | findstr :3000找到PID,再用taskkill /PID PID /F强杀。如果你在WSL环境开发,需要注意Windows和WSL的端口监听差异——WSL里的进程和Windows里的进程可能互相抢端口,需要检查两边各自的占用情况。
如果是开发环境频繁改代码,可以用server.on('error')做个兜底处理:
server.on('error', err => { if (err.code === 'EADDRINUSE') { console.error(`端口 ${port} 已被占用,尝试使用 ${port + 1}`); server.listen(port + 1); } });但生产环境不建议这样自动换端口,因为客户端是固定端口访问的,换端口会导致服务不可达。生产环境一定要保证端口的一致性,靠进程管理工具(如PM2)统一管理,避免端口冲突。
5.2 中文乱码与编码问题的根因
接口返回中文变成乱码或者\uXXXX形式,这个问题的根源是响应头的Content-Type里没有明确指定charset,或者客户端与服务端的编码不一致。
Node源码里字符串默认是UTF-8,只要响应头设置了charset=utf-8,浏览器和绝大多数HTTP客户端都会用UTF-8解码,中文正常显示。如果不设置,某些客户端会按平台默认编码(比如Windows的GBK)解码,自然乱码。
另外要注意JSON序列化的问题。JSON.stringify默认不会转义非ASCII字符,所以返回的JSON里中文字符会直接以UTF-8的原始字符输出。如果你在日志里看到类似{"name":"\u5f20\u4e09"}的形式,那是JSON库做了Unicode转义,这是合法的JSON表示方式,客户端解析后仍然是中文,不用慌。
还有一种情况是保存在文件或数据库里的数据本身已经乱码了,这不是HTTP模块的问题,而是写数据时的编码问题。排查时先确认HTTP响应头的charset是否一致,再确认数据源的存储编码。
5.3 高并发下连接不释放、响应慢的排查思路
高并发场景下,最常见的两个问题是连接数耗尽和响应速度下降。先说连接数,HTTP/1.1默认开启Keep-Alive,也就是TCP连接在请求结束后不会立即关闭,而是复用。这是好事情,省去了反复建立连接的开销。但如果客户端没有正确关闭空闲连接,或者服务端没有设置Keep-Alive的超时时间,空闲连接越积越多,最终把端口和文件描述符耗尽。
服务端可以通过设置server.keepAliveTimeout来控制空闲连接的超时时间,一般5000毫秒左右是合理值。如果业务场景是短连接为主,可以直接设置server.headersTimeout来控制请求头超时,避免慢速连接长期占用资源。
响应慢的排查思路,我一般按照这个顺序来:先看机器的CPU和内存,如果CPU接近100%,大概率是事件循环被阻塞。在代码里可以用console.time打印关键操作的耗时,定位到具体是哪个环节慢。再看请求是否在等待IO,比如数据库查询慢、外部API调用慢。最后检查是否有大量的同步JSON序列化、正则匹配这类CPU密集型操作阻塞了事件循环。
之前排查过一个真实案例:某个接口平时10毫秒返回,高并发时变成3秒。排查发现是接口里有一段对超大数组做排序的同步操作,单次执行要100多毫秒,并发一高,所有请求排队。优化方案是把排序改成异步的、分片处理,或者提前缓存结果,接口响应时间立刻掉回20毫秒以内。
Node的http模块是个很典型的基础能力,说简单确实简单,但深入下去处处是细节。很多看起来“莫名其妙”的线上问题,追到底都是对请求响应生命周期、编码处理、连接管理这些基础点理解不透。我自己的体会是,花点时间把原生模块吃透,比多学一个框架更能提升排查问题的能力。希望这篇能把HTTP模块这层窗户纸捅破,后面你再用任何Node框架,都会有一种“原来如此”的顺畅感。