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"));
server 的 error(如 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. end、close、error
建议统一记录,而不是把固定顺序当协议:
for (const event of ["connect", "data", "end", "timeout", "error", "close"] as const) {
socket.on(event, (...args) => console.log(event, ...args));
}
| 事件 | 含义 | 不能据此断言 |
|---|---|---|
end |
可读端收到对端 FIN;以后不会再有数据 | 自己的写端已经关闭、资源都释放 |
error |
Socket 发生错误,如 ECONNREFUSED、ECONNRESET |
一定还会看到 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、内存和连接槽位;服务端必须决定协议级关闭策略。心跳定时器也必须在 close 时 clearInterval(),示例的 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 题答案