跳轉到

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"

這叫黏包/拆包問題。解法是應用層自己定界:

  1. 固定長度
  2. 分隔符(如 \n,Redis 協議、早期 IRC)
  3. 長度前綴(頭 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);

三個工程要點:

  1. 廣播 = 對集合裡每個 socket write —— 所謂「推送」,伺服器端就是一個 for 迴圈
  2. close 時清理集合 —— 洩漏的 socket 引用會讓廣播打到死連線,記憶體緩慢上漲
  3. 狀態在記憶體中 → 單機玩玩可以;多機部署需要 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 世界的形態。

習題#

  1. 用 nc 連上 echo 伺服器,一次貼上 1MB 文字,觀察黏包現象。
  2. 把行式協議改為長度前綴協議,處理訊息跨 data 事件的情況。
  3. 若 1000 個客戶端同時廣播,慢客戶端會造成什麼?設計回壓方案。