
玩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 OKNode会把你设置的状态码和对应的状态文本拼好写进去。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.methodHTTP请求方法比如GET、POST、PUT、DELETE。路由分发时判断方法很常用。req.url请求的URL路径注意它包含路径和查询字符串比如/api/user?id123。要拿到纯路径和参数需要解析。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; charsetutf-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; charsetutf-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; charsetutf-8 }); res.end(JSON.stringify({ id, name: 用户${id} })); return; } // 兜底404 res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(Not Found); }); server.listen(3000);路由设计有几个关键点。第一path.startsWith()做前缀匹配比精确匹配更灵活适合带参数的RESTful接口。第二每个分支处理完必须return否则代码会继续往下走造成重复响应。这个错误太常见了新手经常踩。第三兜底404一定要写否则所有未匹配的请求都会得到200空响应前端调试时会非常困惑。路由表膨胀之后可以把它整理成配置数组批量注册本质上就是框架路由的原型。理解了这种思路以后看Express的路由源码会轻松很多。3.2 状态码和响应头的正确打开方式状态码不是随便写的它跟客户端行为直接相关。比如返回204 No Content时客户端会认为没有响应体返回301 Moved Permanently时浏览器会自动跳转到Location指定的地址。我用一张表整理常用状态码及推荐使用场景状态码含义推荐使用场景200OK请求成功正常返回数据201Created资源创建成功如POST新增数据204No Content请求成功但无返回体如DELETE操作301Moved Permanently永久重定向SEO场景常用302Found临时重定向304Not Modified协商缓存命中返回缓存的资源400Bad Request客户端参数错误或格式不对401Unauthorized未认证如缺少登录凭证403Forbidden已认证但无权限访问404Not Found资源不存在500Internal Server Error服务器内部错误503Service Unavailable服务器过载或维护中响应头方面除了Content-Type有几个字段值得特别留意。Cache-Control控制浏览器缓存策略接口和静态资源需求不同静态资源可以设max-age31536000接口一般设no-store。Access-Control-Allow-Origin处理跨域前端调用接口报跨域错误时要在这里配置。Location配合3xx状态码做跳转地址。一个要注意的细节是Content-Type里带上charsetutf-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; charsetutf-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; charsetutf-8 }); res.end(JSON.stringify({ received: data })); } catch (err) { res.writeHead(400, { Content-Type: text/plain; charsetutf-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请求与自定义headerPOST请求需要写请求体还要设置Content-Type和Content-Length用http.request更合适const http require(http); const postData JSON.stringify({ name: 新用户, email: userexample.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只要响应头设置了charsetutf-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框架都会有一种“原来如此”的顺畅感。