Node深入淺出(聖思園教育) 003

8次閱讀

WebSocket 與 SSE 總覽

WebSocket 基礎

  • 定位:WebSocket 是一條在 HTTP 握手後升級的全雙工連接,允許客戶端與服務器在同一 TCP 通道上雙向推送數據,省去了反覆輪詢。
  • 握手流程
  • 客戶端通過 Upgrade: websocket 頭髮起 HTTP 請求;
  • 服務器響應 101 Switching Protocols,雙方協商子協議、壓縮等擴展;
  • 後續通信轉爲 WebSocket 幀,遵循 FIN/Opcode/Payload 格式。
  • 特點:狀態常駐、頭部極小、支持二進制 / 文本幀、可以按房間劃分廣播。
  • 適用場景:IM/ 聊天室、協同編輯、實時儀表盤、在線遊戲等需要低延遲雙向通信的業務。

Node.js + Socket.IO 示例

socket.io 封裝了握手、心跳、自動重連、回退輪詢等細節,通過自帶協議 + 服務端組件 (socket.io) 與客戶端 (socket.io-client) 協作。

目錄與依賴初始化

mkdir websocket-demo && cd websocket-demo
npm init -y
npm install socket.io express

服務端示例

// server.js
const express = require('express');
const http = require('http');
const {Server} = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {cors: { origin: '*'},
  pingInterval: 10000, // 心跳頻率
});

io.on('connection', (socket) => {console.log('client connected:', socket.id);

  // 自定義事件:客戶端發來 chat-message
  socket.on('chat-message', (payload) => {
    // 發給當前客戶端的回執
    socket.emit('chat-ack', { id: payload.id, status: 'OK'});
    // 廣播給所有其他客戶端
    socket.broadcast.emit('chat-message', {
      ...payload,
      from: socket.id,
    });
  });

  // 服務器主動廣播
  socket.on('join-room', (room) => {socket.join(room);
    io.to(room).emit('room-notify', {
      room,
      memberCount: io.sockets.adapter.rooms.get(room)?.size || 0,
    });
  });

  socket.on('disconnect', (reason) => {console.log('disconnect:', socket.id, reason);
  });
});

server.listen(3000, () => {console.log('socket.io server listening on 3000');
});

客戶端示例

<!-- client.html -->
<script src="https://cdn.socket.io/4.7.4/socket.io.min.js"></script>
<script>
  const socket = io('http://localhost:3000', { transports: ['websocket'] });

  socket.on('connect', () => {console.log('connected', socket.id);
    socket.emit('join-room', 'general');
    socket.emit('chat-message', { id: Date.now(), text: 'Hello everyone!' });
  });

  socket.on('chat-message', (msg) => {console.log('[broadcast]', msg);
  });

  socket.on('room-notify', (data) => {console.log(`[room ${data.room}] members:`, data.memberCount);
  });

  socket.on('disconnect', (reason) => {console.log('connection closed:', reason);
  });
</script>

自定義事件與廣播策略

  • socket.on('<event>', handler):監聽自定義事件,如 chat-messagetyping
  • socket.emit:只發給當前連接(常用於回執、私聊)。
  • socket.broadcast.emit:發給除自己外的所有客戶端,適合房間外的全局廣播。
  • io.emit:發送給所有連接,包括自己,適合系統消息。
  • io.to(room).emit:房間級廣播,結合 socket.join(room)/socket.leave(room) 管理訂閱。
  • socket.compress(false).emit 等:對大流量廣播可控制壓縮、Acks 等參數以滿足性能需求。

SSE(Server-Sent Events)

概念與特點

  • 基於 HTTP/1.1 持久連接,由服務器單向推送文本事件到瀏覽器 EventSource
  • 事件格式是 text/event-stream,每個事件包含 eventdataid 等字段,以 \n\n 分隔。
  • 瀏覽器原生支持自動重連、Last-Event-ID 斷點續傳,適合通知、行情、日誌流式輸出。
  • SSE 是單向的(服務端 -> 客戶端),若需要客戶端上行消息仍要配合 POST/AJAX;而 WebSocket 是全雙工。

Node 服務端示例

// sse-server.js
const express = require('express');
const app = express();

app.get('/events', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });
  res.flushHeaders();

  let counter = 0;
  const timer = setInterval(() => {
    counter += 1;
    res.write(`event: tick\n`);
    res.write(`id: ${counter}\n`);
    res.write(`data: ${JSON.stringify({ counter, ts: Date.now() })}\n\n`);
  }, 2000);

  req.on('close', () => {clearInterval(timer);
    res.end();});
});

app.listen(4000, () => console.log('SSE server on 4000'));

瀏覽器客戶端示例

<script>
  const evtSource = new EventSource('http://localhost:4000/events');

  evtSource.addEventListener('tick', (evt) => {console.log('SSE tick payload:', JSON.parse(evt.data));
  });

  evtSource.onerror = (err) => {console.error('SSE connection lost', err);
  };
</script>

與 WebSocket 對比

  • 通信方向:SSE 只有服務端 -> 客戶端;WebSocket 支持雙向。
  • 協議 / 兼容:SSE 依賴 HTTP,受代理與防火牆更友好;WebSocket 需 Upgrade,對舊環境可能有限制。
  • 吞吐 & 二進制:SSE 僅傳文本,適合中低頻推送;WebSocket 帶二進制幀,對高頻 / 大數據更合適。
  • 心跳:SSE 內置重連機制;WebSocket 需應用層心跳(Socket.IO 已封裝)。

在實際項目中,可按場景選擇:例如後臺配置、行情推送可用 SSE;需要實時協作、IM、控制面板等則優先 WebSocket/Socket.IO。

HTTP/2 與長連接

核心特性

  • 繼承 HTTP/1.1 的持久連接模型,通過 多路複用 在一條 TCP 長連接上併發多條 Stream,避免隊頭阻塞。
  • 採用二進制幀層(Frame Layer),將請求 / 響應拆成 HEADERS、DATA 等幀以幀 ID 區分流。
  • HPACK 頭部壓縮 + 靜態表顯著降低重複 header 的傳輸開銷。
  • 支持服務器主動推送(Server Push,現今瀏覽器已逐步棄用,但在特定客戶端仍可用),也可通過長時間保持 DATA 幀實現流式返回。

Node.js http2 Demo

mkdir http2-demo && cd http2-demo
npm init -y

創建 server.mjs

import fs from 'node:fs';
import http2 from 'node:http2';

// 瀏覽器訪問需 https,可以用 mkcert/openssl 生成本地證書;示例使用自簽證書
const server = http2.createSecureServer({key: fs.readFileSync('./localhost-key.pem'),
  cert: fs.readFileSync('./localhost-cert.pem'),
  allowHTTP1: true, // 兼容老客戶端
});

server.on('stream', (stream, headers) => {const path = headers[':path'];
  if (path === '/time') {
    // 以 DATA 幀流式推送,實現長連接下的實時更新
    const timer = setInterval(() => {stream.write(JSON.stringify({ ts: Date.now() }) + '\n');
    }, 2000);
    stream.on('close', () => clearInterval(timer));
    return;
  }

  stream.respond({
    'content-type': 'application/json',
    ':status': 200,
  });
  stream.end(JSON.stringify({ hello: 'http2'}));
});

server.listen(8443, () => {console.log('HTTP/2 server running https://localhost:8443');
});

客戶端驗證

  • 瀏覽器:使用 fetch('https://localhost:8443/time') 需信任自簽證書,可在 DevTools 網絡面板確認 h2 協議,響應會持續追加數據。
  • CLI:curl --http2 -k https://localhost:8443/time-k 跳過證書校驗,可看到每 2 秒一行數據。

也可寫一個 Node 客戶端:

// client.mjs
import http2 from 'node:http2';

const client = http2.connect('https://localhost:8443', {rejectUnauthorized: false,});

const req = client.request({':path': '/time'});
req.on('data', (chunk) => {process.stdout.write('tick' + chunk.toString());
});
req.on('close', () => client.close());
req.end();

與 WebSocket/SSE 的取捨

  • HTTP/2 長連接仍遵循請求 - 響應語義,客戶端先發 HEADERS 後才能收到 DATA;WebSocket 連接一旦升級後就不再有請求概念。
  • 對純瀏覽器推送場景,若已升級 HTTP/2,可用長響應或 SSE 代替額外的 WS 鏈路,減少心跳管理;但 HTTP/2 仍不支持客戶端主動上行而不發請求。
  • 若需要雙向通信或跨 TCP 針對房間廣播,WebSocket 更直接;若是單向通知且基礎設施已支持 HTTP/2,則利用其多路複用 / 壓縮可以讓接口更加高效。

nodejs 的事件模型

事件循環概念

  • Node 架構:單線程 JS 執行線程 + 背景中的 libuv 線程池。所有 I/O 任務在內核或線程池中完成後,通過事件循環(Event Loop)調度回調進入 JS 主線程。
  • 事件循環階段(每一輪 tick 都按順序執行):
  • timers:到期的 setTimeout/setInterval;
  • pending callbacks:部分系統級回調,如 TCP errors;
  • idle/prepare:內部使用;
  • poll:獲取新的 I/O 事件,並執行相關回調。若爲空會視情況進入下一階段或阻塞等待;
  • check:專門執行 setImmediate;
  • close callbacks:例如 socket.on('close', ...)
  • 微任務:process.nextTick 在每個階段結束都會優先執行,Promise microtask 緊隨其後;因此合理使用 nextTick/queueMicrotask 可以調整回調執行順序。

與瀏覽器事件循環的差異

  • 階段模型不同:瀏覽器遵循 HTML 標準的 Task/Microtask 隊列(macro task 如 setTimeout、DOM 事件、fetch 回調等;micro task 如 Promise、MutationObserver),每次執行完一個宏任務就清空所有微任務。Node 由 libuv 驅動,擁有 timers/poll/check 等 6 個階段,每個階段結束後才輪詢 microtask,並在 process.nextTick 專隊列後才執行 Promise 微任務。
  • API 差異:瀏覽器沒有 setImmediateprocess.nextTick,而是以 MessageChannelrequestAnimationFramepostMessage 等手段來影響調度;Node 的 setImmediate 綁定 check 階段,nextTick 用於打斷當前階段。相同的 setTimeout(fn, 0) 在瀏覽器最早也會在 4ms(>=5 次嵌套)後執行,而 Node 在 timers 階段只要達到 0ms 就會調度。
  • 渲染與 UI 約束:瀏覽器事件循環與渲染管線耦合——requestAnimationFrame、layout/paint 會在宏任務之間執行,若 JS 長時間佔用主線程會阻塞頁面渲染。Node 不存在渲染階段,CPU 密集邏輯只會阻塞 I/O 回調進入主線程。
  • 多 event loop:瀏覽器中每個 window/frame/worker 擁有獨立事件循環,彼此通過 postMessage 交互;Node 只有一個主事件循環,若需要並行可藉助 worker_threads/cluster 來創建額外線程 / 進程。
  • 微任務優先級:瀏覽器 microtask(Promise 等)在宏任務完成後統一執行;Node 則是在 process.nextTick → Promise microtask → 下一階段的順序下執行,因此過度使用 nextTick 可能讓 I/O 飢餓,而瀏覽器則主要擔心 microtask 長時間未清空導致渲染延遲。

事件循環示例

// event-loop.js
const fs = require('fs');

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));

fs.readFile(__filename, () => {console.log('I/O callback');
  setTimeout(() => console.log('timeout in I/O'), 0);
  setImmediate(() => console.log('immediate in I/O'));
});

console.log('sync');

可能的輸出(取決於 libuv poll 階段狀態):

sync
nextTick          // 微任務最先執行
timeout / immediate(順序非固定)I/O callback
immediate in I/O  // I/O 回調階段後立即執行 check 階段
timeout in I/O

通過這個腳本可以觀察 timers/poll/check 在不同上下文下的調度次序。

EventEmitter 與自定義事件

Node 內部大量 API 基於 EventEmitterhttp.Servernet.Socket 等)。我們也可以通過繼承或直接實例化來發送 / 監聽自定義事件。

// custom-event.js
const EventEmitter = require('events');

class TaskBus extends EventEmitter {addTask(task) {
    // 模擬異步執行完成後觸發自定義事件
    setTimeout(() => {
      this.emit('task:done', {
        id: task.id,
        result: task.work.toUpperCase(),});
    }, 100);
  }
}

const bus = new TaskBus();

bus.on('task:done', (payload) => {console.log('Task finished:', payload);
});

bus.once('task:done', () => {console.log('This runs only for the first completion');
});

bus.addTask({id: 1, work: 'compile'});
bus.addTask({id: 2, work: 'test'});

要點:

  • on/addListener:添加長期監聽;once:一次性監聽並在觸發後自動移除。
  • emit(event, ...args):同步觸發監聽器,按註冊順序執行。若希望異步觸發可在 emit 前後使用 setImmediate.
  • removeListener/offremoveAllListeners 用於釋放資源,避免泄漏。

事件驅動模式有利於解耦生產者 / 消費者:I/O 完成、業務狀態變化等都封裝爲事件。結合事件循環的調度,Node 既能保持單線程的編程模型,又能充分利用異步 I/O 的高併發能力。

node 底層異步 IO 與線程池

libuv 模型

  • Node 的異步 IO 能力來自 C 層的 libuv:它向操作系統請求異步文件 /TCP/DNS 等操作,並通過事件循環在完成後觸發 JS 回調。
  • 對於 真正異步的內核調用(多數 socket、epoll/kqueue 支持的文件描述符),libuv 只需註冊事件並在可讀 / 可寫時向上通知,不佔用線程池。
  • 對於無法提供非阻塞接口的操作(如部分文件系統調用、DNS 解析、壓縮 / 加密等 CPU 密集任務),libuv 會將任務派發到一個固定大小的線程池中執行,完成後再把結果投遞迴事件循環。

線程池細節

  • 默認大小爲 4,可通過環境變量 UV_THREADPOOL_SIZE(最多 128)調整,例如 UV_THREADPOOL_SIZE=8 node server.js
  • 使用線程池的 Node API 包括:fs.readFile/writeFilecrypto.pbkdf2zlibdns.lookup(默認)、fs.stat 等。
  • 線程池中的任務一旦被佔滿,後續同類操作會排隊等待;因此高併發文件 IO 場景建議將同步讀寫改爲流式 fs.createReadStream/createWriteStream,或適當增大池規模。

示例:對比 IO 與 CPU 密集任務

// threadpool.js
const crypto = require('crypto');
const fs = require('fs');

console.time('fs');
for (let i = 0; i < 6; i++) {fs.readFile(__filename, () => {console.timeLog('fs', 'read file done', i);
  });
}

console.time('pbkdf2');
for (let i = 0; i < 6; i++) {crypto.pbkdf2('secret', 'salt', 1e6, 64, 'sha512', () => {console.timeLog('pbkdf2', 'hash done', i);
  });
}

運行後你會發現 pbkdf2 的回調最多同時處理 4 個任務,因爲線程池默認僅提供 4 條工作線程;而 fs.readFile 也會佔用同一個池。若對 CPU 密集任務並行度有更高需求,可以在啓動時調大 UV_THREADPOOL_SIZE,但要注意機器核數和上下文切換成本。

與事件循環的協作

  • 線程池執行完任務後,會將完成事件放入 pending callbacks 階段隊列;事件循環會在下一輪處理它們,從而調用 JS 回調。
  • 若 JS 層長時間處理 CPU 邏輯,事件循環無法盡快回到 poll 階段,線程池排隊的結果也無法被消費,這就是單線程阻塞問題。解決方案包括:拆分任務、使用 worker_threads、或遷移到專用進程。

實踐建議

  • 避免在請求線程中直接執行 crypto.pbkdf2zlib.gzip 等重 CPU 操作,可放到 worker_threads 或外部服務。
  • 大量文件操作時使用流式 API,或調節 UV_THREADPOOL_SIZE 並結合批量 / 限流控制。
  • 對網絡 IO,儘量使用真正異步的 socket/HTTP 客戶端,藉助 libuv epoll/kqueue 能力可以在單線程下同時管理數萬連接。

總體來說,Node 的“異步 IO + 線程池”模式將 CPU 與 IO 工作拆分:IO 事件由內核通知、CPU 耗時由線程池承擔,JS 主線程只負責驅動狀態機和業務邏輯,從而達到高併發、高吞吐的效果。

Node 連接 MySQL / MongoDB / Redis

MySQL:事務型業務

適用於電商訂單、庫存等強一致場景。推薦 mysql2,支持 Promise 與連接池。

npm install mysql2
// mysql.js
const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: 'localhost',
  user: 'app_user',
  password: 'secret',
  database: 'shop',
  waitForConnections: true,
  connectionLimit: 10,
});

async function createOrder(userId, items) {const conn = await pool.getConnection();
  try {await conn.beginTransaction();
    const [orderResult] = await conn.execute('INSERT INTO orders(user_id, status) VALUES(?, ?)',
      [userId, 'pending']
    );
    for (const item of items) {
      await conn.execute('INSERT INTO order_items(order_id, sku, qty) VALUES(?,?,?)',
        [orderResult.insertId, item.sku, item.qty]
      );
    }
    await conn.commit();
    return orderResult.insertId;
  } catch (err) {await conn.rollback();
    throw err;
  } finally {conn.release();
  }
}

實戰提示:

  • 所有參數使用 ? 佔位 + 綁定數組,防 SQL 注入;
  • 把長連接集中託管在 pool 中,設置 waitForConnections 避免連接數耗盡;
  • 對報表、只讀接口可配置從庫連接,實現主寫從讀;
  • 結合 pool.on('connection', conn => conn.query('SET SESSION sql_mode=...')) 做統一設置。

ORM 示例:Sequelize

在中大型項目中可使用 ORM 來抽象模型、關係與遷移。

npm install sequelize mysql2
// sequelize.js
const {Sequelize, DataTypes} = require('sequelize');
const sequelize = new Sequelize('shop', 'app_user', 'secret', {
  host: 'localhost',
  dialect: 'mysql',
  logging: false,
  pool: {max: 10, idle: 10000},
});

const User = sequelize.define('User', {email: { type: DataTypes.STRING, unique: true},
  nickname: DataTypes.STRING,
});

const Order = sequelize.define('Order', {
  status: {type: DataTypes.ENUM('pending', 'paid', 'shipped'),
    defaultValue: 'pending',
  },
  totalPrice: DataTypes.DECIMAL(10, 2),
});

User.hasMany(Order, { foreignKey: 'userId'});
Order.belongsTo(User, { foreignKey: 'userId'});

async function init() {await sequelize.sync({ alter: true}); // 生產環境建議用 migration
  const user = await User.create({email: 'demo@test.com', nickname: 'Demo'});
  await Order.create({userId: user.id, totalPrice: 199.99});
  const orders = await Order.findAll({include: [{ model: User, attributes: ['email'] }],
  });
  console.log(JSON.stringify(orders, null, 2));
}

init().catch(console.error);

Sequelize 要點:

  • 支持模型鉤子、驗證、作用域、樂觀鎖 (version) 等高級特性;
  • 生產環境使用 sequelize-cli 維護 migration,避免 sync({alter: true}) 在多節點環境下帶來不可控變更;
  • 當 SQL 需要優化時可使用 sequelize.query() 下發原生語句,同時保留模型定義。

ORM 示例:Prisma

Prisma 使用 Schema 文件定義模型,並生成類型安全的客戶端,適合 TypeScript 項目。

npm install prisma @prisma/client
npx prisma init

prisma/schema.prisma:

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {provider = "prisma-client-js"}

model User {id       Int      @id @default(autoincrement())
  email    String   @unique
  nickname String?
  orders   Order[]}

model Order {id        Int      @id @default(autoincrement())
  status    String   @default("pending")
  total     Decimal
  user      User     @relation(fields: [userId], references: [id])
  userId    Int
  createdAt DateTime @default(now())
}

執行 npx prisma migrate dev --name init 生成表,然後:

// prisma-demo.ts
import {PrismaClient} from '@prisma/client';
const prisma = new PrismaClient();

async function main() {
  const user = await prisma.user.create({
    data: {
      email: 'dev@test.com',
      nickname: 'dev',
      orders: {create: [{ total: 299.9}, {total: 399.9, status: 'paid'}],
      },
    },
    include: {orders: true},
  });
  console.log(user);
}

main().finally(() => prisma.$disconnect());

Prisma 會根據 Schema 自動生成類型定義,支持中間件(如審計日誌)、連接池複用、query event hook 等,能在保持 SQL 控制的同時保證開發效率。

MongoDB:文檔型場景

內容管理、社交 Feed、日誌或靈活 Schema 使用 MongoDB 更方便。可用官方驅動或 mongoose

npm install mongoose
// mongo.js
const mongoose = require('mongoose');

await mongoose.connect('mongodb://localhost:27017/forum', {maxPoolSize: 20,});

const PostSchema = new mongoose.Schema({
  title: String,
  content: String,
  tags: [String],
  createdAt: {type: Date, default: Date.now},
});

PostSchema.index({tags: 1, createdAt: -1});

const Post = mongoose.model('Post', PostSchema);

async function listPosts(tag) {return Post.find({ tags: tag}).sort({createdAt: -1}).limit(20).lean();}

要點:

  • 使用 maxPoolSize 限制連接數量,搭配連接監控;
  • 查詢鏈路最後調用 lean(),減少 Mongoose 的 getter/setter 開銷;
  • 對高頻字段建立複合索引(如 tag + createdAt),避免全表掃描;
  • 可以利用副本集多節點讀 (readPreference=secondaryPreferred) 用於報表或實時推薦。

Redis:緩存 + 消息

Redis 擅長緩存、會話、計數器、Pub/Sub。ioredis 支持 Cluster/Sentinel、自動重連。

npm install ioredis
// redis.js
const Redis = require('ioredis');
const redis = new Redis({host: '127.0.0.1', port: 6379});

async function cacheProfile(userId, profile) {await redis.set(`user:${userId}`, JSON.stringify(profile), 'EX', 3600);
}

async function getProfile(userId) {const cached = await redis.get(`user:${userId}`);
  return cached ? JSON.parse(cached) : null;
}

// 分佈式鎖:防止重複下單
async function acquireLock(key, ttl = 5000) {const lockId = Date.now() + Math.random().toString(16).slice(2);
  const ok = await redis.set(key, lockId, 'NX', 'PX', ttl);
  return ok ? lockId : null;
}

// Pub/Sub:訂單狀態通知
const sub = new Redis();
sub.subscribe('order-status');
sub.on('message', (_, payload) => {console.log('Order update:', payload);
});

const pub = new Redis();
pub.publish('order-status', JSON.stringify({ orderId: 1, status: 'shipped'}));

實踐建議:

  • 緩存必須設置 TTL,配合隨機過期(EX + Math.random())降低雪崩;
  • 需要持久化的數據配合 AOF/RDB 機制,或使用 Redis Cluster 提高可用性;
  • Pub/Sub 只保證盡力而爲,若需可靠消費可用 Redis Streams (XADD/XREADGROUP);
  • 全局自增 ID 可用 INCR,排行榜使用 ZADD/ZREVRANGE

通過合理地將 MySQL 的強事務、MongoDB 的靈活文檔與 Redis 的高速緩存結合,Node 應用可以 3 層分工:寫入鏈路首先落盤 MySQL/MongoDB,讀取優先命中 Redis,錯失再回源;實時事件通過 Redis Pub/Sub 或 Stream 推送到 WebSocket/SSE,與上文實時通信部分協同,構建端到端的高併發服務。

Node 主進程、子進程與 Cluster

child_process 基礎

Node 默認單線程執行 JS,如需利用多核或調用系統命令,可使用 child_process 模塊創建子進程:

  • spawn(command, args, options):最常用,返回流式 stdout/stderr,適合長期任務;
  • exec(command, options, callback):將輸出緩存在內存,適合短命令;
  • fork(modulePath, args, options):專門用來運行 Node 子腳本,自動建立主子進程 IPC 通道。
// child.js
setInterval(() => {process.send({ type: 'tick', ts: Date.now() });
}, 1000);

process.on('message', (msg) => {if (msg === 'stop') process.exit(0);
});
// master.js
const {fork} = require('child_process');
const child = fork('./child.js');

child.on('message', (msg) => {console.log('Child message:', msg);
  if (msg.type === 'tick' && Math.random() > 0.8) {child.send('stop');
  }
});

child.on('exit', (code) => console.log('child exit', code));

fork 創建的管道是基於 IPC 的,process.sendchild.on('message') 傳遞的對象會自動序列化。在 spawn/exec 場景也可以通過 child.stdin.writechild.stdout.on('data') 來實現流式通信。

共享端口與 Cluster

單個 Node 進程無法使用多核。cluster 模塊封裝了 child_process.fork,允許多個 Worker 共享同一個服務器端口;Master 負責監聽端口並將連接分發給 Worker(在 Linux 上通過 SO_REUSEPORT 或內部 Round-Robin)。

// cluster-server.js
const cluster = require('cluster');
const http = require('http');
const os = require('os');

if (cluster.isMaster) {const cpuCount = os.cpus().length;
  for (let i = 0; i < cpuCount; i++) {cluster.fork();
  }
  cluster.on('exit', (worker, code) => {console.log(`Worker ${worker.process.pid} died`, code);
    cluster.fork(); // 自動重啓});
} else {
  http
    .createServer((req, res) => {res.end(`Handled by worker ${process.pid}\n`);
    })
    .listen(3000);
}

主進程中可以監聽 cluster.on('online')cluster.on('message') 等事件,Worker 也可通過 process.send 與主進程交互,例如上報負載、健康狀態。

Cluster 與子進程通信

  • Master -> Worker:worker.send(payload)
  • Worker -> Master:process.send(payload);Master 監聽 cluster.on('message', (worker, msg) => {})
  • Worker 間通信需要由 Master 做中轉,或使用 Redis/Message Queue;
  • 對於 CPU 密集型任務,worker_threads 是更輕量的選擇,且共享內存;Cluster 側重端口共享 + 進程隔離。

實踐建議

  • 子進程默認與主進程同生命週期,確保監聽 exit/error 以便重啓或降級;
  • IPC 消息會序列化,避免傳輸巨大對象,可使用共享內存文件或 Socket;
  • Cluster 模式下的 Worker 是獨立進程,狀態(緩存、連接)不共享,需在外部存儲會話(如 Redis);
  • 在容器環境中可考慮使用 PM2、forever 等進程管理工具封裝 cluster,附帶日誌、重啓策略和健康檢查。

通過 child_process + cluster,Node 可以在保持單線程模型的前提下複用多核、隔離崩潰,並與系統命令或其他 Node 子腳本協作,構建更可靠的服務。

Express 與 Koa 項目搭建

Express:經典 MVC 框架

Express 是 Node 上使用最廣泛的 Web 框架之一,提供路由、中間件機制,可以方便地組織 MVC/REST 項目。

初始化步驟

mkdir express-demo && cd express-demo
npm init -y
npm install express dotenv morgan

目錄結構示例:

express-demo/
├─ app.js            # 創建 express 實例
├─ routes/
│  ├─ index.js
│  └─ users.js
├─ controllers/
│  └─ user.controller.js
├─ models/
│  └─ user.model.js  # 可使用 Sequelize/Prisma 等 ORM
└─ middlewares/
   └─ auth.js

app.js:

const express = require('express');
const morgan = require('morgan');
require('dotenv').config();

const app = express();
app.use(express.json());
app.use(morgan('dev'));

const userRouter = require('./routes/users');
app.use('/api/users', userRouter);

app.use((err, req, res, next) => {console.error(err);
  res.status(err.status || 500).json({message: err.message});
});

app.listen(process.env.PORT || 3000, () => {console.log('Express server running');
});

routes/users.js:

const router = require('express').Router();
const userController = require('../controllers/user.controller');
const auth = require('../middlewares/auth');

router.get('/', auth.optional, userController.list);
router.post('/', auth.required, userController.create);

module.exports = router;

controllers/user.controller.js:

const User = require('../models/user.model');

exports.list = async (req, res, next) => {
  try {const users = await User.findAll();
    res.json(users);
  } catch (err) {next(err);
  }
};

Express 模型層通常由 ORM/ODM 提供(Sequelize/Prisma/Mongoose 等)。Express 引入中間件棧,因此可以輕鬆插入鑑權、日誌、限流等邏輯,適合 REST API 和 SSR 應用。

Koa:更輕量的中間件組合

Koa 由 Express 團隊打造,使用 async/await + 洋蔥模型中間件,核心更輕,適合按需組合。

初始化步驟

mkdir koa-demo && cd koa-demo
npm init -y
npm install koa koa-router koa-body koa-logger dotenv

目錄結構示例:

koa-demo/
├─ app.js
├─ routes/
│  └─ user.route.js
├─ controllers/
│  └─ user.controller.js
├─ services/
│  └─ user.service.js
└─ models/         # 可放 ORM 定義

app.js:

const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-body');
const logger = require('koa-logger');
require('dotenv').config();

const app = new Koa();
const router = new Router({prefix: '/api'});

app.use(logger());
app.use(bodyParser());

const userRoute = require('./routes/user.route');
router.use('/users', userRoute.routes(), userRoute.allowedMethods());

app.use(router.routes()).use(router.allowedMethods());

app.on('error', (err, ctx) => {console.error('server error', err, ctx);
});

app.listen(process.env.PORT || 4000, () => {console.log('Koa server running');
});

routes/user.route.js:

const Router = require('koa-router');
const controller = require('../controllers/user.controller');
const authGuard = require('../middlewares/auth');

const router = new Router();
router.get('/', controller.list);
router.post('/', authGuard, controller.create);

module.exports = router;

Koa controller 示例:

const userService = require('../services/user.service');

exports.list = async (ctx, next) => {const users = await userService.findAll();
  ctx.body = users;
};

Koa 推薦的“模型”組織方式通常是 Service/Repository 層來封裝業務邏輯,結合 async/await 洋蔥模型,可以對請求前後做流式處理(如響應壓縮、錯誤捕獲)。配合 koa-composekoa-jwt 等中間件,可快速搭建 GraphQL、REST、或 BFF 服務。

洋蔥皮模型原理

Koa 的中間件本質是一個 async 函數組成的數組(middleware stack),利用 await next() 將執行權傳給下箇中間件,再在 next 返回後繼續向外執行,形成“先遞進、後回溯”的洋蔥模型。koa-compose 的核心實現如下(簡化版):

function compose(middlewares) {return function (ctx) {return dispatch(0);
    function dispatch(i) {const fn = middlewares[i];
      if (!fn) return Promise.resolve();
      return Promise.resolve(fn(ctx, () => dispatch(i + 1)));
    }
  };
}

示例:

app.use(async (ctx, next) => {console.log('A before');
  await next();
  console.log('A after');
});

app.use(async (ctx, next) => {console.log('B before');
  await next();
  console.log('B after');
});

app.use(async (ctx) => {console.log('handler');
  ctx.body = 'OK';
});

輸出順序:A before -> B before -> handler -> B after -> A after。因此,想要在請求完成後做收尾工作(日誌、事務、響應包裝),只需把邏輯寫在 await next() 之後即可;想在進入下層之前預處理(鑑權、解析)則寫在 await next() 之前。該模型通過 Promise/async 遞歸即可實現,無需複雜的狀態機。

Express vs Koa

  • 中間件機制:Express 基於 callback 棧;Koa 基於 async/await 洋蔥模型,中間件可以 await next() 後在返回時再處理。
  • 生態:Express 中間件生態龐大;Koa 核心輕量,但需要手動選擇中間件搭建功能。
  • 默認功能:Express 自帶大量便捷方法(res.jsonexpress.static);Koa 只提供核心,需要額外引入包。
  • 錯誤處理:Koa 的 try/catch 搭配 app.on('error') 能捕獲 async 中的異常;Express 通過 next(err) 傳遞到錯誤處理中間件。

無論是 Express 還是 Koa,都可以將前述數據庫層(MySQL/MongoDB/Redis)和實時能力(WebSocket/SSE)整合進來,按照路由 -> 控制器 -> 服務 -> 模型的分層設計,構建高可維護的 Node Web 項目。

正文完
 0