ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Node.js原生http模块详解:从创建服务器到客户端请求

Node.js原生http模块详解:从创建服务器到客户端请求 网上讲Node.js的教程十个里有八个直接用Express上手原生http模块往往被一句话带过。我更愿意反过来先把Node.js HTTP模块弄明白因为它是所有Web框架的地基。你在Koa里操作ctx.body、在Express里操作res.json底层都跑不脱createServer那套请求与响应机制。这篇东西不讲框架只讲Node.js原生http模块里创建服务器、响应请求、发起客户端请求这三件最基本的事。适合刚学Node想打牢基础的人也适合写脚本、做中间层服务时不想引入完整框架的人。放心看完你能徒手写一个简单的HTTP服务也能明白框架底层到底做了什么。1. 为什么说http模块是所有Node Web框架的地基1.1 框架封装了什么没封装什么Express、Koa这些框架确实做了很多事路由匹配、请求体解析、响应序列化、中间件编排。但你真的去读它们的源码就会发现最底层一定是node:http模块。Koa的ctx.request和ctx.response底层就是http模块的req和res的二次封装框架帮你把流式请求数据攒成body对象帮你把JSON序列化后塞进响应体。这意味着如果不理解http模块你很难调试框架层面的问题。举个真实例子Koa里用ctx.request.body读不到数据很多人第一反应是是不是框架有bug但如果你知道原生http里请求体是一个Readable流数据是分块到达而不是一次给全你就会意识到问题可能出在请求体还没被消费完就去做解析了。这个认知的欠缺才是玄学bug的真正根源。1.2 哪些场景其实不需要框架不是所有HTTP服务都需要Express。我在实际项目里经常遇到只需要一个简单接口的情况比如接收第三方平台的通知回调、提供一个健康检查端点、搭一个本地Mock Server。用框架反而要装一堆依赖走一遍中间件初始化流程部署体积也更大。一个几十行的原生http服务器就够用了。当然不是说框架没用。业务复杂、路由多、中间件生态丰富的时候直接用框架是合理的选择。但作为Node开发者原生http应该是基本素养。就像开车不需要自己造发动机但你至少知道发动机在哪个位置、为什么车会抖——修车的时候不至于两眼一抹黑。1.3 一个最小可运行的服务先放一个最小的服务热个身const http require(node:http); const server http.createServer((req, res) { res.writeHead(200, { Content-Type: text/plain; charsetutf-8 }); res.end(hello, http\n); }); server.listen(3000, () { console.log(server running at http://127.0.0.1:3000); });这段代码里包含了几件事createServer创建了一个服务器实例回调函数里描述了收到请求后怎么响应listen把服务器绑定到3000端口。这段代码放在任意目录node server.js就能跑起来浏览器访问127.0.0.1:3000可以看到结果。不要因为它简单就忽略后面所有复杂服务器的实现都是从这三行扩展出来的。2. 创建服务器createServer与listen背后真正发生的机制2.1 createServer的回调不是普通函数调用从表现看createServer传入的回调确实是每次请求到达时执行一次。但它背后是事件驱动Node在底层维护了一个TCP服务器每个TCP连接建立、每个数据块到达都会触发对应的事件。我们写的回调本质上是注册了一个对request事件的监听器。以下两种写法在效果上是等价的const server http.createServer((req, res) {}); // 等价于 const server new http.Server(); server.on(request, (req, res) {});这是理解Node异步模型的关键一步回调不是你主动调用的而是事件循环在某个时机被动触发的。所以我会在写完createServer之后紧接着加一个error监听防止服务器运行中碰到异常直接崩溃server.on(error, (err) { console.error(server error:, err.message); });2.2 listen端口绑定和几个容易忽略的参数大多数教程只教你server.listen(3000)但listen还有更多参数值得了解server.listen(port, host, backlog, callback);port监听端口。传0表示随机分配一个可用端口这在写自动化测试时特别有用。host绑定的地址。默认是::相当于监听所有网络接口。如果只想本机访问可以绑定127.0.0.1如果想局域网内其他机器也能访问绑定0.0.0.0。backlog连接等待队列长度一般不需要动。callback监听成功后的回调。有个坑值得单独说host只写127.0.0.1时局域网内其他机器访问不到。我遇到过同事在公司内网起服务怎么都连不上最后发现host绑定成了localhost。本机访问没问题远程访问全部拒绝这就是host绑定的细节。反过来如果你bind 0.0.0.0本机通过127.0.0.1访问依然可以因为回环地址也在监听范围内。提示不要把端口和host硬编码在代码里。用process.env.PORT和process.env.HOST读取环境变量实际部署时调整不需要改代码。2.3 端口被占用时的排查路径启动服务最常见的报错是Error: listen EADDRINUSE: address already in use :::3000这代表端口被别的进程占用。排查方式分平台Linux/macOSlsof -i :3000查看谁占用了3000端口Windowsnetstat -ano | findstr :3000再通过任务管理器找到对应PID知道占用者之后要么kill掉旧进程要么换一个端口。如果希望代码自动处理可以做一次fallback尝试下一个端口const tryListen (port, retries 3) { const server http.createServer(handler); server.on(error, (err) { if (err.code EADDRINUSE retries 0) { console.log(port ${port} is in use, try ${port 1}); tryListen(port 1, retries - 1); } }); server.listen(port); };实际开发时我更推荐在脚本里显式指定端口环境变量而不是让代码自动跳端口。自动跳端口在调试时会造成困惑——你明明访问的是3000端口服务实际落在3001半天找不到原因。3. 请求对象requrl解析、路由分发与请求体读取3.1 req.url到底是什么不少人被req.url误导以为它就是完整网址。实际上req.url是客户端请求的路径部分包含查询参数但不包含域名和协议。比如浏览器请求http://127.0.0.1:3000/api/users?id42req.url拿到的值是/api/users?id42要解析它可以用Node内置的url模块const parsed new URL(req.url, http://localhost); const pathname parsed.pathname; // /api/users const searchParams parsed.searchParams; // URLSearchParams对象 const id searchParams.get(id); // 42注意new URL的第二个参数必须给否则遇到相对路径会直接抛错。我见过有人漏写第二个参数接口一调用就500排查了半天才发现是这里的问题。3.2 最简单的路由分发原生http没有Router但小型接口的路径分发可以自己写。我常用的是下面这个写法const server http.createServer((req, res) { const parsed new URL(req.url, http://localhost); if (req.method GET parsed.pathname /health) { res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ status: ok })); return; } if (req.method POST parsed.pathname /api/echo) { let body ; req.on(data, chunk { body chunk; }); req.on(end, () { res.writeHead(200, { Content-Type: application/json; charsetutf-8 }); res.end(JSON.stringify({ echo: body })); }); return; } res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(not found); });注意这里的return非常关键。http回调和事件回调都是异步的如果不return匹配到一个路由之后还会继续走到下面的判断可能导致重复写响应头或重复end直接抛ERR_HTTP_HEADERS_SENT。我自己写原生路由时会刻意让每个分支都提前return相当于手动做了一套一次响应约束。3.3 请求体是流data事件和end事件的正确定位这是我见过最多人踩坑的点。新手直觉认为req是已经收到的完整请求于是直接req.body拿数据结果拿到undefined。原生http里请求体是一个Readable流数据按大小分块到达必须通过监听data事件和end事件来获取完整Body。最基础的读取方式const chunks []; req.on(data, chunk { chunks.push(chunk); }); req.on(end, () { const body Buffer.concat(chunks).toString(utf-8); // 拿到完整body后做业务处理 });为什么用Buffer.concat而不是直接字符串拼接因为chunk是Buffer对象如果body里有二进制内容或者多字节字符比如中文、emoji直接做字符串拼接可能在某次分块边界处出错。Buffer.concat是稳妥的做法。如果你确定body只是普通文本且编码是UTF-8累加字符串也没问题。但一旦遇到gzip压缩、multipart/form-data等复杂body类型就需要更专业的处理了。这其实就是框架里body-parser在做的事把所有chunk收齐后根据Content-Type做不同解析。3.4 req.headers的键名规则与安全边界req.headers在Node里会自动把键名转成小写。你客户端发的是Content-Type在req.headers里拿到的键是content-type。这个细节和浏览器端Headers对象的大小写不敏感行为略有不同容易让不熟悉的人困惑但实际上用起来问题不大——你只要统一用小写键名读取即可。另一个安全边界也值得提Node默认单个请求头大小限制是16KB超出会返回413 Payload Too Large。如果确实需要接收大请求头可以在创建服务时调大const server http.createServer({ maxHeaderSize: 32 * 1024 }, handler);不过一般业务里不需要动这个。更需要关注的是客户端传来的Content-Length不可信流式读取时应该自己限制body大小防止有人提交一个几百MB的body把你的内存打爆。在data事件里累计字节数超过阈值就主动调用req.destroy()这是一个简单且重要的防护。4. 响应对象res状态码、响应头与中文乱码背后的编码逻辑4.1 writeHead与setHeader的用法差异设置响应头的常见做法有两套// 方式一一次性设置 res.writeHead(200, { Content-Type: application/json; charsetutf-8, X-Powered-By: node }); // 方式二分开设置 res.statusCode 200; res.setHeader(Content-Type, application/json; charsetutf-8); res.setHeader(X-Powered-By, node);两种方式在大多数场景等价。但注意writeHead会立刻发送状态行和头信息如果之前已经用setHeader设置过某个字段之后又用writeHead设置同名字段writeHead里的值会覆盖之前的。建议固定使用一种方式不要混用否则排查响应头异常时会浪费不少时间。有一个铁律一旦调用res.write或res.end响应头就不能再改了否则抛ERR_HTTP_HEADERS_SENT。这个错误出现频率极高原因通常是异步回调里又发了第二次响应。比如在路由里return之前调用了res.end之后异步回调又写了一次头。这种错误在并发场景下尤其隐蔽因为不是每次请求都会触发。4.2 中文乱码字节编码与charset的关系浏览器打开一个返回中文的页面如果乱码八成是响应头里没有指定charset。比如res.writeHead(200, { Content-Type: text/plain }); res.end(你好世界);浏览器可能用默认的ISO-8859-1解析文本中文自然乱码。正确做法是res.writeHead(200, { Content-Type: text/plain; charsetutf-8 });返回JSON时同样建议带上charsetres.writeHead(200, { Content-Type: application/json; charsetutf-8 });这不是玄学。HTTP响应里的Content-Type带着charset信息浏览器和HTTP客户端都靠它决定解码方式。这也是我写原生http服务时几乎每个响应头都不会漏掉charset的原因。反过来如果别人调你的接口出现乱码通常不是你的接口坏了而是你少交代了一个编码信息。4.3 响应体输出write、end与流式响应最简单的输出是res.end(content)直接把content发送出去并结束响应。注意end只能调用一次调用之后再res.write会报错write after end。如果响应内容很大可以分块write最后再endres.write(html); res.write(body); // 实际项目中可能是一段一段生成 res.end(/body/html);这天然引出了流式响应的用法当你需要返回一个持续生成的内容比如日志流、服务器推送事件可以用res.write持续输出直到某个条件满足后再res.end。这是一种很实用的能力原生http直接支持不需要额外封装。有个常见误解res.write之后立刻res.end客户端有时收到的内容不完整。实际上在Node事件循环中这些write调用会按顺序缓冲最后的end会关闭响应流。如果发现客户端收到一半的响应更可能的原因是服务端在write过程中崩溃或连接被中断而不是write和end的顺序问题。5. 作为客户端发请求http.get与http.request实战解析5.1 http.get最简单的GET请求Node.js的http模块不仅可以做服务器也可以作为客户端主动发起请求。这在内部服务相互调用、拉取第三方接口、写脚本做接口巡检时非常常用。最基础的是http.getconst http require(node:http); http.get(http://127.0.0.1:3000/api/users, (res) { let data ; res.on(data, (chunk) { data chunk; }); res.on(end, () { console.log(status:, res.statusCode); console.log(body:, data); }); }).on(error, (err) { console.error(request error:, err.message); });要点有三个http.get只支持GET方法内部会自动调用req.end()不需要手动结束。响应res在这里也是一个流同样需要通过data和end事件读取。不要忘记监听error事件否则请求失败时未捕获的错误可能直接让进程崩溃。如果只关心状态码不关心body可以在end回调里判断res.statusCode 400。这在健康检查脚本里非常方便几十行就能搞定一个批量接口探测工具。5.2 http.request完整的请求控制能力POST、PUT、DELETE、自定义请求头、携带请求体这些都需要http.request。它比http.get多一个步骤返回的是ClientRequest对象需要用req.write写入请求体然后req.end()发送。const http require(node:http); const postData JSON.stringify({ name: node }); const req http.request( { hostname: 127.0.0.1, port: 3000, path: /api/users, method: POST, headers: { Content-Type: application/json, Content-Length: Buffer.byteLength(postData), }, }, (res) { let body ; res.on(data, (chunk) { body chunk; }); res.on(end, () { console.log(response:, body); }); } ); req.on(error, (err) { console.error(request error:, err.message); }); req.write(postData); req.end();Content-Length这里特别说一下很多人会直接写postData.length但postData是字符串时length是字符长度HTTP的Content-Length要求的是字节长度。中文字符在UTF-8编码下是3个字节比如你好的字符串length是2但字节数是6。如果Content-Length算少了服务端解析body时可能截断数据算多了服务端会一直等不到end事件连接挂住。用Buffer.byteLength计算才是准确做法。5.3 超时、取消与重定向处理ClientRequest默认不设超时时间。如果目标服务器一直不响应进程会一直挂着。我习惯在请求开始前设置超时req.setTimeout(5000, () { req.destroy(new Error(request timeout)); });setTimeout的回调触发后请求并不会自动销毁需要你手动调用req.destroy。destroy之后会触发error事件在error监听里统一处理超时错误。再说一个很容易被忽略的点Node原生http不会自动跟随30x重定向。如果接口返回302你必须看res.headers.location然后自己再发起一次新请求。这和浏览器自动跟随的行为完全不同。我早年用原生http拉一个带重定向的登录接口折腾了半天才发现问题后来写了一个跟随重定向的小函数function getWithRedirect(url, redirects 5) { if (redirects 0) return Promise.reject(new Error(too many redirects)); return new Promise((resolve, reject) { http.get(url, (res) { if ([301, 302, 303, 307, 308].includes(res.statusCode) res.headers.location) { res.resume(); // 释放掉当前响应的数据流否则连接可能挂住 resolve(getWithRedirect(res.headers.location, redirects - 1)); return; } let body ; res.on(data, (chunk) { body chunk; }); res.on(end, () resolve({ status: res.statusCode, body })); }).on(error, reject); }); }这里有个很多教程不会写的关键细节遇到重定向时先通过res.resume()消费当前响应流的数据。如果你不消费当前响应流里残留的数据一直不被读取底层socket无法被复用连接数会堆积。这在写批量请求脚本时尤其重要。6. 从踩坑现场排查POST数据丢失、连接挂起与write after end6.1 场景一POST提交的数据解析不出来我帮同事排查过一个接口POST请求发出后服务端日志里req.body一直是undefined。查了代码发现他确实写了data事件和end事件但打印出来的body是空字符串。最后定位到问题是客户端发送的请求体是JSON格式但Content-Type设置成了application/x-www-form-urlencoded服务端按表单格式解析数据自然对不上。常见原因整理成一个表现象原因处理方式body为空字符串客户端没发送请求体或发送前就end了先打印req.headers[content-length]判断是否有bodybody是undefined用了框架但忘了配置body解析中间件检查框架配置通常是编码格式不匹配中文变成问号请求体的charset和服务端解析时不一致统一用UTF-8服务端按Buffer转文本大文件传不进来默认内存限制或fd限制改用流式接收或临时文件方案排查这类问题我有个固定步骤先看Content-Type再看Content-Length最后看请求体的原始字节。把这三样对齐了90%的body解析问题都能找到答案。6.2 场景二客户端请求一直挂住不返回服务端几乎什么都没做客户端却一直等到超时。这种情况最常见的原因是客户端发送的请求体没有被服务端消费完。比如你用curl发了一个POSTbody是空的服务端又没有读取req流此时如果服务端不主动res.end连接就可能一直挂着。原生http服务里哪怕你不关心请求体也应该消费掉或者明确结束响应req.resume(); // 把请求体数据流全部丢弃让连接更快释放这个细节在写健康检查接口时特别重要。如果服务端不消费请求体连接池和keep-alive的行为会变得很奇怪低并发时没问题高并发下连接数容易异常增长。我在写原生http服务时如果某个路由不需要读body会在进入路由前加一句req.resume()。6.3 场景三write after end错误ERR_HTTP_HEADERS_SENT和write after end是我见过最高频的两个原生http报错。前者是响应头已经发出后再设置头后者是响应已经结束后再次写入。实际触发场景多半是异步回调。举个例子http.createServer((req, res) { if (req.url /fast) { res.end(ok); } setTimeout(() { res.end(slow); // 这里就报错 }, 1000); });第一个res.end已经发送了第二个res.end再执行时连接早已关闭直接崩。解决方法是每个分支都提前return确保只有一条路径会写响应。这听起来简单但在代码分支较多、异步逻辑嵌套较深时确实容易漏。6.4 场景四EADDRINUSE与高并发连接数限制端口被占用是最容易定位的问题但高并发下还有另一个坑文件描述符限制。Linux/Unix默认的文件描述符限制通常是1024如果你做压测连接数超过1024Server端就会报EMFILE或连接建立失败。需要临时提高限制ulimit -n 65535或者用更专业的部署方案比如把Node服务放在Nginx后面统一管理连接。Node原生http的并发处理能力其实不弱但受限于系统的fd限制和单线程Event Loop的性能边界需要合理规划部署架构。自己写脚本压测时先把ulimit调上去否则压测结果会严重失真。写在最后的一点体会Node的http模块并不复杂只是入口多、细节碎加上req和res都是流式对象理解上需要跨几个台阶。我在实际项目里写了不少小工具和简单接口用的都是这套原生能力只有业务复杂度上来之后才引入框架。先把这块地盘踩熟后面学Express、Koa、NestJS都会顺畅很多。最后再分享一个小技巧如果你经常写原生http服务可以封装一个sendJson函数把writeHead和end统一收口减少重复代码的同时也降低漏写charset的概率。这小改动看起来不起眼但能在后续项目里帮你省掉不少乱码问题的排查时间。
返回列表