Unify Service Communication with gRPC

透過 gRPC 統一服務之間溝通

前言

AI 開發大多時候只要描述清楚目標,細部實踐通常不是問題,但有個問題一直困擾我很久:「跨服務之間溝通正確性」和「提供合理上下文給 AI 協作」,我發現跨服務時花很多時間在手動規劃提供兩個服務之間的程式碼,塞合適的上下文給 AI。

舉例最近在處理多個服務之間溝通的時候最麻煩的是對於同一筆資料有不同的描述:

痛點一:服務間對同筆資料定義名稱不同

Order_Service_View

+string userId

+string fullName

+string contactEmail

Customer_Service_View

+string customer_id

+string full_name

+string email

+string phone_number

Order Service

Customer Service

兩邊都在講「同一個客戶」,但欄位命名不一致(userId vs customer_id

痛點二:資料欄位缺漏導致問題

Customer ServiceOrder ServiceClientCustomer ServiceOrder ServiceClient轉換欄位命名customer_id → userIdfull_name → fullNamephone_number 沒定義,丟棄建立訂單,需要簡訊通知查詢使用者 (customer_id=123)回傳 {customer_id, full_name, email, phone_number}訂單建立成功,但無法簡訊通知

gRPC 解方

上面兩個痛點的共同根因是:同一筆資料定義散落在各個服務的程式碼裡,命名差異與欄位缺漏都只能靠人工比對文件發現,通常是等到上線後才炸開。

gRPC🔗 的做法是把「介面」從程式碼裡抽出來,變成一份與語言無關的合約檔案 .proto,再由工具產生各語言的程式碼鞏固型別(細節底下章節會說):

customer.proto
唯一真實來源

protoc / buf
程式碼生成

Go Server
CustomerServiceServer

Go Client
CustomerServiceClient

TypeScript Client
前端 / BFF

因為合約只有一份,服務之間不可能再對「資料定義」有分歧,且因為程式碼是根據合約生成的,欄位改名或新增欄位會直接讓沒跟上的一方編譯或型別檢查失敗,而不是執行時才出問題。

定義合約

Protocol Buffers(Protobuf)是 gRPC 預設的介面定義語言(IDL)與序列化格式。把前兩個服務對於資料的定義寫成合約:

proto/customer/v1/customer.proto
// 指定使用 Protobuf 第 3 版語法規格
syntax = "proto3";
// Proto 內部的邏輯命名空間,用來避免不同 Proto 檔案之間的名稱衝突。
package customer.v1;
// 告訴 Protobuf 編譯器將此檔案編譯成 Go 語言程式碼時的存放路徑與 Package 名稱。
option go_package = "github.com/riceball/example/gen/customer/v1;customerv1";
message Customer {
string id = 1;
string full_name = 2;
string email = 3;
string phone_number = 4;
}
message GetCustomerRequest {
string id = 1;
}
message GetCustomerResponse {
Customer customer = 1;
}
service CustomerService {
rpc GetCustomer(GetCustomerRequest) returns (GetCustomerResponse);
}
  • 欄位編號(Field Number)= 1= 2 不是預設值,而是這個欄位在二進位格式中的識別碼。編號一旦上線就不能改,名稱反而可以改,因為二進位傳輸的是編號而不是名稱,例如:傳送「編號 + 資料內容」(例如 2: “張三”)。
  • 命名慣例交給生成器:proto 裡規範統一用 snake_case,生成 Go 時會變成 FullName、生成 TypeScript 時會變成 fullName。也就是說痛點一的 userId vs customer_id 之爭,在合約層根本不存在,各語言拿到的都是自己習慣的寫法。

產生程式碼

官方原生工具是 protoc,但參數與 include path 很難維護,實務上推薦 buf🔗

buf.yaml
version: v2
modules:
- path: proto
lint:
use:
- DEFAULT
breaking:
use:
- FILE
buf.gen.yaml
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- remote: buf.build/grpc/go
out: gen
opt: paths=source_relative
Terminal window
# 產生程式碼
buf generate
# 檢查命名、風格是否符合慣例
buf lint
# 對照 main 分支檢查是否有破壞性變更
buf breaking --against '.git#branch=main'

buf breaking 是最方便的一個工具,會在 CI 直接擋下「刪掉還在用的欄位」、「改掉欄位編號」這類變更,讓合約的相容性由合約保證而不是靠代碼審核眼力。

代碼生成什麼?

buf generate 不會產生任何業務邏輯,只會把合約翻譯成 Go 程式碼。上面 yaml 設定了兩個 plugin,各自負責一半:

  • protoc-gen-go (protocolbuffers/go)
    • 職責:資料結構(Message)層。
    • 產出:customer.pb.go
    • 內容:將 message Customer 轉成 Go 的 type Customer struct,並包含 Getter 方法與 Protocol Buffers 的二進位序列化/反序列化(Marshal/Unmarshal)邏輯。
  • protoc-gen-go-grpc (grpc/go)
    • 職責:網路傳輸(RPC)層。
    • 產出:customer_grpc.pb.go
    • 內容:將 service CustomerService 轉成 Go 的 Interface,包含 Client 端的呼叫封裝(Client Stub)與 Server 端要實作的 Handler 介面。
gen/
└── customer/
└── v1/
├── customer.pb.go # protocolbuffers/go:message 的型別與序列化
└── customer_grpc.pb.go # grpc/go:service 的 Client 與 Server 骨架

customer.pb.go 是「資料」的部分,把每個 message 變成 Go struct,附上欄位編號與序列化資訊,並提供 nil-safe 的 getter:

gen/customer/v1/customer.pb.go(節錄)
type Customer struct {
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
FullName string `protobuf:"bytes,2,opt,name=full_name,json=fullName,proto3" json:"full_name,omitempty"`
Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"`
PhoneNumber string `protobuf:"bytes,4,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"`
// ...省略 protobuf 自用的私有欄位
}
// 生成的 getter 會處理 nil receiver,所以 res.GetCustomer().GetFullName() 不會 panic
func (x *Customer) GetFullName() string {
if x != nil {
return x.FullName
}
return ""
}

full_name 在這裡同時保留了三種名字:proto 的 full_name(傳輸與 JSON 對照用)、Go 的 FullName(程式使用)、以及 json=fullName(給 JSON 轉換時的 camelCase)。這就是為什麼命名慣例可以交給生成器,而不需要各服務自己寫轉換函式。

customer_grpc.pb.go 是「介面」的部分,Client 與 Server 兩邊都從這裡長出來,但生成的程度完全不同。

Client 端:連呼叫都幫你寫好

gen/customer/v1/customer_grpc.pb.go(節錄)
const CustomerService_GetCustomer_FullMethodName = "/customer.v1.CustomerService/GetCustomer"
type CustomerServiceClient interface {
GetCustomer(ctx context.Context, in *GetCustomerRequest, opts ...grpc.CallOption) (*GetCustomerResponse, error)
}
type customerServiceClient struct {
cc grpc.ClientConnInterface
}
func NewCustomerServiceClient(cc grpc.ClientConnInterface) CustomerServiceClient {
return &customerServiceClient{cc}
}
func (c *customerServiceClient) GetCustomer(ctx context.Context, in *GetCustomerRequest, opts ...grpc.CallOption) (*GetCustomerResponse, error) {
out := new(GetCustomerResponse)
err := c.cc.Invoke(ctx, CustomerService_GetCustomer_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

Client 端是完整實作:介面、結構、以及每個方法的 Invoke 都生成好了,呼叫端只要 NewCustomerServiceClient(conn) 就有一個可用的物件。路徑字串 /customer.v1.CustomerService/GetCustomer 也被寫成常數,這正是 proto 的 package + service + rpc 三者組合出來的位址,手寫 HTTP 請求時最容易打錯的部分被消滅了。

Server 端:只生成骨架,邏輯自己寫

gen/customer/v1/customer_grpc.pb.go(節錄)
type CustomerServiceServer interface {
GetCustomer(context.Context, *GetCustomerRequest) (*GetCustomerResponse, error)
mustEmbedUnimplementedCustomerServiceServer()
}
type UnimplementedCustomerServiceServer struct{}
func (UnimplementedCustomerServiceServer) GetCustomer(context.Context, *GetCustomerRequest) (*GetCustomerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCustomer not implemented")
}
func RegisterCustomerServiceServer(s grpc.ServiceRegistrar, srv CustomerServiceServer) {
s.RegisterService(&CustomerService_ServiceDesc, srv)
}
var CustomerService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "customer.v1.CustomerService",
HandlerType: (*CustomerServiceServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "GetCustomer", Handler: _CustomerService_GetCustomer_Handler},
},
// 省略 Streams 與 Metadata
}

Server 端生成的是待填的洞

  • CustomerServiceServer 介面定義了有哪些方法、收什麼、回什麼。方法簽章打錯(例如少一個參數、回傳型別不對)就編譯失敗,不會等到執行期。
  • UnimplementedCustomerServiceServer 是預設實作,全部回傳 codes.Unimplemented。介面裡那個小寫的 mustEmbedUnimplementedCustomerServiceServer() 方法無法從外部套件實作,等於強制你把它嵌進自己的 struct,這樣 proto 新增 rpc 時舊 Server 仍能編譯。
  • ServiceDesc 與各個 _Handler 是路由表與解碼器:把進來的二進位資料反序列化成 *GetCustomerRequest,呼叫你的方法,再把回傳值序列化出去。

所以兩邊的分工是:

手寫程式碼

生成程式碼(不要手改)

Client:介面 + 實作
直接可用

Server:介面 + 空實作 + 路由表
等你填

customerServer
實作介面,接 DB 與商業邏輯

  • 絕對不要手改 .pb.go:下次 buf generate 會整份覆蓋。要加行為就在自己的 struct 上包一層。
  • 生成的檔案要不要進版控? 我傾向 commit 進去,這樣 go build 不需要先安裝 protoc 工具鏈,IDE 與 AI 也能直接讀到型別;代價是每次改 proto 都會有一包 diff。反過來在 CI 生成則能確保不會忘記重跑,只是本地開發體驗差一些。

實作 Server

生成的 customerv1 套件會給一個 CustomerServiceServer 介面,Server 端要做的就是實作它:

package main
import (
"context"
"log"
"net"
customerv1 "github.com/riceball/example/gen/customer/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type customerServer struct {
customerv1.UnimplementedCustomerServiceServer
}
func (s *customerServer) GetCustomer(ctx context.Context, req *customerv1.GetCustomerRequest) (*customerv1.GetCustomerResponse, error) {
if req.GetId() == "" {
return nil, status.Error(codes.InvalidArgument, "id is required")
}
// 實務上這裡換成 DB 查詢
if req.GetId() != "123" {
return nil, status.Errorf(codes.NotFound, "customer %s not found", req.GetId())
}
return &customerv1.GetCustomerResponse{
Customer: &customerv1.Customer{
Id: "123",
FullName: "Riceball",
PhoneNumber: "0912345678",
},
}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
customerv1.RegisterCustomerServiceServer(s, &customerServer{})
log.Println("gRPC server listening on :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

除了前面提過的嵌入 UnimplementedCustomerServiceServer,這裡唯一新增的觀念是錯誤用 status 表達:gRPC 有自己的錯誤碼系統(codes.NotFoundcodes.InvalidArgumentcodes.DeadlineExceeded…),不存在塞在 response body 裡的自訂欄位,客戶端可以直接用 status.Code(err) 分辨。

實作 Client

Client 端不需要手寫任何 HTTP 請求或 JSON 解析,拿到的是一個型別安全的函式:

package main
import (
"context"
"log"
"time"
customerv1 "github.com/riceball/example/gen/customer/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
client := customerv1.NewCustomerServiceClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
res, err := client.GetCustomer(ctx, &customerv1.GetCustomerRequest{Id: "123"})
if err != nil {
log.Fatalf("GetCustomer failed: %v", err)
}
// PhoneNumber 是合約的一部分,不會因為 Order Service 忘記定義而消失
log.Println(res.GetCustomer().GetFullName(), res.GetCustomer().GetPhoneNumber())
}

insecure.NewCredentials() 只適合本機開發,正式環境要換成 credentials.NewTLS(...)

context.WithTimeout 在 gRPC 裡不只是本地端超時,deadline 會隨著請求以 grpc-timeout 這個 header 傳到 Server,只要 Server 把同一個 ctx 往下傳,下游服務也會共用剩餘時間,這點跟 REST 需要自己約定 header 很不一樣。

回頭看兩個痛點

Customer ServiceOrder ServiceClientCustomer ServiceOrder ServiceClient兩邊共用 customer.proto 生成的型別無需轉換命名直接使用 GetPhoneNumber()建立訂單,需要簡訊通知GetCustomer(id=123)Customer{id, full_name, email, phone_number}訂單建立成功,簡訊已發送

  • 痛點一(命名不同):命名由合約決定,各語言的生成器負責轉成當地慣例,不再需要人工對照表。
  • 痛點二(欄位缺漏):phone_number 存在於同一份 message,Order Service 拿到的就是完整結構;如果哪天 Customer Service 想刪掉這個欄位,buf breaking 會在 CI 就攔下來。

順帶解決一開始提到的另一件事:.proto 本身就是很好的 AI 上下文。與其把兩個儲存庫的程式碼全丟給模型讓它猜介面,直接給它一份幾十行的合約,它就知道有哪些服務、哪些方法、哪些欄位、哪些型別。

四種呼叫模式

gRPC 建立在 HTTP/2 上,除了常見的一問一答,還支援串流:

service CustomerService {
// 1. Unary:一個 request,一個 response
rpc GetCustomer(GetCustomerRequest) returns (GetCustomerResponse);
// 2. Server streaming:一個 request,多個 response(例如匯出、訂閱事件)
rpc ListCustomers(ListCustomersRequest) returns (stream ListCustomersResponse);
// 3. Client streaming:多個 request,一個 response(例如批次上傳)
rpc ImportCustomers(stream ImportCustomersRequest) returns (ImportCustomersResponse);
// 4. Bidirectional streaming:雙向同時進行(例如聊天、即時同步)
rpc SyncCustomers(stream SyncCustomersRequest) returns (stream SyncCustomersResponse);
}

多數內部服務溝通只會用到單向來回,但需要推送或大量資料串流時,不必再額外引進 WebSocket 或 SSE 這一層技術。

取捨

不是所有情境都適合 gRPC,實際導入前值得先確認以下限制:

面向gRPCREST + OpenAPI
傳輸格式Protobuf 二進位,體積小、解析快JSON 文字,人類可讀
型別安全由生成程式碼保證,編譯期發現問題靠 lint 或執行期驗證
瀏覽器支援需要 gRPC-Web(搭配 Envoy 這類代理)或改用 Connect 協定原生支援
除錯需要 grpcurlbuf curl 等工具curl 即可
對外開放生態較小,外部串接門檻高業界標準,文件工具成熟

通常是 內部服務之間用 gRPC,對外開放的 API 仍然維持 OpenAPI。中間可以用 grpc-gateway🔗 從同一份 .proto 產生 RESTful 端點與 OpenAPI 文件,這樣連對外文件都不必手寫維護。

除錯上少了 curl 有點不習慣,但 grpcurl 靠 Server Reflection 就能不帶 proto 檔案直接打,前提是 Server 有註冊反射服務(google.golang.org/grpc/reflectionreflection.Register(s),通常只在內網或開發環境開啟):

Terminal window
# 列出所有服務
grpcurl -plaintext localhost:50051 list
# 呼叫方法
grpcurl -plaintext -d '{"id": "123"}' localhost:50051 customer.v1.CustomerService/GetCustomer

總結

gRPC 將服務溝通都統整在一份合約內
  1. 服務之間對資料的定義只有一份,命名轉換與欄位缺漏這類低級錯誤消失了
  2. 破壞性變更由 buf breaking 在 CI 擋下,改資料定義不再依賴 Code Review
  3. .proto 成為人與 AI 共用的介面文件,協作變得精準又便宜

代價是多了一層程式碼生成的建置流程以及除錯工具要重新熟悉,對內部多服務的架構來說,是合理的取捨。

延伸閱讀