Node.js net 模块练习答案(1~13)

对应 net-exercises.md 的第 1~13 题。示例以 Node.js 22+、TypeScript 为准。TCP 是连续字节流data 的 chunk 既不是应用消息,也不是一行文本;write() 的一次调用也不承诺对应对端的一次 data

运行任一服务端示例后,再运行对应客户端。每个 net.Socket 都必须注册 error 监听器;否则错误会成为未处理的 EventEmitter 错误并使进程退出。

1. 最小 TCP 服务

import net from "node:net";

const server = net.createServer((socket) => {
  console.info("client connected", socket.remoteAddress, socket.remotePort);
  socket.write("WELCOME\\n");
  socket.on("error", (error) => console.warn("socket error", error.message));
});

server.on("error", (error) => console.error("server error", error));
server.listen(9000, "127.0.0.1", () => console.info("tcp://127.0.0.1:9000"));

servererror(如 EADDRINUSE)表示监听失败;它不是某个客户端连接错误。remoteAddress 可能为空,日志中应允许它缺失。

2. TCP 客户端

import net from "node:net";

const socket = net.createConnection({ host: "127.0.0.1", port: 9000 });
socket.on("connect", () => socket.end("你好,服务端\\n")); // 结束自己的写方向
socket.on("data", (chunk: Buffer) => console.log("received:", chunk.toString("utf8")));
socket.on("end", () => console.log("server ended its write side"));
socket.on("close", (hadError) => console.log("closed", { hadError }));
socket.on("error", (error) => console.error("connect/socket error", error.message));

end(data) 会写完 data 后发送 FIN;它不等同于立即销毁连接。客户端仍可继续收到服务端在收到 FIN 后发来的数据。

3. Echo 服务

显式写法便于加协议解析、统计、鉴权和背压控制:

const server = net.createServer((socket) => {
  socket.on("data", (chunk: Buffer) => {
    if (!socket.write(chunk)) socket.pause();
  });
  socket.on("drain", () => socket.resume());
  socket.on("error", () => undefined);
});
server.listen(9000);

最简回显是 socket.pipe(socket)。它会自动处理流背压,适合纯回显;但读写同一 Socket 的生命周期与错误处理不够显式,复杂协议通常选择前者或 pipeline()

4. endcloseerror

建议统一记录,而不是把固定顺序当协议:

for (const event of ["connect", "data", "end", "timeout", "error", "close"] as const) {
  socket.on(event, (...args) => console.log(event, ...args));
}
事件 含义 不能据此断言
end 可读端收到对端 FIN;以后不会再有数据 自己的写端已经关闭、资源都释放
error Socket 发生错误,如 ECONNREFUSEDECONNRESET 一定还会看到 end
close 句柄已关闭,是最终清理点;参数 hadError 表示此前是否有错误 每个逻辑协议消息已完整处理

正常客户端 end():服务端常见 end → close(默认 allowHalfOpen: false 会自动结束另一方向)。异常断开可能只有 error → close,也可能只见 close;服务端 socket.end() 会让客户端收到 end。连不存在端口通常客户端为 error(ECONNREFUSED) → close(true),没有 connect。事件先后会受平台和主动方影响,业务清理放在 close,错误信息在 error 保存。

5. 半关闭

服务端要在客户端结束写入后仍回最终结果,应启用半关闭:

import net from "node:net";

const server = net.createServer({ allowHalfOpen: true }, (socket) => {
  socket.on("data", (chunk) => console.log("request", chunk.toString()));
  socket.on("end", () => {
    socket.end("FINAL RESULT\\n"); // 写完后才关闭服务端写方向
  });
  socket.on("error", () => undefined);
});
server.listen(9000);

默认 allowHalfOpen: false:收到对端 FIN 后 Node 通常自动 end() 本端,未必有机会异步返回。半关闭会保留资源,必须明确何时最终 end()/destroy();不要把它当成长时间挂起连接的理由。

6. 空闲超时与心跳

const IDLE_MS = 30_000;
const server = net.createServer((socket) => {
  socket.setTimeout(IDLE_MS);
  socket.on("timeout", () => socket.end("IDLE_TIMEOUT\\n"));
  socket.on("error", () => undefined);
});
server.listen(9000);

// 客户端:心跳本身会重置任意 I/O 超时计时器。
setInterval(() => {
  if (!socket.destroyed) socket.write("PING\\n");
}, 10_000).unref();

setTimeout() 只触发 timeout 事件,不会自动断开。空闲连接占用 FD、内存和连接槽位;服务端必须决定协议级关闭策略。心跳定时器也必须在 closeclearInterval(),示例的 unref() 只是不让该定时器单独阻止进程退出。

7. 粘包与分包

socket.on("data", (chunk) => console.log(chunk.length, chunk.toString()));
socket.write("hello");
socket.write("world");

可能得到一块 helloworld、两块,也可能在网络与缓冲条件下有其他切分。TCP 保证有序可靠的字节序列,不保留应用 write() 边界;所谓粘包/分包是应用层误把 chunk 当消息造成的错觉。协议必须用分隔符、长度前缀或其他明确 framing。

8. 换行分隔命令

ASCII 命令可用字符串缓存;若允许任意 UTF-8 文本,使用第 12 题的 StringDecoder

let pending = "";
socket.on("data", (chunk: Buffer) => {
  pending += chunk.toString("utf8");
  let newline: number;
  while ((newline = pending.indexOf("\\n")) !== -1) {
    const line = pending.slice(0, newline).replace(/\\r$/, "");
    pending = pending.slice(newline + 1);
    if (line === "PING") socket.write("PONG\\n");
    else if (/^GET_USER \\d+$/.test(line)) socket.write(`USER ${line.slice(9)}\\n`);
    else socket.write("ERR unknown command\\n");
  }
  if (Buffer.byteLength(pending, "utf8") > 8 * 1024) socket.destroy(new Error("line too long"));
});

循环处理同一个 chunk 内的多行;没有换行的尾部留到下次。生产协议还应限制命令长度,避免攻击者永远不发换行而让缓存增长。

9. NDJSON

type Message = { type: "ping" } | { type: "message"; text: string };
function parseMessage(value: unknown): Message | undefined {
  if (!value || typeof value !== "object") return undefined;
  const record = value as Record<string, unknown>;
  if (record.type === "ping") return { type: "ping" };
  if (record.type === "message" && typeof record.text === "string") {
    return { type: "message", text: record.text };
  }
  return undefined;
}

function handleLine(line: string, socket: net.Socket): void {
  try {
    const message = parseMessage(JSON.parse(line) as unknown);
    if (!message) return void socket.write('{"type":"error","message":"invalid schema"}\\n');
    socket.write(message.type === "ping" ? '{"type":"pong"}\\n' : '{"type":"ok"}\\n');
  } catch {
    socket.write('{"type":"error","message":"invalid json"}\\n');
  }
}

将第 8 题循环里的命令处理替换为 handleLine() 即可。这里选择“单条无效 JSON 返回协议错误并继续”,适合交互协议;如果解析失败可能意味着攻击、状态不同步或安全敏感命令,则发送错误后 end() 更合适。JSON.parse() 的结果必须视为 unknown,TS 类型不会验证网络输入。

10. 4 字节长度前缀

const HEADER_BYTES = 4;
function frame(text: string): Buffer {
  const body = Buffer.from(text, "utf8");
  const header = Buffer.allocUnsafe(HEADER_BYTES);
  header.writeUInt32BE(body.length, 0);
  return Buffer.concat([header, body]);
}

let pending = Buffer.alloc(0);
socket.on("data", (chunk: Buffer) => {
  pending = Buffer.concat([pending, chunk]);
  while (pending.length >= HEADER_BYTES) {
    const length = pending.readUInt32BE(0);
    if (pending.length < HEADER_BYTES + length) break;
    const body = pending.subarray(HEADER_BYTES, HEADER_BYTES + length);
    pending = pending.subarray(HEADER_BYTES + length);
    console.log("message:", body.toString("utf8"));
  }
});
socket.write(frame("你好"));

先检查 header 是否完整,再读长度;再检查 body 是否完整;循环可以解析一个 chunk 内的多帧。subarray() 仍引用原 Buffer;若要长期保存 body,请 Buffer.from(body) 复制。

11. 单条消息与缓存上限

const MAX_FRAME_BYTES = 1 * 1024 * 1024;
const MAX_PENDING_BYTES = MAX_FRAME_BYTES + 4;

function protocolError(socket: net.Socket, message: string): void {
  socket.end(`ERR ${message}\\n`);
}

socket.on("data", (chunk: Buffer) => {
  if (pending.length + chunk.length > MAX_PENDING_BYTES) {
    protocolError(socket, "buffer limit");
    return;
  }
  pending = Buffer.concat([pending, chunk]);
  while (pending.length >= 4) {
    const length = pending.readUInt32BE(0);
    if (length > MAX_FRAME_BYTES) return void protocolError(socket, "frame too large");
    if (pending.length < 4 + length) return;
    const body = pending.subarray(4, 4 + length);
    pending = pending.subarray(4 + length);
    // handle body
  }
});

长度字段一出现就拒绝,不能等待攻击者把 4GB body 传完。单帧限制保护单次解析和业务内存;连接累计限制(第 15 题)限制整个会话的总流量,两者都需要。更高吞吐协议应使用分段缓冲队列,避免每次 Buffer.concat() 复制大量数据。

12. UTF-8 拆分与 StringDecoder

错误方式:每块单独 toString();一个中文字符的多字节被切开时会出现 Unicode replacement character(替换字符),原字节信息已经丢失。

import { StringDecoder } from "node:string_decoder";

const decoder = new StringDecoder("utf8");
let text = "";
socket.on("data", (chunk: Buffer) => {
  text += decoder.write(chunk); // decoder 暂存不完整的 UTF-8 序列
});
socket.on("end", () => {
  text += decoder.end(); // 刷出残留;残缺 UTF-8 会按解码规则替换
  console.log(text);
});

若协议已有二进制长度边界,也可缓存 Buffer,只有帧完整时对整帧 toString("utf8")。不要同时用字符串分行和不安全的 chunk.toString();NDJSON 应将 decoder.write() 的结果交给第 8 题的行缓存。

13. 回显的背压

function writeMany(socket: net.Socket, count: number): void {
  let index = 0;
  const writeMore = (): void => {
    while (index < count) {
      const accepted = socket.write(`line ${index++}\\n`);
      if (!accepted) {
        socket.once("drain", writeMore);
        return;
      }
    }
    socket.end();
  };
  writeMore();
}

write() 返回 false 表示数据已进入可写流缓冲区且超过 high-water mark;它不是写入失败。停止继续生产,等待一次 drain 后恢复。忽略它会让用户态缓冲持续积压,慢客户端可消耗大量内存。纯转发应优先 pipeline()(第 14 题),它自动传播背压。


下一册:第 14~27 题答案