3.2 用 Node.js 實作 TCP 伺服器#
從 C 到 Node.js:抽象的演化#
3.1 節的 C 寫法要手管 fd、epoll、緩衝區。Node.js(2009)把 epoll 事件循環封裝成「事件 + 回呼」,再用 Promise/async-await 修飾 —— 這一節你會看到同一台 TCP 伺服器的抽象層次逐級升高,而底層機制完全相同。
最小 TCP Echo 伺服器#
// server.js — net 模組 = Socket API 的事件化包裝
const net = require('net');
const server = net.createServer((socket) => {
// 每個新連線一個 socket 物件(= 一個已 accept 的 fd)
console.log('連線自:', socket.remoteAddress, socket.remotePort);
socket.on('data', (chunk) => { // read 事件化
socket.write(chunk); // write = echo
});
socket.on('close', () => console.log('連線關閉'));
socket.on('error', (err) => console.error('錯誤:', err.message));
});
server.listen(4000, () => console.log('監聽 4000'));
對照 3.1 節:
| C | Node.js |
|---|---|
socket() + bind() + listen() | net.createServer() + listen(4000) |
accept() 回傳新 fd | createServer 回呼參數 socket |
read() 阻塞 | socket.on('data') 事件 |
write() | socket.write() |
| epoll 事件循環 | 隱藏在 libuv 中,你感覺不到 |
測試:
node server.js &
nc localhost 4000 # 或 telnet
hello # 輸入
hello # 伺服器回聲
關鍵觀念:TCP 是位元組流,不是訊息#
TCP 沒有「訊息」邊界。你 write 兩次,對方可能一次收到全部、或分三次收到:
發送方: write("AB") write("CD")
接收方 data 事件可能是: "ABCD"、"AB"+"CD"、"A"+"BC"+"D"
這叫黏包/拆包問題。解法是應用層自己定界:
- 固定長度
- 分隔符(如
\n,Redis 協議、早期 IRC) - 長度前綴(頭 4 bytes 是 body 長度,最通用)
// 長度前綴法:4 bytes 長度 + JSON body
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 4) {
const len = buffer.readUInt32BE(0);
if (buffer.length < 4 + len) break; // 還沒收齊,等下一個 data 事件
const msg = JSON.parse(buffer.subarray(4, 4 + len));
buffer = buffer.subarray(4 + len); // 剩餘保留
handleMessage(msg);
}
});
這正是 WebSocket 幀協定存在的理由(2.4 節):WebSocket 幀頭裡的 length 欄位就是在替你解決這個問題。
一個有狀態的簡單協議:行式指令#
const net = require('net');
const rooms = new Map(); // room → Set<socket>
net.createServer((socket) => {
let room = null;
let name = 'anon' + Math.floor(Math.random() * 1000);
socket.write('歡迎!/join 房名 加入聊天\n');
socket.on('data', (chunk) => {
for (const line of chunk.toString().split('\n')) {
const text = line.trim();
if (!text) continue;
if (text.startsWith('/join ')) {
const r = text.slice(6);
rooms.has(r) || rooms.set(r, new Set());
rooms.get(r).add(socket);
room = r;
socket.write(`已加入 ${r}\n`);
} else if (room) {
const out = `[${name}] ${text}\n`;
for (const s of rooms.get(room)) s.write(out); // 廣播
}
}
}).on('close', () => {
room && rooms.get(room)?.delete(socket); // 清理!
});
}).listen(4000);
三個工程要點:
- 廣播 = 對集合裡每個 socket write —— 所謂「推送」,伺服器端就是一個 for 迴圈
- close 時清理集合 —— 洩漏的 socket 引用會讓廣播打到死連線,記憶體緩慢上漲
- 狀態在記憶體中 → 單機玩玩可以;多機部署需要 Redis Pub/Sub(見 6.4)
回壓(backpressure):被忽略的坑#
socket.write(bigString); // 若對方收得慢,資料積壓在記憶體
if (!socket.write(data)) {
socket.pause(); // 上游停止讀入
socket.once('drain', () => socket.resume());
}
write() 回傳 false 表示核心緩衝已滿。每個「讀入→寫出」的管線都要處理回壓,否則慢速客戶端會耗盡伺服器記憶體 —— Tower 的 poll_ready(前書 3.1)是同一問題在 Rust 世界的形態。
習題#
- 用
nc連上 echo 伺服器,一次貼上 1MB 文字,觀察黏包現象。 - 把行式協議改為長度前綴協議,處理訊息跨 data 事件的情況。
- 若 1000 個客戶端同時廣播,慢客戶端會造成什麼?設計回壓方案。