gRPC 微服務通訊完整教學:用 Protocol Buffers 打造高效能 Node.js 服務間通訊
如果你曾經在微服務架構中用 REST API 串接五個以上的服務,你大概知道那種痛苦——JSON 序列化慢、型別不安全、文件永遠跟不上程式碼。這就是為什麼越來越多團隊轉向 gRPC。身為在後端打滾多年的工程師,我想跟你分享我從 REST 遷移到 gRPC 的實戰經驗。
gRPC 與 REST 的根本差異
很多人把 gRPC 當成「更快的 REST」,但這個理解太淺了。gRPC 是 Google 開源的遠端程序呼叫框架,底層跑在 HTTP/2 上,使用 Protocol Buffers 作為介面定義語言(IDL)和序列化格式。REST 是基於資源的架構風格,而 gRPC 是基於服務和方法的 RPC 框架——兩者的設計哲學完全不同。
| 比較項目 | REST | gRPC |
|---|---|---|
| 協定 | HTTP/1.1 為主 | HTTP/2 |
| 資料格式 | JSON(文字) | Protocol Buffers(二進位) |
| 型別安全 | 需額外工具(OpenAPI) | 內建強型別 |
| 串流支援 | 有限(SSE、WebSocket) | 原生四種串流模式 |
| 程式碼生成 | 可選 | 內建多語言支援 |
| 瀏覽器支援 | 原生支援 | 需要 gRPC-Web 代理 |
在我的經驗中,服務間通訊選 gRPC,對外 API 用 REST,這是目前最務實的組合。如果你正在設計API Gateway 微服務閘道,gRPC 在內部路由層的效能優勢會非常明顯。
Protocol Buffers 基礎語法
Protocol Buffers(簡稱 Protobuf)是 gRPC 的核心。你用 .proto 檔案定義資料結構和服務介面,然後用編譯器自動產生各語言的程式碼。
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
rpc CreateUsers (stream CreateUserRequest) returns (BatchResponse);
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message GetUserRequest {
string user_id = 1;
}
message UserResponse {
string user_id = 1;
string name = 2;
string email = 3;
int32 age = 4;
repeated string roles = 5;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message BatchResponse {
int32 created_count = 1;
}
message ChatMessage {
string sender = 1;
string content = 2;
int64 timestamp = 3;
}
幾個重點:每個欄位後面的數字是 field number,不是預設值。一旦你的 .proto 上線後,絕對不要改動已有欄位的 field number,否則會破壞向後相容性。repeated 表示陣列,stream 關鍵字標記串流方法。
Node.js gRPC Server 實作
讓我們用 Node.js 實際架一個 gRPC Server。先安裝依賴:
npm install @grpc/grpc-js @grpc/proto-loader
@grpc/grpc-js 是純 JavaScript 實作,不需要原生編譯,部署時少很多麻煩。
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const path = require('path');
// 載入 proto 定義
const PROTO_PATH = path.join(__dirname, 'user.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
// 模擬資料庫
const users = new Map([
['u001', { user_id: 'u001', name: '王小明', email: '[email protected]', age: 28, roles: ['admin'] }],
['u002', { user_id: 'u002', name: '李小華', email: '[email protected]', age: 32, roles: ['user'] }],
]);
// 實作 Unary RPC
function getUser(call, callback) {
const user = users.get(call.request.user_id);
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: `User ${call.request.user_id} not found`
});
}
callback(null, user);
}
// 實作 Server Streaming
function listUsers(call) {
for (const [id, user] of users) {
call.write(user);
}
call.end();
}
// 啟動 Server
const server = new grpc.Server();
server.addService(userProto.UserService.service, {
GetUser: getUser,
ListUsers: listUsers,
});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) throw err;
console.log(`gRPC Server running on port ${port}`);
});
注意 loadSync 的選項:keepCase: true 保持原始命名,longs: String 避免 JavaScript 的大數精度問題。這些看起來不起眼的設定,在生產環境中都是踩過坑後才知道的。
Client 端呼叫方式
Client 端的程式碼同樣簡潔:
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const PROTO_PATH = './user.proto';
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true, longs: String, enums: String, defaults: true, oneofs: true
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
const client = new userProto.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Unary 呼叫
client.GetUser({ user_id: 'u001' }, (err, response) => {
if (err) return console.error('Error:', err.message);
console.log('User:', response);
});
// Server Streaming 呼叫
const stream = client.ListUsers({ page_size: 10 });
stream.on('data', (user) => console.log('Received:', user));
stream.on('end', () => console.log('Stream ended'));
stream.on('error', (err) => console.error('Stream error:', err));
四種串流模式深度解析
gRPC 的串流能力是它最強大的特色之一。讓我逐一說明:
1. Unary RPC(一對一)
最基本的模式,跟 REST API 一樣一問一答。適合簡單的 CRUD 操作。
2. Server Streaming(伺服器串流)
Client 發一個請求,Server 回傳一串資料。非常適合即時通知、日誌串流,或是大量資料分批傳送。比起 REST 要客戶端輪詢,效率高出不少。
3. Client Streaming(客戶端串流)
Client 持續送資料,Server 最後回一個結果。經典場景是檔案上傳或批次資料匯入。
4. Bidirectional Streaming(雙向串流)
兩邊同時收發,像 WebSocket 一樣。聊天室、即時協作編輯、遊戲同步都可以用這個模式。在搭配GitHub Actions CI/CD 自動化部署時,雙向串流也能用於即時部署狀態回報。
錯誤處理與最佳實踐
gRPC 有自己的一套狀態碼系統,跟 HTTP 狀態碼不同但概念類似:
| gRPC 狀態碼 | 對應情境 | HTTP 近似 |
|---|---|---|
| OK (0) | 成功 | 200 |
| NOT_FOUND (5) | 資源不存在 | 404 |
| ALREADY_EXISTS (6) | 重複建立 | 409 |
| PERMISSION_DENIED (7) | 權限不足 | 403 |
| UNAVAILABLE (14) | 服務暫時不可用 | 503 |
| DEADLINE_EXCEEDED (4) | 請求逾時 | 504 |
在生產環境,務必設定 deadline(超時時間),否則一個 hang 住的請求會連鎖拖垮整個服務鏈。這跟你設計API 限流策略的道理一樣,都是為了服務穩定性。
// 設定 5 秒超時
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5);
client.GetUser({ user_id: 'u001' }, { deadline }, (err, response) => {
if (err && err.code === grpc.status.DEADLINE_EXCEEDED) {
console.error('Request timed out');
}
});
實戰部署注意事項
分享幾個我在生產環境中學到的教訓:
- Health Check:gRPC 有標準的健康檢查協定(grpc.health.v1),一定要實作,Kubernetes 的 liveness/readiness probe 會用到。
- 連線管理:gRPC 使用長連線和連線多工,不需要連線池。但要注意 Load Balancer 的設定——如果你用 L4 負載均衡,請求可能都打到同一台機器。建議使用 client-side load balancing 或 L7 proxy(如 Envoy)。
- Reflection:開發環境開啟 gRPC Reflection,讓 Postman 或 grpcurl 等工具能自動發現服務定義,大幅提升開發體驗。
- Interceptors:類似 Express 的 middleware,用來處理認證、日誌、指標收集等橫切關注點。
- 向後相容:永遠只新增欄位,不要刪除或修改既有欄位的 field number。這是 Protobuf 能做到零停機更新的關鍵。
總結與選擇建議
gRPC 不是要取代 REST,而是在微服務內部通訊這個特定場景中提供更好的選擇。如果你的系統有以下特徵,我會強烈建議導入 gRPC:服務數量超過 5 個、需要即時串流、對延遲和吞吐量有要求、或是多語言混合開發。
從 REST 遷移到 gRPC 不需要一步到位。你可以先在最需要效能的服務間通訊路徑上試行,搭配 API Gateway 對外仍然提供 REST 介面,這樣風險最低,收益最大。記住,技術選型永遠是根據場景做取捨,沒有銀彈。
你可能也喜歡
探索其他領域的精選好文