前言
AI 開發大多時候只要描述清楚目標,細部實踐通常不是問題,但有個問題一直困擾我很久:「跨服務之間溝通正確性」和「提供合理上下文給 AI 協作」,我發現跨服務時花很多時間在手動規劃提供兩個服務之間的程式碼,塞合適的上下文給 AI。
舉例最近在處理多個服務之間溝通的時候最麻煩的是對於同一筆資料有不同的描述:
痛點一:服務間對同筆資料定義名稱不同
兩邊都在講「同一個客戶」,但欄位命名不一致(userId vs customer_id)
痛點二:資料欄位缺漏導致問題
gRPC 解方
上面兩個痛點的共同根因是:同一筆資料定義散落在各個服務的程式碼裡,命名差異與欄位缺漏都只能靠人工比對文件發現,通常是等到上線後才炸開。
gRPC 的做法是把「介面」從程式碼裡抽出來,變成一份與語言無關的合約檔案 .proto,再由工具產生各語言的程式碼鞏固型別(細節底下章節會說):
因為合約只有一份,服務之間不可能再對「資料定義」有分歧,且因為程式碼是根據合約生成的,欄位改名或新增欄位會直接讓沒跟上的一方編譯或型別檢查失敗,而不是執行時才出問題。
定義合約
Protocol Buffers(Protobuf)是 gRPC 預設的介面定義語言(IDL)與序列化格式。把前兩個服務對於資料的定義寫成合約:
// 指定使用 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。也就是說痛點一的userIdvscustomer_id之爭,在合約層根本不存在,各語言拿到的都是自己習慣的寫法。
產生程式碼
官方原生工具是 protoc,但參數與 include path 很難維護,實務上推薦 buf:
version: v2modules: - path: protolint: use: - DEFAULTbreaking: use: - FILEversion: v2plugins: - remote: buf.build/protocolbuffers/go out: gen opt: paths=source_relative - remote: buf.build/grpc/go out: gen opt: paths=source_relative# 產生程式碼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:
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() 不會 panicfunc (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 端:連呼叫都幫你寫好
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 端:只生成骨架,邏輯自己寫
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,呼叫你的方法,再把回傳值序列化出去。
所以兩邊的分工是:
- 絕對不要手改
.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.NotFound、codes.InvalidArgument、codes.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 很不一樣。
回頭看兩個痛點
- 痛點一(命名不同):命名由合約決定,各語言的生成器負責轉成當地慣例,不再需要人工對照表。
- 痛點二(欄位缺漏):
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,實際導入前值得先確認以下限制:
| 面向 | gRPC | REST + OpenAPI |
|---|---|---|
| 傳輸格式 | Protobuf 二進位,體積小、解析快 | JSON 文字,人類可讀 |
| 型別安全 | 由生成程式碼保證,編譯期發現問題 | 靠 lint 或執行期驗證 |
| 瀏覽器支援 | 需要 gRPC-Web(搭配 Envoy 這類代理)或改用 Connect 協定 | 原生支援 |
| 除錯 | 需要 grpcurl、buf curl 等工具 | curl 即可 |
| 對外開放 | 生態較小,外部串接門檻高 | 業界標準,文件工具成熟 |
通常是 內部服務之間用 gRPC,對外開放的 API 仍然維持 OpenAPI。中間可以用 grpc-gateway 從同一份 .proto 產生 RESTful 端點與 OpenAPI 文件,這樣連對外文件都不必手寫維護。
除錯上少了 curl 有點不習慣,但 grpcurl 靠 Server Reflection 就能不帶 proto 檔案直接打,前提是 Server 有註冊反射服務(google.golang.org/grpc/reflection 的 reflection.Register(s),通常只在內網或開發環境開啟):
# 列出所有服務grpcurl -plaintext localhost:50051 list
# 呼叫方法grpcurl -plaintext -d '{"id": "123"}' localhost:50051 customer.v1.CustomerService/GetCustomer總結
gRPC 將服務溝通都統整在一份合約內
- 服務之間對資料的定義只有一份,命名轉換與欄位缺漏這類低級錯誤消失了
- 破壞性變更由
buf breaking在 CI 擋下,改資料定義不再依賴 Code Review .proto成為人與 AI 共用的介面文件,協作變得精準又便宜
代價是多了一層程式碼生成的建置流程以及除錯工具要重新熟悉,對內部多服務的架構來說,是合理的取捨。
延伸閱讀
- gRPC 官方文件
- Protocol Buffers Language Guide (proto 3)
- Buf Documentation
- gRPC-Go Basics Tutorial
- Easily Understanding gRPC - Coding with Yalco