人人都会AI编程

9.2 TCP 与 UDP 编程

更新时间:2026-07-11

HTTP 协议在 Web 开发中无处不在,但它本质上是建立在 TCP 之上的应用层协议。大多数情况下,我们直接使用 HTTP 模块就能满足业务需求。但面对自定义协议、长连接通信、二进制数据传输、物联网设备对接等场景时,理解底层 TCP 和 UDP 编程就显得非常必要。Node.js 的内置模块 netdgram 分别提供了创建 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 只保证字节流的顺序和完整性,不保证应用层消息的分界。

在项目中,我们需要设计一个应用层协议来确定消息边界。常用的方法有:

  1. 定长消息:每条消息固定长度,不足部分用填充字节补全。这种方式简单但浪费空间,不够灵活。
  2. 分隔符:使用特殊字符(如换行符 \n\r\n 或自定义结束符)标记一条消息的结束。适合文本协议,例如 Redis 的协议就是使用 \r\n 分隔。
  3. 消息头 + 消息体:在消息体前加上一个固定长度的头部,头部中包含后续消息体的长度信息。这是最通用的方案,广泛应用于 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 的发送速度过快可能导致本地缓冲区溢出,可通过返回的错误控制发送频率。

掌握了 netdgram 模块,你就拥有直接操控传输层的能力。这不仅能让你更深刻地理解上层协议,也能帮助你在面对特殊通信需求时,从 Node.js 的工具箱里拿出合适的武器,而不是被 HTTP 所束缚。