EP.06網路系列

gRPC:微服務間通訊的
高效選擇

Protobuf 二進位編碼、Service 定義、Streaming — 比 REST 更快、強型別、自動生成客戶端

Joseph Chen 2026 15 min read gRPC · Protobuf · Microservices · HTTP/2

Section 1:REST 在微服務間通訊的問題

假設你有一個電商系統,Order Service 需要向 Payment Service 發起付款請求。 用 REST 的話,看起來很直觀:

REST 微服務通訊示意圖

plaintext
Order Service  →  HTTP/1.1 POST /api/payments  →  Payment Service
                       JSON: { "orderId": "abc-123", "amount": 1000 }

能用,但在高頻次、多服務的微服務架構下,這個做法有四個根本問題:

效能瓶頸

JSON 序列化 / 反序列化開銷大,HTTP/1.1 每次請求都需要建立新的 TCP 連線(沒有多路複用),高流量下延遲顯著上升。

型別不安全

Payment Service 不知道 Order Service 傳來的 amount 是 float 還是 integer。JSON 是純文字,型別只存在於文件,不在程式碼裡。

版本管理困難

API 欄位一旦更改,需要手動同步更新雙方的 Model 定義,非常容易漏改、改錯,也難以追蹤 breaking change。

沒有原生 Streaming

如果要傳輸大量資料(例如批次處理結果)或即時推送(例如訂單狀態流),REST 的請求 - 回應模型非常不方便,只能靠 WebSocket 或 SSE 繞過。

gRPC 如何解決這些問題?

  • 二進位格式(Protobuf):比 JSON 小 3–10 倍,序列化速度快 5–10 倍
  • HTTP/2:單一連線多路複用,消除 HTTP/1.1 的連線開銷
  • 強型別 Protobuf:從 .proto 檔自動生成雙語言的客戶端與伺服器 stub
  • 原生 Streaming:四種 RPC 模式,覆蓋所有通訊需求

Section 2:Protocol Buffers(Protobuf)

Protobuf 是 gRPC 的 IDL(Interface Definition Language)。你先用 .proto 檔定義好資料結構與 Service 介面, 再用 protoc 編譯器自動生成各語言的 client / server 程式碼。 這份 .proto 就是雙方服務的「合約」,型別不一致會在編譯期就報錯。

protobuf
// payment.proto
syntax = "proto3";

package payment;

// 定義 Enum
enum Currency {
  USD = 0;
  TWD = 1;
  JPY = 2;
}

// 定義 Message(相當於 TypeScript interface)
message PaymentRequest {
  string order_id    = 1;   // field number(序列化時的 ID,不要改!)
  double amount      = 2;
  Currency currency  = 3;
  string description = 4;
}

message PaymentResponse {
  string payment_id    = 1;
  bool   success       = 2;
  string error_message = 3;  // 失敗時才有值
}

message GetHistoryRequest {
  string user_id = 1;
  int32  limit   = 2;
}

message PaymentRecord {
  string payment_id = 1;
  double amount     = 2;
  string created_at = 3;
}

message PaymentFilter {
  string currency = 1;
  double min_amount = 2;
}

message BatchResult {
  int32 success_count = 1;
  int32 fail_count    = 2;
}

// 定義 Service(相當於 API 端點)
service PaymentService {
  // Unary RPC(普通請求 - 回應)
  rpc ProcessPayment(PaymentRequest) returns (PaymentResponse);

  // Server-Side Streaming(一個請求,多個回應)
  rpc GetPaymentHistory(GetHistoryRequest) returns (stream PaymentRecord);

  // Client-Side Streaming(多個請求,一個回應)
  rpc BatchProcess(stream PaymentRequest) returns (BatchResult);

  // Bidirectional Streaming(雙向流)
  rpc LivePaymentFeed(stream PaymentFilter) returns (stream PaymentRecord);
}

JSON 格式(文字)

json
{
  "order_id": "abc123",
  "amount": 1000.50
}
// 大小:38 bytes
// key 名稱也要序列化傳輸

Protobuf 格式(二進位)

plaintext
// 同樣資料 = ~12 bytes
// 用 field number (1, 2, 3...) 取代 key 名稱
// 省去了 "order_id"、"amount" 字串本身的空間
// 數字型別直接以二進位編碼,不轉成字串

為什麼 field number 不能改? Protobuf 序列化時,欄位的識別碼是 field number 而非名稱。 如果你把 amount = 2 改成 total = 2, binary 層面完全沒有影響,舊客戶端依然可以解碼。 但如果你把 field number 從 2 改成 5,舊客戶端就再也找不到 amount 了——這是 breaking change。


Section 3:Node.js gRPC Server 實作

Node.js 使用 @grpc/grpc-js 套件實作 gRPC server。 不需要額外編譯 .proto,直接在 runtime 載入即可:

typescript
// npm install @grpc/grpc-js @grpc/proto-loader

import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import path from 'path';

// ── 型別定義(對應 proto 裡的 Message)──
interface PaymentRequest {
  order_id: string;
  amount: number;
  currency: string;
  description: string;
}

interface PaymentResponse {
  payment_id: string;
  success: boolean;
  error_message: string;
}

interface GetHistoryRequest {
  user_id: string;
  limit: number;
}

interface PaymentRecord {
  payment_id: string;
  amount: number;
  created_at: string;
}

// ── 載入 proto 定義 ──
const packageDef = protoLoader.loadSync(
  path.join(__dirname, 'payment.proto'),
  {
    keepCase: true,   // 保留 snake_case(對應 proto 原始命名)
    longs: String,    // int64 轉成 string(JS number 精度不夠)
    enums: String,    // enum 轉成 string
    defaults: true,   // 未設定欄位填預設值
  }
);
const proto = grpc.loadPackageDefinition(packageDef) as any;

// ── 模擬資料庫操作 ──
async function processPaymentInDB(orderId: string, amount: number, currency: string) {
  // 實際會連資料庫、打金流 API...
  return { id: `pay-${Date.now()}` };
}

async function getPaymentHistory(userId: string, limit: number): Promise<PaymentRecord[]> {
  // 模擬從資料庫撈資料
  return Array.from({ length: Math.min(limit, 10) }, (_, i) => ({
    payment_id: `pay-${i}`,
    amount: (i + 1) * 100,
    created_at: new Date().toISOString(),
  }));
}

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

// ── 實作 Service ──
const paymentService = {
  // Unary RPC:一個請求,一個回應
  async processPayment(
    call: grpc.ServerUnaryCall<PaymentRequest, PaymentResponse>,
    callback: grpc.sendUnaryData<PaymentResponse>
  ) {
    const { order_id, amount, currency } = call.request;

    try {
      const result = await processPaymentInDB(order_id, amount, currency);
      callback(null, {
        payment_id: result.id,
        success: true,
        error_message: '',
      });
    } catch (error: any) {
      // gRPC 有標準的錯誤狀態碼,對應 HTTP status
      callback({
        code: grpc.status.INTERNAL,
        message: error.message,
      });
    }
  },

  // Server-Side Streaming:一個請求,多個回應(分批傳送)
  async getPaymentHistory(
    call: grpc.ServerWritableStream<GetHistoryRequest, PaymentRecord>
  ) {
    const { user_id, limit } = call.request;
    const records = await getPaymentHistory(user_id, limit);

    for (const record of records) {
      call.write(record);  // 每筆資料獨立傳送,不用等全部查完才回傳
      await sleep(10);     // 模擬資料庫分批查詢的間隔
    }
    call.end();  // 告訴客戶端串流結束
  },
};

// ── 啟動 Server ──
const server = new grpc.Server();
server.addService(proto.payment.PaymentService.service, paymentService);

server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createInsecure(),  // 生產環境改用 createSsl()
  (err, port) => {
    if (err) {
      console.error('Server 啟動失敗:', err);
      return;
    }
    server.start();
    console.log(`gRPC server 在 port ${port} 啟動`);
  }
);

gRPC 錯誤狀態碼:gRPC 有自己的一套狀態碼系統(非 HTTP status),例如INTERNAL(5xx 類)、NOT_FOUND(404 類)、PERMISSION_DENIED(403 類)、DEADLINE_EXCEEDED(timeout)。 設計良好的 gRPC service 應該使用語意明確的狀態碼,而不是所有錯誤都回 INTERNAL。


Section 4:gRPC Client 實作

gRPC 的 client 使用同一份 .proto 檔生成,所以型別是保證對得上的。 原生 API 是 callback 風格,實際使用時通常 promisify 一下:

typescript
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { promisify } from 'util';
import path from 'path';

const packageDef = protoLoader.loadSync(
  path.join(__dirname, 'payment.proto'),
  { keepCase: true, longs: String, enums: String, defaults: true }
);
const proto = grpc.loadPackageDefinition(packageDef) as any;

// ── 建立客戶端 ──
// 第一個參數是服務的 host:port(微服務環境通常透過 service discovery 解析)
const client = new proto.payment.PaymentService(
  'payment-service:50051',
  grpc.credentials.createInsecure()
);

// ── Promisify:把 gRPC callback 包成 async/await ──
const processPayment = promisify(client.processPayment.bind(client));

// Unary Call:等價於 fetch + await response.json(),但型別安全
async function chargeUser(orderId: string, amount: number) {
  const response = await processPayment({
    order_id: orderId,
    amount,
    currency: 'TWD',
    description: '商品購買',
  });

  if (!response.success) {
    throw new Error(response.error_message);
  }

  return response.payment_id;
}

// ── Server-Side Streaming:接收多個回應 ──
function streamPaymentHistory(userId: string): Promise<PaymentRecord[]> {
  return new Promise((resolve, reject) => {
    const records: PaymentRecord[] = [];

    // 呼叫 streaming RPC,取得一個可監聽事件的 stream 物件
    const stream = client.getPaymentHistory({
      user_id: userId,
      limit: 100,
    });

    // 每收到一筆資料就觸發
    stream.on('data', (record: PaymentRecord) => {
      records.push(record);
      console.log(`收到第 ${records.length} 筆付款記錄`);
    });

    // server 呼叫 call.end() 後觸發
    stream.on('end', () => {
      console.log(`串流結束,共 ${records.length}`);
      resolve(records);
    });

    stream.on('error', (err: Error) => {
      reject(err);
    });
  });
}

// ── Client-Side Streaming:多個請求,一個回應 ──
function batchProcessPayments(payments: PaymentRequest[]): Promise<BatchResult> {
  return new Promise((resolve, reject) => {
    const stream = client.batchProcess(
      (err: Error, response: BatchResult) => {
        if (err) reject(err);
        else resolve(response);
      }
    );

    // 一次一筆送過去
    for (const payment of payments) {
      stream.write(payment);
    }
    stream.end();  // 告訴 server 客戶端送完了
  });
}

// ── 實際使用 ──
async function main() {
  try {
    // Unary
    const paymentId = await chargeUser('order-001', 999);
    console.log('付款成功,ID:', paymentId);

    // Streaming
    const history = await streamPaymentHistory('user-001');
    console.log('歷史記錄共:', history.length, '筆');

    // Batch
    const result = await batchProcessPayments([
      { order_id: 'batch-1', amount: 100, currency: 'TWD', description: '' },
      { order_id: 'batch-2', amount: 200, currency: 'TWD', description: '' },
    ]);
    console.log(`批次結果:成功 ${result.success_count} 筆,失敗 ${result.fail_count}`);
  } catch (error) {
    console.error('gRPC 呼叫失敗:', error);
  }
}

main();

Section 5:gRPC 與 REST 詳細對比

gRPC 不是要取代 REST,而是針對不同場景各有優勢。看完這張表,你就知道什麼情況下該選哪個:

面向RESTgRPC
資料格式JSON(文字)Protobuf(二進位)
傳輸協議HTTP/1.1HTTP/2
效能基準5–10x 更快
型別安全無(需要 OpenAPI)✅ 強型別,編譯期檢查
客戶端生成需要 openapi-generator✅ protoc 自動生成
瀏覽器支援✅ 完整支援❌ 需要 grpc-web proxy
StreamingLong Polling / SSE✅ 原生四種 RPC 模式
學習成本中(需要學 Protobuf)
調試友好度✅ curl / Postman 即可需要 grpcurl / Postman gRPC

效能差異從何而來?HTTP/2 的多路複用(Multiplexing)是關鍵——多個 RPC 呼叫可以共用同一條 TCP 連線, 消除了 HTTP/1.1 每次都要重新建立連線的開銷。加上 Protobuf 的二進位壓縮,在高流量微服務之間效果非常顯著。

HTTP/1.1 vs HTTP/2 連線模型

plaintext
HTTP/1.1(REST):
Request A ──────────────────────────────► Response A
                    Request B ──────────────────► Response B
                                   Request C ──────────────► Response C
(三個請求需要三條 TCP 連線,或排隊等待 head-of-line blocking)

HTTP/2(gRPC):
          ┌─ Stream 1: Request A ──────────────► Response A ─┐
TCP 連線 ─┼─ Stream 2: Request B ──────────────► Response B ─┤
          └─ Stream 3: Request C ──────────────► Response C ─┘
(三個請求共用一條 TCP 連線,真正並行)

Section 6:什麼時候用 gRPC

工具沒有好壞,只有適不適合。實際工程決策應根據場景來選型:

用 gRPC

  • 微服務間的內部通訊
  • 高頻低延遲的 API 呼叫
  • 需要 Streaming(即時推送、批次)
  • 多語言環境(Go + Node.js + Python)
  • API 合約需要強型別保障

用 REST

  • 對外公開的 API(第三方整合)
  • 瀏覽器直接呼叫(無 proxy)
  • 簡單的 CRUD API
  • 團隊熟悉度高、快速迭代
  • 需要 CDN 快取(GET 請求)

用 tRPC

  • Next.js 全端應用
  • 整個 stack 都是 TypeScript
  • 不需要跨語言通訊
  • 開發速度優先(零 schema 定義)
  • 小型到中型團隊

現實世界的架構選擇

Google、Netflix、Uber 等公司的微服務架構通常長這樣: 對外是 REST API(瀏覽器友好),服務與服務之間是 gRPC(效能最優)。 這兩者並不互斥,你可以用 API Gateway 把外部 REST 請求轉發給內部 gRPC 服務。

plaintext
瀏覽器 / App
     │ REST (HTTPS)
API Gateway(Nginx / Kong)
     │ gRPC (HTTP/2)
     ├──► Order Service  :50051
     ├──► Payment Service :50052
     └──► Inventory Service :50053

gRPCProtobufMicroservicesHTTP/2StreamingNetwork