HTTP 协议在 Web 开发中无处不在,但它本质上是建立在 TCP 之上的应用层协议。大多数情况下,我们直接使用 HTTP 模块就能满足业务需求。但面对自定义协议、长连接通信、二进制数据传输、物联网设备对接等场景时,理解底层 TCP 和 UDP 编程就显得非常必要。Node.js 的内置模块 net 和 dgram 分别提供了创建 TCP 服务器/客户端和 UDP 套接字的能力,让我们可以穿透 HTTP 的包装,直接操控传输层的数据流。
9.2.1 net 模块:构建 TCP 服务器与客户端
TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的协议。它在发送数据之前需要先建立连接(三次握手),发送过程中保证数据按序到达且不丢失,接收端需要根据字节流的特征自行“分包”或“组包”。net 模块封装了操作系统的 TCP 套接字,提供了创建服务器和客户端的方法,并支持流式数据读写。
创建 TCP 服务器
一个最简单的 TCP 服务器监听某个端口,当有客户端连接时,会触发 connection 事件,并返回一个 net.Socket 对象(双向流(Duplex Stream))。通过这个 Socket 对象,我们可以读取客户端发来的数据,也可以向客户端写入数据。
const net = require('net');
const server = net.createServer((socket) => {
// socket 是 net.Socket 实例,继承自 stream.Duplex
console.log('客户端已连接', socket.remoteAddress, socket.remotePort);
// 监听数据事件
socket.on('data', (data) => {
console.log('收到数据:', data.toString());
// 回复信息
socket.write(`服务端已收到: ${data.toString()}`);
});
// 连接关闭
socket.on('close', (hadError) => {
console.log('客户端断开连接', hadError);
});
// 错误处理
socket.on('error', (err) => {
console.error('Socket 错误:', err.message);
});
});
server.listen(4000, () => {
console.log('TCP 服务器监听端口 4000');
});
createServer 的回调实际上是对 connection 事件的快捷写法。也可以这样写:
server.on('connection', (socket) => { ... });
服务器还可以设置最大连接数、半关闭行为等:
server.maxConnections = 10; // 最多同时 10 个连接,超出会自动关闭
当使用 telnet 或自定义 TCP 客户端连接后,发送的数据会被服务端接收并回显。
创建 TCP 客户端
使用 net.createConnection 可以创建指向某台服务器端口的 TCP 客户端。返回的也是一个 net.Socket 对象,既可以写入数据,也可以接收服务器发来的数据。
const net = require('net');
const client = net.createConnection({ port: 4000, host: '127.0.0.1' }, () => {
console.log('已连接到服务器');
// 发送数据
client.write('你好,服务端!');
});
// 接收响应
client.on('data', (data) => {
console.log('来自服务器:', data.toString());
// 收到响应后关闭连接(也可以不关)
client.end();
});
client.on('end', () => {
console.log('连接正常断开');
});
client.on('error', (err) => {
console.error('连接错误:', err.message);
});
需要注意的是,socket.write() 是异步的,数据会被缓冲到底层缓冲区,然后由系统调度发送。如果写入速度过快,可能会触发背压(backpressure),但 Socket 本身继承了 Stream 的 drain 事件,可以手动控制写入节奏。
9.2.2 TCP 数据传输的特点与“粘包”问题
TCP 是面向字节流的协议,它不保留应用层消息的边界。也就是说,客户端连续发送的多个数据包,在传输层可能会合并成一个大的数据块到达服务端(粘包),或者由于网络拥塞、MTU 限制等原因被拆分为多段。这一点是很多初学者容易踩的坑。
粘包案例:假设客户端快速发送 "Hello" 和 "World" 两个独立的 write() 调用:
client.write('Hello');
client.write('World');
服务端的 data 事件很有可能一次性收到 HelloWorld 一个 Buffer,而不是两次分别收到。也有可能先收到 Hel,下一次收到 loWorld。TCP 只保证字节流的顺序和完整性,不保证应用层消息的分界。
在项目中,我们需要设计一个应用层协议来确定消息边界。常用的方法有:
- 定长消息:每条消息固定长度,不足部分用填充字节补全。这种方式简单但浪费空间,不够灵活。
- 分隔符:使用特殊字符(如换行符
\n、\r\n或自定义结束符)标记一条消息的结束。适合文本协议,例如 Redis 的协议就是使用\r\n分隔。 - 消息头 + 消息体:在消息体前加上一个固定长度的头部,头部中包含后续消息体的长度信息。这是最通用的方案,广泛应用于 TCP 自定义协议、WebSocket 帧、gRPC 等。
使用分隔符处理粘包
Node.js 中可以利用内置的 readline 模块或自定义逻辑,将收到的数据按行分隔。
const net = require('net');
const server = net.createServer((socket) => {
let buffer = ''; // 暂存数据缓冲区
socket.on('data', (chunk) => {
buffer += chunk.toString();
let idx;
// 按 \n 切分完整消息
while ((idx = buffer.indexOf('\n')) !== -1) {
const msg = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
console.log('完整消息:', msg);
socket.write(`处理结果: ${msg.toUpperCase()}\n`);
}
});
});
server.listen(4000);
客户端发送 'hello\n',服务端解析出完整消息并返回 'HELLO\n'。如果客户端发送 'hel' 和 'lo\n' 两次 write,缓存机制能正确拼接并解析出完整消息。
使用长度前缀(消息头+消息体)处理粘包
这是一种更健壮的方案,适合传输二进制数据。例如,规定每条消息的前 4 个字节(一个 32 位整数)表示后续消息体的字节长度(大端序)。服务端需要实现一个缓冲区攒够一个完整长度头部,然后根据头部指示的长度读取数据。
const net = require('net');
const server = net.createServer((socket) => {
let buffer = Buffer.alloc(0);
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
// 循环处理,直到 buffer 不足以解析头部
while (buffer.length >= 4) {
const bodyLen = buffer.readUInt32BE(0); // 读取消息体长度
const totalLen = 4 + bodyLen;
if (buffer.length < totalLen) break; // 数据不够,等待更多数据
const body = buffer.slice(4, totalLen);
console.log('收到完整消息:', body.toString());
// 处理完,从缓冲区移除这条消息
buffer = buffer.slice(totalLen);
}
});
});
server.listen(4000, () => {
console.log('TCP 服务器(带长度前缀)启动');
});
客户端发送消息时也要遵守协议,先发送一个 4 字节长度头,再发送消息体:
const net = require('net');
function sendMessage(socket, message) {
const body = Buffer.from(message, 'utf-8');
const header = Buffer.alloc(4);
header.writeUInt32BE(body.length, 0);
socket.write(Buffer.concat([header, body]));
}
const client = net.createConnection({ port: 4000 }, () => {
sendMessage(client, 'Hello, 长度前缀协议');
sendMessage(client, '另一条消息');
});
这样无论 TCP 如何分包,接收端总能根据长度指示正确分割每条消息,从根本上解决粘包问题。
9.2.3 dgram 模块:UDP 数据报通信
UDP(用户数据报协议)是一种无连接的、不可靠的、基于数据报的传输协议。与 TCP 不同,UDP 无需建立连接,可以直接向目标地址发送独立的数据包(称为“数据报”),但数据报可能丢失、重复或乱序到达。由于没有连接维护和确认重传机制,UDP 的传输效率非常高,延迟低,适合实时音视频、广播、在线游戏、DNS 查询等对速度要求高且能容忍少量丢包的场景。
Node.js 的 dgram 模块提供了创建 UDP 套接字的能力,支持 IPv4 和 IPv6。
创建 UDP 服务器(监听并接收数据)
调用 dgram.createSocket 并指定类型('udp4' 或 'udp6'),然后绑定端口。服务器通过 message 事件接收数据。
const dgram = require('dgram');
const server = dgram.createSocket('udp4');
server.on('message', (msg, rinfo) => {
console.log(`来自 ${rinfo.address}:${rinfo.port} 的消息: ${msg.toString()}`);
// 回复一条消息给发送方
const response = Buffer.from(`已收到: ${msg.toString()}`);
server.send(response, rinfo.port, rinfo.address, (err) => {
if (err) console.error('回复失败', err);
});
});
server.on('listening', () => {
const address = server.address();
console.log(`UDP 服务器监听 ${address.address}:${address.port}`);
});
server.bind(41234);
注意,UDP 服务端和客户端的角色很模糊:任何绑定端口的套接字都可以收发数据,不必预先“连接”。
创建 UDP 客户端(发送数据)
UDP 客户端可以不绑定固定端口,直接创建套接字发送数据。
const dgram = require('dgram');
const client = dgram.createSocket('udp4');
const message = Buffer.from('Hello UDP 服务器');
client.send(message, 41234, 'localhost', (err) => {
if (err) console.error('发送失败', err);
else console.log('消息已发送');
});
// 监听来自服务器的响应
client.on('message', (msg, rinfo) => {
console.log(`收到响应: ${msg.toString()} 来自 ${rinfo.address}:${rinfo.port}`);
client.close(); // 可选
});
send() 方法支持字符串或 Buffer。如果发送的字符串包含非 ASCII 字符,最好先转换为 Buffer 并指定编码。
UDP 的关键特性与注意事项
- 消息边界保留:与 TCP 的字节流不同,UDP 每次
send()都会生成一个独立的数据报,接收方通过message事件一次性收到完整数据报,不会出现 TCP 那种粘包问题。但如果数据报超过网络 MTU(通常约 1500 字节),可能在 IP 层被分片,到达后重组,但对于应用仍然是完整的。 - 不可靠性:发送的数据报可能丢失,也可能因为网络原因重复到达。如果业务对可靠性有要求,需要在应用层实现确认重传、排序等机制。
- 无连接性:UDP 可以直接向任何地址发送数据,接收方也无法知道对方是否离开。如果需要点对点的双向通信,通常需要双方都绑定固定端口并维护地址映射。
- 广播与组播:UDP 支持向局域网内所有主机发送广播(
255.255.255.255),也支持加入组播组(multicast)实现一对多通信,适合局域网设备发现等场景。Node.js 中可以通过setBroadcast(true)和addMembership()实现。
// 发送广播消息
const client = dgram.createSocket('udp4');
client.bind(() => {
client.setBroadcast(true);
client.send('Hello 广播', 41234, '255.255.255.255', (err) => {
if (err) console.error(err);
client.close();
});
});
// 加入组播组接收数据
const server = dgram.createSocket('udp4');
server.bind(41234, () => {
server.addMembership('224.0.0.1'); // 组播地址
});
server.on('message', (msg, rinfo) => {
console.log(`收到组播消息: ${msg.toString()}`);
});
- 错误处理:UDP 套接字也可以监听
error事件,但多数错误(如地址不可达)在异步返回时可能不会触发异常,通常需要在send的回调中检查错误。
9.2.4 TCP 与 UDP 的适用场景对比
| 特性 | TCP | UDP |
| ------------ | -------------------------------------- | ------------------------------------- |
| 连接方式 | 面向连接,需要三次握手 | 无连接,直接发送 |
| 可靠性 | 保证数据按序到达,不丢失 | 不保证可靠,可能丢包、重复或乱序 |
| 数据边界 | 字节流,需应用层设计边界 | 保留数据报边界 |
| 传输效率 | 相对较低(有确认、重传、流控制) | 高效,低开销 |
| 适用场景 | Web、文件传输、数据库、远程登录 | 视频流、语音通话、DNS、在线游戏、广播 |
在实际项目中,大多数通用业务都建立在 TCP 上(HTTP、WebSocket 等),直接编写原始 TCP 程序的需求不多,但在开发高性能自定义通信、与嵌入式设备交互、底层网络工具时,net 模块就能大显身手。UDP 则更多出现在对实时性和效率要求极高,且允许一定数据损失的环境里。
9.2.5 实际应用中的关键点
资源清理:无论是 TCP 服务器还是 UDP 套接字,在程序退出前都应妥善关闭。可以监听进程退出信号,执行 server.close() 或 socket.close()。
安全考虑:自定义 TCP 协议暴露在公网时,必须做好认证、加密(可通过 TLS 升级为 TLS 套接字)、流量控制、拒绝服务防护等,不要假定客户端按预期发送数据。
性能优化:TCP 的 Nagle 算法可能会延迟小包发送,在低延迟场景下可通过 socket.setNoDelay(true) 禁用。UDP 的发送速度过快可能导致本地缓冲区溢出,可通过返回的错误控制发送频率。
掌握了 net 和 dgram 模块,你就拥有直接操控传输层的能力。这不仅能让你更深刻地理解上层协议,也能帮助你在面对特殊通信需求时,从 Node.js 的工具箱里拿出合适的武器,而不是被 HTTP 所束缚。