不知道你有没有过这种经历:第一次照着网上的教程写Node.js,敲完node server.js,终端老老实实打出一行Server running at http://localhost:3000/,然后浏览器一开,满心期待看到页面,结果屏幕上只有一行冰冷的Cannot GET /。这时候大多数人的第一反应是"代码写错了?"但代码明明只比教程少了三五行。再仔细看,教程里写的是res.end('Hello World'),你写的是res.write('Hello World'),请求一直挂着转圈圈,页面死活不出来。
这不是代码错误,而是你对HTTP模块的理解还停留在"照着抄"的阶段。Cannot GET /是Express等框架返回的默认404提示,它想告诉你的其实是:你创建了一个HTTP服务器,但你根本没告诉服务器,当浏览器请求根路径/的时候应该返回什么。换句话说,你已经把服务器进程拉起来了,但请求来了之后怎么响应、响应些什么内容,这套完整链路里有一大半你还不知道。
Node.js的HTTP模块就是整个Web服务最底层的那层"原始能力":它不帮你做路由、不帮你解析POST表单、不帮你处理静态文件,它只负责三件事——创建服务器接收请求、把请求数据解析成你能读的对象、把你要返回的数据封装成标准HTTP响应发回去。恰好,这三件事就是你这个标题里的三个关键词:创建服务器、响应请求、客户端请求。这篇文章我不打算给你堆一份API文档式的罗列,而是按一条真实项目的开发线索,把这套模块从底层逻辑到实战坑位完整过一遍。
1. 先搞清楚:HTTP模块到底封装了什么、没封装什么
1.1 HTTP协议的本质:请求-响应的"回合制对话"
要想用好HTTP模块,你得先忘掉"Node.js"这几个字,回到HTTP协议本身去理解一次完整的HTTP通信是什么。你可以把HTTP想象成两个人的回合制对话,对话永远由客户端(通常是浏览器)先开口,它会发一段带上固定格式的文字,这段文字叫HTTP请求报文。
请求报文长这样,我拆开给你看:
code复制POST /api/login HTTP/1.1
Host: www.example.com
Content-Type: application/json
Content-Length: 31
Connection: keep-alive
{"username":"admin","password":"123456"}
第一行叫请求行,里面有三个要素:请求方法POST、请求路径/api/login、HTTP版本号HTTP/1.1。接下来几行以键: 值格式出现的内容叫请求头,Host告诉服务器你要访问哪个域名,Content-Type告诉服务器请求体里的数据是什么格式。空行之后再往后的内容叫请求体,也就是POST请求携带的正文数据。
服务器接收到这段内容之后,会解析出方法、路径、请求头、请求体,然后处理业务逻辑,最终返回一段HTTP响应报文:
code复制HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 11
Date: Mon, 01 Jul 2024 08:00:00 GMT
Hello World
结构跟请求报文几乎一一对应:第一行HTTP/1.1 200 OK是状态行,包含HTTP版本、状态码200、状态描述OK;接下来是响应头;空行之后是响应体——也就是浏览器实际渲染出的内容。
很多人学Node.js,一开始被各种框架"保护"得太好,根本没接触过这两段原始报文。你只要打开浏览器的开发者工具,切到Network标签,随便点开一个请求看它的Headers面板,就能看到原文(Raw)视图。我第一次手动对着原始报文调试接口的时候,才真正意识到:Node.js的HTTP模块,本质就是帮你解析和组装这两段报文。它做的事情并不神秘:你给它一段请求报文,它解析成req对象给你用;你往res对象上写数据,它帮你组装成响应报文发出去。
1.2 Node.js的HTTP模块处在整个网络栈的哪一层
这个问题很多人没想过,但它直接关系到你能不能看懂后面所有的源码和报错。
完整的TCP/IP网络栈有四层:链路层、网络层、传输层、应用层。HTTP属于应用层协议,而Node.js的HTTP模块并不是直接从网卡抓数据来解析的,它依赖下面一层——net模块提供的TCP能力。你可以把TCP理解成一个"双向管道":它只负责字节流的可靠传输,不关心这些字节流的内容是什么格式。两个进程只要建立TCP连接,就可以互相扔字节。至于扔过来的字节是一段视频、一段文本还是一份HTTP报文,TCP不管。
HTTP模块做的事情,是在TCP管道之上增加一个"翻译层"。Node.js内置了一个很高效的HTTP解析器(历史上是http-parser,后来C++绑定逐步替换成了llhttp),它负责从TCP流里按\r\n\r\n这样的分隔符切出头部,再按照Content-Length或Transfer-Encoding切出请求体。解析完成后,Node.js把请求数据封装成一个IncomingMessage实例挂到req参数上,同时创建一个ServerResponse实例挂到res参数上。整个回调函数就是一个"已经翻译好的上下文"。
这个分层关系决定了几个非常重要的实践结论:
- HTTP模块不负责管理TCP连接的生命周期。TCP连接是
net模块创建的,HTTP只在上面做报文解析。所以你会碰到ECONNRESET、socket hang up这类底层的错误,它们本质上不是HTTP层面的问题,而是TCP层面的连接被对方重置了。 - HTTP模块不包含TLS/SSL加密能力。需要HTTPS的时候得用
https模块,它在内部复用了HTTP模块的逻辑,只是额外加上TLS层。 - HTTP模块不做请求分发。创建出来的服务器接收所有请求,然后全部丢给你注册的回调函数,由你自己决定什么样的路径、什么样的方法该怎么处理.这也是我们看到的框架(Express、Koa)存在的意义:它们在HTTP模块外面套一层路由和中间件逻辑。
我还想强调一点:HTTP模块的核心是一个基于事件驱动、自带状态机的C++解析器。当TCP数据包一块块到达时,解析器处于"正在解析请求头"或"正在解析请求体"这样的状态中,每解析出一个字段就触发一次回调。如果你以后去读HTTP模块的源码,会看到大量parserOnHeaders、parserOnBody之类的函数名,它们都是这个状态机的不同阶段回调。理解这一点,你就明白为什么Node.js能轻松胜任高并发I/O场景——它不会为每个请求单独阻塞一个线程等数据,而是在同一个线程里用事件轮询的方式"拼装"数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 创建服务器:从createServer到listen背后的完整事件链
2.1 createServer(callback)到底做了什么
我们来看最经典的创建服务器代码:
javascript复制const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('Hello Node.js\n');
res.end();
});
server.listen(3000, '127.0.0.1', () => {
console.log('服务器已启动: http://127.0.0.1:3000');
});
这一小段代码每一个API背后都有讲究。http.createServer接收一个回调函数,这个回调函数的官方叫法是requestListener。函数内部源码大概是这样的:
javascript复制function createServer(opts, requestListener) {
return new Server(opts, requestListener);
}
function Server(options, requestListener) {
if (requestListener) {
this.on('request', requestListener);
}
}
也就是说,http.createServer返回的server是一个Server实例,这个实例继承了net.Server,并且内部是一个EventEmitter。你传进去的回调函数并没有被放在什么神秘的地方,它被注册成了request事件的事件监听器。这意味着,每次有一个HTTP请求到达,服务器就会触发一次request事件,然后执行你这个回调。
搞清楚这一点对理解Node.js的并发模型非常关键:Node.js是单线程的(这里说的是JavaScript执行线程,libuv线程池另说),所有请求共享一个线程。如果回调函数里有一个耗时的同步操作,比如while (true) {},那么第二个请求来了也只能在事件队列里排队,等到第一个请求的回调执行完才轮到它。这也是为什么Node.js生态里反复强调"不要在响应请求的主路径上做同步阻塞操作"。
Server实例自己还维护着几个事件,你在实际项目中会接触到:
connection:底层TCP连接建立时触发。request:一个HTTP请求解析完成、可以交给业务代码处理时触发。close:服务器关闭时触发。clientError:客户端连接异常时触发,默认情况下会发送400状态码并关闭连接。
2.2 listen背后发生了什么:端口绑定与回调时机
server.listen(3000, '127.0.0.1', callback)这一段同样值得仔细说说。3000是端口号,127.0.0.1是要绑定的IP地址,callback是监听成功后的回调。为什么大多数教程监听的都是3000或8080?因为它们是比较常见的高端口号,不需要管理员权限。80端口是HTTP协议默认端口,但它在Linux/macOS上默认需要root权限才能绑定;在Windows上通常不会有这个问题,但依然可能被IIS、Apache之类的软件占用。如果你在部署Node服务时接到一个需求要监听80端口,最稳妥的做法是让Node监听一个高端口(比如3000),再用Nginx等反向代理把80端口的流量转发过去。
listen调用发生的时候,Node.js会执行到libuv层,创建一个uv_tcp_t句柄,然后调用操作系统的bind和listen系统调用。注意,此刻进程并没有阻塞等着请求到来,listen之后进程继续跑事件循环。当操作系统内核收到TCP握手请求时,它会通知libuv,libuv把事件丢进Node.js的事件队列,最终触发我们之前注册的connection事件。所以listen回调函数的执行时机,是在端口成功绑定的那一刻,而不是收到第一个请求的那一刻。
这里有一个很多新手会踩的坑:如果你连续启动两次同一个服务,第二次启动会报Error: listen EADDRINUSE: address already in use :::3000。意思是3000端口已经被占用了。这在调试的时候太常见了,解决办法很简单:
- Linux/macOS下:执行
lsof -i :3000查看占用进程的PID,然后kill -9 PID。 - Windows下:执行
netstat -ano | findstr :3000找到PID,再执行taskkill /F /PID 你的PID。
也可以换一个策略,让listen回调里输出端口号和进程PID,方便后续管理:
javascript复制server.listen(3000, () => {
console.log(`server running at http://localhost:${3000}`);
console.log(`当前进程 PID: ${process.pid}`);
});
2.3 request事件之后:req对象为什么是流
现在回到request事件监听器里的req参数。很多初学者知道req.url、req.method、req.headers这些属性,但很少注意到req本质上是一个IncomingMessage对象,而IncomingMessage继承了stream.Readable。也就是说,请求对象本身是一个可读流,请求体数据是通过流的方式一点一点流进来的,而不是在回调执行那一刻一次性全部给你。
为什么这么设计?因为HTTP请求体可能很大,比如用户上传一个几百MB的文件。如果Node.js在解析完请求头之后还要等全部请求体到齐才触发回调,那么内存里就得先攒下整个请求体,服务器分分钟被撑爆。流式处理的好处是:数据到达一段就让你消费一段,内存占用始终保持在一个很低的水位。
但这也带来一个使用上的关键规律:如果业务代码没有监听req的data事件去消费请求体数据,这些数据会被Node.js内部自动丢弃,不会一直占用内存。对于GET请求这没有问题,因为GET通常没有请求体;但对于POST、PUT这类携带请求体的请求,如果你不在回调里读取请求体就直接res.end(),前端收到的响应没有任何问题,但你拿不到提交的数据,而且这种"丢数据"是不会报错的,特别隐蔽。
我把读取请求体数据的基本写法写在下面,这是后面第四、第五章节频繁要用的基础模板:
javascript复制const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
console.log('请求体内容:', body);
res.end('收到');
});
});
注意一个隐蔽的细节:chunk默认是Buffer对象,如果用body += chunk这样的字符串拼接,JavaScript会自动调用Buffer.toString()把它转成字符串。如果请求体里包含中文且原始编码不是UTF-8,这里就可能出现乱码。常见的HTTP请求体编码都是UTF-8,但为了保险起见,可以显式设置:
javascript复制req.setEncoding('utf8');
调用setEncoding之后,data事件拿到的chunk就直接是字符串了,省去手动转码的麻烦。后面我会在讲客户端请求的时候再次强调它,因为服务器去请求第三方接口时同样会遇到这个坑。
3. 响应请求:res对象的写入节奏与报文生成规则
3.1 响应三大件:状态码、响应头、响应体
到了res这一侧,事情瞬间变得比req那边"主动"了。res是ServerResponse实例,它继承了stream.Writable,也就是说你往它上面写东西,它帮你把数据包成HTTP报文发出去。一个标准的HTTP响应由三部分构成:状态码、响应头、响应体。
先说状态码。HTTP规范定义了五大类状态码,我只挑实际开发中最常碰到的:
| 状态码 | 含义 | 典型使用场景 |
|---|---|---|
| 200 | OK | 正常返回数据 |
| 201 | Created | 资源创建成功,常在POST接口中返回 |
| 204 | No Content | 请求成功但没有返回体,常用于DELETE接口 |
| 301 | Moved Permanently | 永久重定向,域名迁移等 |
| 302 | Found | 临时重定向 |
| 304 | Not Modified | 走缓存,资源未变更 |
| 400 | Bad Request | 客户端参数错误 |
| 401 | Unauthorized | 未登录或Token失效 |
| 403 | Forbidden | 已登录但没有权限 |
| 404 | Not Found | 资源不存在 |
| 500 | Internal Server Error | 服务器内部错误 |
| 502 | Bad Gateway | 反向代理后,上游无响应 |
| 503 | Service Unavailable | 服务过载或维护中 |
状态码的选择是有讲究的。很多人接口一报错就返回500,这是偷懒的做法。一个合格的后端接口应该尽量细分:参数不对返400,没权限返403,未认证返401。虽然前端可以不管状态码直接取response.body里的业务错误码,但规范的HTTP状态码对网关日志监控、错误告警、CDN缓存策略都有直接影响。
接下来是响应头。在Node.js的设置方式有两种等价写法:
javascript复制// 写法一:writeHead 一次性写入状态码和响应头
res.writeHead(200, {
'Content-Type': 'application/json',
'X-Powered-By': 'Node.js'
});
// 写法二:先设状态码和响应头,最后统一发送
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.setHeader('X-Powered-By', 'Node.js');
这里有一个最容易踩的坑:writeHead一旦调用成功,响应头就不可再改了,再调用setHeader不会生效但也不会报错。反过来,如果你先调用了setHeader,再调用writeHead,writeHead里传入的headers对象会跟之前的header合并,相同字段名以writeHead里传入的为准。顺序逻辑理清楚之后,我一般习惯用statusCode + setHeader的方式,因为代码可读性更高,也不容易因为后续插入逻辑而破坏头部写入的时机。
还有一点值得专门提醒:Node.js的setHeader对header名不区分大小写,内部做了归一化处理,所以你可以写content-type,也可以写Content-Type,最后发出去的报文里都会规范成Content-Type。
3.2 Content-Length与Transfer-Encoding:为什么响应会"分块"
写响应体的时候有几个致命细节,很多人在线上出事故才回头看文档。
第一个细节是Content-Length。这个响应头告诉客户端"响应体总共有多少字节"。如果你没有手动设置它,Node.js在第一次调用res.write或res.end时还会观察后续数据多少,然后决定要不要自动加上。具体规则是这样的:
- 如果你在接口里调用了一次
res.end(data),把完整数据一次性传进去,Node.js会自动计算这段数据的字节长度,然后设置Content-Length。 - 如果你先调用了多次
res.write(chunk1)、res.write(chunk2),最后才res.end(),说明数据是分段写入的,Node.js没法在写第一段时就知道总长度,它会放弃设置Content-Length,改用Transfer-Encoding: chunked——也就是分块传输编码。
chunked不是错误,它是HTTP/1.1里非常标准的传输方式。服务器按块把数据发给客户端,每一块前面标注这一块的长度,最后用长度0的块结束。它的好处是服务器不用预先知道整个响应体有多大,适合动态生成内容、大文件流式输出等场景。坏处是,某些老旧的HTTP客户端对chunked响应支持不完善,如果你写的是一个被老设备访问的接口,最好一开始就用res.end(JSON.stringify(data))的方式让Node.js自动生成Content-Length。
第二个细节跟第一个相关,也是新手最懵的点:为什么我都调用了res.end(),浏览器还是像卡住了一样一直转圈? 答案往往是你没有把数据写完就结束了。比如:
javascript复制res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.write('Hello');
// 忘记调用 res.end()
res.write只是把数据写入了响应流,并没有告诉"响应发送完毕"这个消息。只要不调用res.end(),TCP连接就不会关闭,响应就不会真正结束。浏览器会一直等待后续数据,直到超时。反过来,如果代码报错导致res.end()这行没走到,也会出现请求挂死。
所以我的习惯是:只要不需要流式输出,一律用return res.end(data)一句话结束处理,避免出现逻辑分支漏掉结束响应的情况。
第三个细节是关于charset的。上面提到很多次'Content-Type': 'text/plain; charset=utf-8'。如果不写charset=utf-8,HTTP规范里text/plain的默认字符集是ISO-8859-1,浏览器拿到中文内容后很可能显示乱码。JSON类型application/json在大多数现代浏览器里默认按UTF-8处理,但保险起见,我仍然习惯写全:
javascript复制res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3.3 一个经典案例:手动写JSON API时的响应头细节
把上面这些串起来,一个最标准的JSON接口写法长这样:
javascript复制const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/api/user' && req.method === 'GET') {
const data = { name: '张三', age: 25 };
const body = JSON.stringify(data);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body)
});
res.end(body);
return;
}
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ message: '接口不存在' }));
});
server.listen(3000);
注意我用的是Buffer.byteLength(body)而不是body.length。这是个非常隐蔽的坑:body.length统计的是字符串的字符数,而中文一个字符在UTF-8编码下可能占3个字节。如果Content-Length算出来的长度比实际字节数小,客户端可能会截断响应内容;如果算大了,客户端会一直等到超时,表现跟"响应没结束"一样。Buffer.byteLength(body)才是按UTF-8编码计算出的真实字节数。字符串拼接算长度这种事,新手防不胜防,但写过一次就记住了。
实际上你完全可以不手动设置Content-Length,因为一次性res.end(body)时Node.js会自动计算并设置。我手动设置只是为了让这个知识点在这里被显式地看见。生产代码里我会去掉手动设置,让框架自己处理,减少出错点。
4. 客户端请求:用http模块去调用别人的接口
4.1 从http.request说到http.get:发一份请求并接收响应
很多人以为Node.js的HTTP模块只能当服务器用,这是误解。它同时是一个HTTP客户端,内置了发起请求的能力。Node.js里的http.request方法和大家熟悉的axios、fetch做的事情本质是一样的,只是API风格更底层,没有任何语法糖。
我们来发一个最简单的GET请求:
javascript复制const http = require('http');
const req = http.request(
{
hostname: 'www.example.com',
port: 80,
path: '/api/users?page=1',
method: 'GET',
headers: {
Accept: 'application/json'
}
},
(res) => {
console.log('状态码:', res.statusCode);
res.setEncoding('utf8');
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
console.log('响应数据:', data);
});
}
);
req.on('error', (err) => {
console.error('请求出错:', err.message);
});
req.end();
逐个字段拆开解释。hostname是目标域名,port是目标端口,HTTP默认端口是80,所以这个端口在访问标准网站时可以省略,但最好写清楚。path是请求路径,包含了URL里的问号参数部分;如果你传的是一个不带参数的路径如/api/user,而把参数放到别的地方,那就错了。method指定HTTP方法,这个参数不传时默认是GET,但我习惯写明。
请求头的设置跟服务器端一样,字段名不区分大小写。这里设置了Accept: application/json,只是告诉服务端我们希望返回JSON格式,并不代表服务端一定会给JSON。
回调函数里的res是一个IncomingMessage,跟你在服务端代码里收到的req是同一个类型,所以它也是一个可读流。你需要监听它的data事件把所有分片拼起来,等end事件触发时,整个响应体才算接收完毕。如果你处理的是JSON接口,还需要手动执行JSON.parse(data)。
看到这里你会发现:Node.js作为HTTP客户端的API风格,跟作为服务器接收请求时的处理模式是高度对称的。服务端用req读请求数据,客户端用res读响应数据;服务端用res写响应,客户端用req写请求体。两个角色共享同一套流式抽象,一旦你理解了一侧,另一侧几乎不费劲。
http.get是http.request的便捷封装。它自动设置方法为GET,并且内部已经帮你调用了req.end()。上面那个请求如果只要GET,可以改写成:
javascript复制http.get('http://www.example.com/api/users?page=1', (res) => {
res.setEncoding('utf8');
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
console.log(data);
});
}).on('error', (err) => {
console.error(err);
});
唯一要注意的坑是:http.request不会自动调用req.end(),如果你忘记调用,请求永远不会真正发出去,代码也不会报错。http.get帮你做了这一步,所以如果只是GET请求,用http.get更省心。
4.2 发送POST请求:写请求体的正确姿势
接下来是实际开发中最常见的场景:往POST接口提交数据。跟GET不同,POST请求通常需要携带请求体,而且要在请求头里明确Content-Type。
提交JSON格式的请求体:
javascript复制const http = require('http');
const postData = JSON.stringify({
username: 'admin',
password: '123456'
});
const req = http.request(
{
hostname: 'api.example.com',
port: 80,
path: '/api/login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
},
(res) => {
res.setEncoding('utf8');
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
console.log('登录接口返回:', data);
});
}
);
req.on('error', (err) => {
console.error('请求失败:', err.message);
});
req.write(postData);
req.end();
这里我再次用了Buffer.byteLength(postData)来计算Content-Length,原因跟服务端响应时一样:防止中文字符长度估算错误。如果你不设置Content-Length,Node.js会自动采用Transfer-Encoding: chunked来发送请求体,大多数服务器能正常解析,但有些严格校验的网关或后端框架可能会因此拒绝请求。所以POST请求我都建议带上准确的Content-Length。
提交表单格式(application/x-www-form-urlencoded)时,请求体需要手动做URL编码。Node.js内置的querystring模块可以帮你完成这件事:
javascript复制const querystring = require('querystring');
const postData = querystring.stringify({
name: '张三',
age: 25
});
// postData 输出: name=%E5%BC%A0%E4%B8%89&age=25
const req = http.request({
hostname: 'api.example.com',
port: 80,
path: '/api/user',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
// 后面写法相同
});
注意querystring.stringify会自动把中文和其他特殊字符做百分号编码,这是表单格式的硬性要求。编码后的字符串长度同样要按字节算。
4.3 处理超时、DNS解析失败和连接重置三类高频错误
用Node.js裸写HTTP客户端,新手最容易遇到的就是各种各样的请求错误。我把最常见的几类问题列出来,同时给出排查方向。
第一类:超时。HTTP请求发出去之后,如果服务器一直没有响应,Node.js默认是不会主动中断这个请求的,连接会一直挂着。你需要给请求设置超时时间。req.setTimeout(ms)设置的其实是socket空闲超时:如果在这个时间段内没有任何数据活动(包括正在下载响应数据但中途卡住的情况)就触发超时事件。
javascript复制const req = http.request(options, (res) => {
// ...
});
req.setTimeout(5000, () => {
console.error('请求超时');
req.destroy(new Error('Timeout'));
});
超时事件的回调并不会自动销毁请求,你得手动调用req.destroy()来终止连接,否则只是一个超时通知,连接可能还开着。更现代的写法是使用AbortController,这是Web标准API,Node.js从v15开始支持,推荐用在高版本Node环境:
javascript复制const { AbortController } = globalThis;
const controller = new AbortController();
const timeoutTimer = setTimeout(() => controller.abort(), 5000);
const req = http.request({ ...options, signal: controller.signal }, (res) => {
// ...
});
req.on('error', (err) => {
if (err.name === 'AbortError') {
console.error('请求超时,已终止');
} else {
console.error('其他错误:', err.message);
}
});
第二类:DNS解析失败。当你传入的hostname是一个不能被解析的域名时,事件循环里会抛出ENOTFOUND错误。这不是HTTP模块的错,是操作系统DNS解析环节失败。排查办法是先在终端里nslookup或ping一下这个域名,确认能不能解析。
第三类:连接被重置。错误信息通常是ECONNRESET或socket hang up。意思是TCP连接在对端被强行关闭,可能是服务器进程崩溃、服务器主动断开空闲连接、或者中间防火墙拦截。这里面有个很常见的场景:目标服务器开启了keep-alive(连接保持),但空闲一段时间后主动关闭了连接,而你复用的是旧socket,就会触发socket hang up。解决办法是客户端在发起新请求前不要复用已经关闭的连接,并且监听socket的close事件及时清理。
5. 把HTTP模块拼成一个可用的服务:路由、静态文件与请求体解析
5.1 手工路由:解析req.url和req.method
当你理解了请求和响应两侧之后,就该把这些能力拼装成一个"能干活"的服务了。最直接的问题是:真实浏览器访问的不只是一个路径,可能是/、/about、/api/user?id=1、/static/app.css……你怎么让服务器对不同路径返回不同内容?
答案其实很粗暴:手动从req.url里解析路径,然后跟预期值比对,这就是路由的本质。在Express这类框架里,你写的app.get('/user', handler)底层做的也是这事,只是它帮你把路径匹配算法和分发逻辑封装好了。
先看一个最简单的手工路由:
javascript复制const http = require('http');
const url = require('url');
const server = http.createServer((req, res) => {
// 用 URL 解析出纯路径和 query 对象
const parsedUrl = new URL(req.url, 'http://localhost:3000');
const pathname = parsedUrl.pathname;
const query = parsedUrl.searchParams;
if (req.method === 'GET' && pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>首页</h1>');
return;
}
if (req.method === 'GET' && pathname === '/api/user') {
const id = query.get('id');
const user = { id, name: '用户' + id };
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(user));
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('404 Not Found');
});
server.listen(3000, () => {
console.log('服务已启动,访问 http://localhost:3000');
});
这里我用了Node.js内置的URL类来解析请求地址。new URL(req.url, 'http://localhost:3000')的意思非常关键:req.url通常是/api/user?id=1这种相对路径,而URL类的构造函数要求传入一个绝对地址,所以我在前面拼了一个假的基础地址,让解析器能识别出路径和参数。parsedUrl.pathname取出的是没有参数的纯路径/api/user;parsedUrl.searchParams是一个URLSearchParams实例,用.get('id')方法可以得到整数查询参数。
按req.method和pathname双重匹配的好处是,可以轻松区分GET /api/user和POST /api/user这两种语义完全不同的请求。真实项目的路由匹配一般不会全部用if堆,要么用switch,要么维护一张路由表,再高级点就是引入路由库。但在裸HTTP模块阶段,你至少得先把这套逻辑跑通,后面再上框架你会特别清楚框架里路由层帮你挡掉了哪些样板代码。
5.2 解析POST请求体:从Buffer拼接到JSON格式化
第四节讲客户端POST时以发请求为主,这一节补上服务器接收POST请求体的完整处理办法。先搭一个能解析JSON格式请求体的助手函数:
javascript复制const http = require('http');
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
req.on('end', () => {
const buffer = Buffer.concat(chunks);
try {
const parsed = JSON.parse(buffer.toString('utf8'));
resolve(parsed);
} catch (err) {
reject(err);
}
});
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/api/user') {
try {
const body = await readBody(req);
console.log('接收到用户数据:', body);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ code: 0, data: body }));
} catch (err) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ code: 400, message: '请求体不是合法JSON' }));
}
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
});
server.listen(3000);
注意我在这里吸收请求体数据时,用的是chunks.push(Buffer.from(chunk)),最后用Buffer.concat(chunks)拼出完整Buffer,再统一toString('utf8')。为什么不直接用字符串拼接?因为如果请求体特别大而且包含多字节字符,字符串中间状态的反复拼接会导致不必要的内存拷贝和潜在的中文截断风险。把每个分片都保持在Buffer层面,最后一次性转字符串,是更稳妥的做法。当然,普通小请求体直接拼字符串也没事,这个区别更多是习惯问题。
JSON解析失败时,它抛出的异常会被catch捕获,最后返回400状态码。这里也顺便展示了一个比裸写HTTP响应更接近生产环境的做法:业务接口统一返回{ code: 0, data: ... }这类结构,无论HTTP层状态码是什么,前端先看业务code,更便于做全局错误提示。当然,HTTP状态码仍然要配合着设置,这样网关监控和日志告警才能正常工作。
5.3 托管静态文件:注意路径穿越和MIME类型
一个Web应用除了API,还有一堆CSS、JavaScript、图片等静态资源需要直接返回给浏览器。用HTTP模块手工实现静态文件服务很能锻炼对文件的控制感,但必须谨慎处理安全问题。
javascript复制const http = require('http');
const fs = require('fs');
const path = require('path');
const rootDir = path.join(__dirname, 'public');
const mimeMap = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml'
};
const server = http.createServer((req, res) => {
const parsedUrl = new URL(req.url, 'http://localhost:3000');
let pathname = decodeURIComponent(parsedUrl.pathname);
if (pathname === '/') pathname = '/index.html';
// 将 URL 路径映射到文件系统路径,并防止路径穿越
const filePath = path.join(rootDir, pathname);
// 关键安全校验:最终路径必须在 rootDir 内
if (!filePath.startsWith(rootDir)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('403 Forbidden');
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('404 Not Found');
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = mimeMap[ext] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
server.listen(3000);
这里的核心安全点是路径穿越防御。假设用户访问的是/../../etc/passwd,经过URL解析和path.join之后得到的文件路径可能在服务器根目录之外,然后fs.readFile就会把系统敏感文件读出来返回给浏览器——这是非常严重的安全漏洞,历史上的很多静态服务器漏洞都出在这里。所以正确顺序是:先用path.join把URL路径与根目录拼起来,再校验拼接后的最终路径是否以根目录开头。
另外一个容易被忽略的细节是decodeURIComponent(parsedUrl.pathname)。浏览器和编码后的URL里可能包含%20(空格)或中文的百分号编码,不解码直接拼接的话,找不到真正的文件。但解码也可能让路径里混入恶意字符,所以解码动作必须在路径穿越校验之前完成——上面代码的顺序是对的。
mimeMap映射也很重要。如果你不管文件类型统统返回application/octet-stream,浏览器会直接下载而不是渲染CSS和JavaScript。常见的MIME类型表我列在上面了,需要时可以自己扩展。
5.4 连外网接口时的合理姿势:设置请求头与User-Agent
最后一个实用场景是Node.js服务作为"中间人",去调用第三方HTTP接口。很多第三方开放平台会校验请求的User-Agent、Referer、Origin等请求头,裸的http.request发出的请求默认User-Agent是node,很可能被对方拒绝。这是我在对接一些开放平台接口时踩过的坑:本地curl测试接口明明能通,代码一调就被403,最后抓包一看,第三方网关把来自node的请求标记为可疑客户端拦截了。
解决办法是显式伪造一个浏览器风格的User-Agent:
javascript复制const options = {
hostname: 'api.thirdparty.com',
port: 443,
path: '/v1/notify',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Authorization': 'Bearer your-token'
}
};
顺带说一句,这个例子里的端口是443,说明对方是HTTPS协议的服务。此时你需要用https模块替代http模块。两个模块的API几乎完全一致,把require('http')改成require('https'),其他代码基本不用动。
请求第三方接口还有个容易被忽略的问题:如果响应数据特别大,比如第三方返回一个几十MB的文件,而你只是想要JSON,流式接收时必须设置合理的res.setEncoding('utf8')和分片处理,避免把所有内容一次性压进内存。更合理的做法是,只接收一定大小的数据,超过预期就主动req.destroy(),防止内存被恶意大响应打爆:
javascript复制let totalBytes = 0;
const maxBytes = 1024 * 1024; // 最多接收 1MB
res.on('data', (chunk) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
req.destroy(new Error('响应体超过 1MB,已终止'));
}
});
这种防御性编码在连接不可信的第三方服务时很有价值,很多线上事故不是发生在业务逻辑上,而是发生在"收到了一个比你预期大得多的响应"这种看似不起眼的边界上。
如果你在服务端频繁调用同一个第三方接口,还要注意连接管理。每次新建http.request默认会走globalAgent的连接池,同一域名下的请求会复用TCP连接。从Node.js 19版本开始,http.globalAgent才默认开启keepAlive。如果你用的是更早的Node版本,高频请求时会不断创建新连接,性能上会有明显损耗。这时可以手动创建一个带keepAlive的agent并传入请求配置:
javascript复制const http = require('http');
const keepAliveAgent = new http.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000
});
const options = {
hostname: 'api.thirdparty.com',
port: 80,
path: '/api/data',
method: 'GET',
agent: keepAliveAgent
};
keepAlive: true表示请求结束后TCP连接不关闭,留给下一次请求复用。maxSockets限制同一个host下最多同时打开的socket数,能有效防止对单台服务器并发压力过大。timeout是socket空闲多久后关闭。这几个参数在压测高并发调用时都要结合实际情况调,不是越大越好——连接池开太大,可能把对端打挂。
我自己在接手一个调用第三方接口的Node服务时,第一步不是看业务代码,而是先看有没有显式创建agent。没有的话,我会先确认Node版本,再决定要不要补上agent配置。运行在Node 18及以下版本时,默认keepAlive: false,意味着每个请求都会经历完整的TCP三次握手和四次挥手,如果每秒调用量上了百级别,这个开销很可观;Node 19及以上则默认开启keepAlive,情况好很多。这个版本差异属于那种不看文档绝对不知道、看了文档才会心头一紧的知识点。
一路写下来,HTTP模块的这套骨架应该已经很清晰了:请求进来,Node解析报文封装成req对象;你的回调函数把业务处理结果写给res对象,Node再把它们打包成响应报文发出去;如果你想当客户端,就自己构造请求报文发出去,然后同样用流的方式接收响应。我自己带项目时有个习惯,要求组里的新人不管用什么框架,第一周先拿HTTP模块手写一个不带框架的REST接口服务,能把JSON、表单、静态文件这三种最基础的场景跑通,后面再学Express的中间件模型会顺畅得多。框架解决的是代码组织问题,而HTTP模块解决的是网络通信的本质问题,底层的东西吃透了,上面盖再高的楼都不慌。
