TCP编程在Node.js中的位置
HTTP协议构建在TCP之上,WebSocket构建在TCP之上,Redis客户端、MySQL连接池、消息队列的底层通信同样构建在TCP之上。当应用需要自定义协议、需要长连接、需要比HTTP更低的传输开销时,直接操作TCP是绕不开的一步。
Node.js的net模块把这层能力封装成了事件驱动的API。它不关心你传输的是JSON、二进制帧还是纯文本,它只负责建立连接、搬运字节、在连接状态变化时通知你。这种低层抽象意味着更大的自由度,也意味着更多需要自己处理的细节——粘包、心跳、超时、错误恢复,这些在HTTP框架中由库代劳的事情,在net模块中需要显式设计。
代码号学习编程的过程中,net模块常常被跳过,因为日常业务用HTTP就够了。但理解net模块的工作方式,对排查HTTP连接问题、理解Node.js的流与事件模型都有直接帮助。下面从模块导入开始,逐步展开。
导入net模块
net是Node.js核心模块,无需安装。
// 代码号学习编程:导入 Net 模块
const net = require('node:net');
require('net')和require('node:net')功能等价,node:前缀是官方推荐的写法,明确标识内置模块,避免与node_modules中的同名包混淆。新项目建议统一使用该前缀。
导入后即可访问createServer、createConnection、isIP、isIPv4、isIPv6等方法,以及Server、Socket两个核心类。Server代表监听端,Socket代表一条具体的连接。理解这两者的分工,是掌握net模块的关键。
创建TCP服务端:net.createServer()
net.createServer()返回一个Server实例。传入的回调函数会在每次客户端连接建立时执行,参数是一个Socket对象,代表这条具体的连接。
// 代码号学习编程:创建 TCP 服务端
const net = require('node:net');
const server = net.createServer((socket) => {
console.log('Client connected');
socket.write('Welcome to the TCP Server!\n');
socket.on('end', () => {
console.log('Client disconnected');
});
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
服务端控制台先输出Serverrunningonport3000,客户端接入后输出Clientconnected,客户端断开后输出Clientdisconnected。
需要留意的是,socket.write()发送的WelcometotheTCPServer!是写给客户端的,服务端控制台不会显示这条内容。这是初学者容易困惑的一点:write是往对端发送数据,console.log才是往本地终端输出。
server.listen()的参数可以是端口号、端口加主机名、或者一个包含port、host、backlog等字段的对象。backlog指定等待队列的长度,当并发连接数超过服务端处理能力时,多余连接会进入这个队列。在高并发场景下适当调大backlog有助于减少连接被拒绝的情况。
创建TCP客户端:net.createConnection()
net.createConnection()创建一条到服务端的连接。连接建立后触发回调,之后可以收发数据。
// 代码号学习编程:创建 TCP 客户端
const net = require('node:net');
const client = net.createConnection(
{ port: 3000 },
() => {
console.log('Connected to server');
}
);
client.on('data', (data) => {
console.log(data.toString());
client.end();
});
输出:
Connected to server
Welcome to the TCP Server!
data事件在服务端发来数据时触发。data参数是Buffer对象,调用toString()转为字符串。client.end()发送FIN包,优雅关闭连接。
这里有一个实际项目中的注意点:运行客户端之前,服务端必须已经在监听。如果服务端未启动,客户端会触发error事件,错误码为ECONNREFUSED。如果不监听error事件,Node.js会抛出未捕获异常导致进程退出。这一点在编写客户端代码时必须处理。
双向通信:发送与接收数据
TCP是全双工协议,客户端和服务端可以同时向对方发送数据。下面的例子演示一次完整的请求-响应交互。
服务端
// 代码号学习编程:服务端接收并响应
const net = require('node:net');
const server = net.createServer((socket) => {
socket.on('data', (data) => {
console.log('Client:', data.toString());
socket.write('Message received');
});
});
server.listen(3000);
客户端
// 代码号学习编程:客户端发送并接收响应
const net = require('node:net');
const client = net.createConnection({ port: 3000 }, () => {
client.write('Hello Server');
});
client.on('data', (data) => {
console.log('Server:', data.toString());
client.end();
});
服务端控制台输出Client:HelloServer,客户端控制台输出Server:Messagereceived。
客户端通过client.write()发送数据,服务端在data事件中收到并打印,随后用socket.write()回传响应。客户端收到响应后关闭连接。
个人经验:在项目中,很少直接传输裸字符串。通常会在数据前后加长度前缀或分隔符来标识消息边界,否则会遇到粘包问题。TCP是字节流协议,不保证发送方的一次write对应接收方的一次data事件。发送方连续写入三条消息,接收方可能一次data事件收到全部三条,也可能分三次收到。这是设计自定义协议时必须优先考虑的问题。
核心方法与事件速查
常用方法
| 方法 | 作用 |
|---|---|
net.createServer() |
创建TCP服务端 |
net.createConnection() |
创建TCP客户端连接 |
server.listen() |
启动监听,等待连接 |
server.close() |
停止接受新连接,已有连接继续 |
socket.write() |
向对端发送数据 |
socket.end() |
优雅关闭连接,发送FIN |
socket.destroy() |
立即销毁连接,不等待缓冲数据发送 |
server.getConnections() |
获取当前活跃连接数 |
net.isIP() |
判断字符串是否为IP地址 |
net.isIPv4() |
判断是否为IPv4地址 |
net.isIPv6() |
判断是否为IPv6地址 |
常用事件
| 事件 | 触发时机 |
|---|---|
connection |
客户端连接到服务端 |
data |
收到对端数据 |
end |
对端发送FIN,连接进入半关闭状态 |
close |
连接关闭 |
error |
发生错误 |
timeout |
连接空闲超时 |
drain |
写缓冲区排空,可以继续写入 |
listening |
服务端开始监听 |
end和close的区别值得注意:end表示对端不再发送数据,但本端仍可写入;close表示连接彻底关闭,无法再收发数据。在处理半关闭场景时,这两个事件的顺序和时机直接影响逻辑正确性。
HTTP模块与Net模块的差异
| 对比维度 | HTTP模块 | Net模块 |
|---|---|---|
| 协议层 | 应用层HTTP协议 | 传输层TCP协议 |
| 抽象层级 | 较高,封装请求响应 | 较低,直接操作字节流 |
| 数据格式 | 解析HTTP头与体 | 原始Buffer |
| 连接管理 | 自动处理keep-alive | 需手动管理 |
| 典型场景 | Web应用与API | 自定义协议、长连接、实时系统 |
HTTP模块内部也是基于net模块实现的。当你调用http.createServer()时,底层仍然是一个TCP服务端在监听端口,HTTP模块负责解析收到的字节流,将其转换为req和res对象。理解这一层关系,有助于在HTTP出现异常时向下排查到TCP层面。
个人建议:除非确实需要自定义协议或极致性能,否则优先使用HTTP模块或更上层的框架。net模块带来的灵活度伴随着大量需要自行处理的边界情况,包括但不限于粘包、心跳保活、连接池管理、背压控制。这些在HTTP生态中已有成熟方案。
项目实例:Echo服务器
Echo服务器是最能体现net模块工作方式的例子:收到什么就回传什么。
// 代码号学习编程:Echo 服务器
const net = require('node:net');
const server = net.createServer((socket) => {
socket.on('data', (data) => {
console.log(data.toString());
socket.write(`Echo: ${data.toString()}`);
});
});
server.listen(3000, () => {
console.log('Echo server running on port 3000');
});
服务端启动后,客户端发送Hello,服务端控制台输出Hello,客户端收到Echo:Hello。
这个例子的价值在于演示了data事件的触发和write的回传。在此基础上可以扩展的方向包括:给每条消息加长度前缀解决粘包、增加心跳机制检测死连接、设置socket.setTimeout()处理空闲连接、用socket.pipe()将数据流转发到其他流。
错误处理与常见
TCP编程中的错误大多来自连接状态变化,而非语法问题。以下是几个实际项目中高频出现的场景。
ECONNREFUSED:客户端连接时服务端未监听。必须在客户端监听error事件,否则进程退出。
ECONNRESET:对端强制关闭连接,本端仍在读写。常见于对端进程崩溃或网络中断。
EPIPE:向已关闭的连接写入数据。需要在write前检查socket.writable,或监听error事件统一处理。
粘包:TCP是字节流,不保留消息边界。解决办法有两种:定长消息、或长度前缀加消息体。长度前缀更通用,实现方式是先读取固定字节数的长度字段,再按该长度读取消息体。
背压:当写入速度超过对端读取速度时,socket.write()返回false,表示缓冲区已满。此时应暂停写入,等待drain事件后再继续。忽略背压会导致内存持续增长。
// 代码号学习编程:处理背压
function sendData(socket, data) {
const canContinue = socket.write(data);
if (!canContinue) {
socket.once('drain', () => {
// 缓冲区已排空,可以继续写入
});
}
}
在代码号学习编程的实践中,背压是最容易被忽略的问题。小数据量测试时不会暴露,一旦生产环境流量上来,内存占用会快速攀升。
现在框架中的惯用写法
在Express、Koa这类HTTP框架中,net模块通常不会直接出现,但底层的连接管理仍然依赖它。当需要实现WebSocket服务时,ws库内部使用net或http的upgrade机制建立连接。在实现自定义RPC协议时,常见做法是在net之上封装一层编解码器,负责消息的序列化、分帧和重组。
Node.js的stream模块与net模块配合紧密。socket本身就是一个双工流,可以调用pipe()将数据导向其他流,也可以被其他流pipe进来。在实现在理或隧道时,clientSocket.pipe(serverSocket)和反向pipe是常见写法。
本节课程知识要点
-
net是Node.js内置模块,无需安装,推荐使用node:net前缀导入。 -
net.createServer()创建TCP服务端,回调参数是代表单条连接的Socket。 -
net.createConnection()创建TCP客户端,连接建立后触发回调。 -
socket.write()发送数据,socket.end()优雅关闭,socket.destroy()立即销毁。 -
核心事件:
connection、data、end、close、error、timeout、drain。 -
end是半关闭,close是关闭,两者时机不同。 -
TCP是字节流协议,不保留消息边界,需要自行处理粘包。
-
客户端必须监听
error事件,否则ECONNREFUSED等错误会导致进程退出。 -
socket.write()返回false表示缓冲区已满,需等待drain事件后再写入。 -
HTTP模块构建在net模块之上,net提供更底层的控制能力。
练习与思考
-
编写一个TCP服务端,统计当前活跃连接数,并在每次连接建立和断开时打印。使用
server.getConnections()验证统计结果。 -
实现一个简单的长度前缀协议:发送方先写入4字节的消息长度,再写入消息体;接收方先读4字节确定长度,再读取对应字节数的消息体。思考如何处理长度字段跨多个
data事件到达的情况。 -
编写一个TCP客户端,连接失败时输出明确的错误信息,连接成功后每5秒发送一次心跳,服务端超过15秒未收到心跳则关闭连接。
-
思考:为什么
socket.write()返回false时不能继续无限制写入?如果忽略背压,内存增长发生在哪个环节? -
使用
socket.pipe()实现一个简单的TCP代理,将3000端口的流量转发到4000端口。