讓 .proto 當唯一契約來源:Go 單一 port 同時服務 gRPC 與 HTTP/JSON 的做法與代價

幾年前我和同事討論即時通訊要用什麼格式,兩個人都對 protobuf 很反感,最後決定內部用 msgpack、對瀏覽器維持 JSON。後來我自己寫了一個 contract-first 的 gRPC 框架 gortexa,以 .proto 作為 single source of truth(SSOT),同一個 port 同時服務 gRPC、HTTP/JSON 和 MCP。

讓我改觀的是契約只有一份:型別、HTTP 路由、AI agent 能呼叫的工具都從同一個 .proto 生出來,這才是 gRPC-first 對我最有用的地方,二進位編碼比 JSON 快只是附帶的好處。共用 port 也得付代價,grpc.Server.ServeHTTP 是 Experimental API,三種協定共用同一組 http.Server timeout,錯誤語意也不會因為有了 .proto 就自動設計好。下面的最小範例在 Go 1.27.2、grpc-go v1.84.0 跑過,後面也會談到我 review gortexa 時找到的問題。

一、當年為什麼嫌棄 protobuf

當時業務邏輯變得快,每改一個欄位就要改 .proto、重新產生程式碼,再改程式;封包是二進位,抓下來不能直接看,除錯比 JSON 麻煩,瀏覽器也沒辦法直接講 gRPC。.proto 不在同一處維護時更煩,手上那份常常沒有及時更新,照著舊的寫完,才發現欄位已經改了,整段白做。

於是內部服務之間用了和 Redis 好整合的 msgpack,對瀏覽器維持 JSON,沒有 schema,也就沒有同步 schema 的負擔。

封包是二進位、瀏覽器不能直接講 gRPC,這些格式和協定的限制到今天都還在,只能靠工具和 gateway 補。改欄位的流程繁瑣、.proto 沒同步,原因卻是當時只把 .proto 當成「另一份文件」:程式、.proto 和給前端的 API 文件分開維護,漏了同步就得靠人追查。契約只要有兩份,遲早會不同步,白做的工就是這樣來的,後來我讓 .proto 成為契約來源,再由它產生各個入口需要的程式碼,處理的就是這一類問題。

二、契約只有一份,其他都從它產生

〈跨邊界的語意流失,與 monolith 一直有吸引力的原因〉提到,資料跨過服務邊界之後只剩 bytes,原本由型別系統替你核對的語意,得靠契約補回來。那篇沒有展開契約要怎麼補,這篇算是延伸。

契約能補回多少語意,取決於它離程式碼多近。OpenAPI 也能產生程式碼,文件若靠人工和程式碼同步,照樣容易落後實作。以契約為唯一來源、兩邊的型別都從同一個檔案產生,欄位改名後重新產生程式碼,編譯器會指出因此失效的欄位存取,改型別則要看產生的 Go 型別有沒有跟著變(int32 改成 sint32 就看不出來),一部分跨服務的型別錯誤也就查得出來。

但另一端有沒有跟著重新產生和部署,編譯器管不到,這仍然要靠版本規則和相容性檢查。

gortexa 的做法是讓同一份 .proto 生出三種入口:

  • gRPC:protoc-gen-go 與 protoc-gen-go-grpc 產生的型別與 service 介面。
  • HTTP/JSON:grpc-gateway 讀 google.api.http annotation,產生把 RESTful JSON 轉成 gRPC 呼叫的 reverse proxy。
  • MCP:用 gortexa.ai.v1 的 annotation 標記哪些 RPC 要開放成 AI agent 的工具,tool call 和其他請求走同一條 interceptor 鏈。

依 gortexa 的文件,gateway 和 MCP bridge 都經由 in-process loopback 回到 gRPC server,三種入口的 RPC 呼叫因此會走同一條 interceptor 鏈,也共用錯誤模型。把入口放在同一個 port,是想集中處理這些請求,auth 和 observability 能不能共用,還得看每個入口有沒有接回這條流程,後面 tools/list 的例子就漏了。

我之前的 gortex 走另一條路,用 struct tag 宣告路由,契約來源是 Go 程式本身,兩個框架名字很像,設計方向不同。

ConnectRPC 也是同類的選擇,它在 2024 年 4 月進入 CNCF Sandbox。connect-go 的 server 預設就接受 Connect、gRPC、gRPC-Web 三種協定,Connect 協定的 unary 與單向 streaming 可以跑在 HTTP/1.1 上(bidi 仍需 HTTP/2),同樣是一份 .proto 對應多種協定。

三、一個 port 依 content-type 分流

grpc-go 的 *grpc.Server 實作了 http.Handler,可以掛在標準函式庫的 http.Server 底下。它的文件直接給了分流的寫法:HTTP/2 且 Content-Type 以 application/grpc 開頭的交給 gRPC,其他交給一般的 mux。

明文 HTTP/2(h2c)以前要用 golang.org/x/net/http2/h2c 包一層,Go 1.24 起 http.Server 有 Protocols 欄位,打開 UnencryptedHTTP2 就行,gortexa 用的也是這個。範例直接拿 grpc-go 內建的 health service 當 gRPC 服務,省去跑 protoc 的步驟。JSON 端點呼叫同一個 health 實作,只示範分流,不會經過 gRPC server 的 interceptor;gortexa 採用的 grpc-gateway 接法則經由 gRPC 呼叫轉送,兩者不同:

package main

import (
	"encoding/json"
	"errors"
	"flag"
	"log"
	"net/http"
	"strings"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/health"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/reflection"
)

func main() {
	readTimeout := flag.Duration("read-timeout", 2*time.Second, "http.Server.ReadTimeout")
	writeTimeout := flag.Duration("write-timeout", 0, "http.Server.WriteTimeout")
	flag.Parse()

	hs := health.NewServer()
	hs.SetServingStatus("demo.Orders", healthpb.HealthCheckResponse_SERVING)

	gs := grpc.NewServer()
	healthpb.RegisterHealthServer(gs, hs)
	reflection.Register(gs) // ServerReflectionInfo 是 bidi stream,拿來示範 ReadTimeout

	// HTTP/JSON 端點直接呼叫同一個 health 實作,不另外維護一套狀態
	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/health/{service}", func(w http.ResponseWriter, r *http.Request) {
		resp, err := hs.Check(r.Context(), &healthpb.HealthCheckRequest{Service: r.PathValue("service")})
		w.Header().Set("Content-Type", "application/json")
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
			return
		}
		_ = json.NewEncoder(w).Encode(map[string]string{
			"status": resp.GetStatus().String(),
			"proto":  r.Proto,
		})
	})

	root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
			gs.ServeHTTP(w, r)
			return
		}
		mux.ServeHTTP(w, r)
	})

	var protocols http.Protocols
	protocols.SetHTTP1(true)
	protocols.SetUnencryptedHTTP2(true)

	srv := &http.Server{
		Addr:              "127.0.0.1:8080",
		Handler:           root,
		Protocols:         &protocols,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       *readTimeout,  // 刻意設短,看它對 stream 的影響
		WriteTimeout:      *writeTimeout, // 預設 0,設了會切斷長時間的 server streaming
	}
	log.Printf("listening on %s (HTTP/1.1 + h2c)", srv.Addr)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatalf("serve: %v", err)
	}
}

用 curl 打 JSON 端點,HTTP/1.1 和 h2c 都能進來:

$ curl -s http://127.0.0.1:8080/v1/health/demo.Orders
{"proto":"HTTP/1.1","status":"SERVING"}
$ curl -s --http2-prior-knowledge http://127.0.0.1:8080/v1/health/demo.Orders
{"proto":"HTTP/2.0","status":"SERVING"}
$ curl -s http://127.0.0.1:8080/v1/health/demo.Payments
{"error":"rpc error: code = NotFound desc = unknown service"}

gRPC client 打同一個 port,依序測 unary、server streaming 和 bidi streaming,後兩個刻意拖過 server 的 2 秒 timeout:

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	refpb "google.golang.org/grpc/reflection/grpc_reflection_v1"
	"google.golang.org/grpc/status"
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	conn, err := grpc.NewClient("127.0.0.1:8080", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		return fmt.Errorf("new client: %w", err)
	}
	defer conn.Close()

	hc := healthpb.NewHealthClient(conn)

	// 1. unary
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	resp, err := hc.Check(ctx, &healthpb.HealthCheckRequest{Service: "demo.Orders"})
	if err != nil {
		return fmt.Errorf("check: %w", err)
	}
	fmt.Println("[unary] Check:", resp.GetStatus())

	// 2. server streaming:3 秒後由用戶端自己取消,超過 server 的 2 秒 timeout
	wctx, wcancel := context.WithCancel(context.Background())
	defer wcancel()
	time.AfterFunc(3*time.Second, wcancel)
	ws, err := hc.Watch(wctx, &healthpb.HealthCheckRequest{Service: "demo.Orders"})
	if err != nil {
		return fmt.Errorf("watch: %w", err)
	}
	start := time.Now()
	for {
		m, err := ws.Recv()
		if err != nil {
			fmt.Printf("[server-stream] Watch ended after %.1fs: %s\n", time.Since(start).Seconds(), status.Code(err))
			break
		}
		fmt.Println("[server-stream] Watch:", m.GetStatus())
	}

	// 3. bidi streaming:第二則訊息在 3 秒後才送
	rc := refpb.NewServerReflectionClient(conn)
	bctx, bcancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer bcancel()
	bs, err := rc.ServerReflectionInfo(bctx)
	if err != nil {
		return fmt.Errorf("reflection stream: %w", err)
	}
	start = time.Now()
	for i := 1; i <= 2; i++ {
		if i == 2 {
			time.Sleep(3 * time.Second)
		}
		req := &refpb.ServerReflectionRequest{
			MessageRequest: &refpb.ServerReflectionRequest_ListServices{ListServices: ""},
		}
		if err := bs.Send(req); err != nil {
			fmt.Printf("[bidi] send #%d at %.1fs: %v\n", i, time.Since(start).Seconds(), err)
		}
		r, err := bs.Recv()
		if err != nil {
			fmt.Printf("[bidi] recv #%d at %.1fs: %s: %s\n", i, time.Since(start).Seconds(), status.Code(err), status.Convert(err).Message())
			return nil
		}
		fmt.Printf("[bidi] recv #%d at %.1fs: %d services\n", i, time.Since(start).Seconds(), len(r.GetListServicesResponse().GetService()))
	}
	return nil
}

輸出留到下一節和 timeout 一起看,這裡先交代 ServeHTTP 的限制。grpc-go 的原始碼註解說明,ServeHTTP 用的是 Go 標準函式庫的 HTTP/2 實作,和 grpc-go 自己的 HTTP/2 server 完全分開,效能與功能可能不同,有些 gRPC 功能也只有 grpc-go 自己的 server 支援。整個 API 標成 EXPERIMENTAL,之後可能修改或移除。

gortexa 的 README 也寫了 handler 模式共用 port 的取捨:外部 gRPC 走 ServeHTTP,部分 transport 行為和專用的 gRPC port 不同,需要完整 grpc-go 語意的話,可以改用 cmux。

cmux 一樣只開一個 port,在連線建立時看前面幾個 bytes,把 gRPC 連線交給 grpc-go 自己的 server、HTTP 連線交給 http.Server。分流以連線為單位,同一條連線就不能混用兩種協定,TLS 也要另外處理(cmux README 提到 handler 拿不到 http.Request.TLS)。兩種做法對外都只有一個 port,前面的 load balancer 和 ingress 設定只要一份。

四、三種協定共用一組 timeout

共用一個 http.Server,代表 gRPC、JSON、MCP 共用同一組 ReadTimeout 和 WriteTimeout,對一般 HTTP 合理的值,放到 streaming RPC 上就會出事。

範例 server 預設 ReadTimeout 2 秒、WriteTimeout 0,跑上一節的 client:

### server (ReadTimeout=2s, WriteTimeout=0)
[unary] Check: SERVING
[server-stream] Watch: SERVING
[server-stream] Watch ended after 3.0s: Canceled
[bidi] recv #1 at 0.0s: 3 services
[bidi] send #2 at 3.0s: EOF
[bidi] recv #2 at 3.0s: Unavailable: i/o timeout

Watch 一直開到 client 自己在第 3 秒取消,bidi 的第二則訊息卻送不進去,stream 以 Unavailable: i/o timeout 結束。net/http 文件把 ReadTimeout 定義為「讀完整個請求(包含 body)的最長時間」,HTTP/2 下每個 stream 都從開始時各自計時,時間到就關掉 request body。

client-streaming 和 bidi 的 request body 是 client 陸續送來的訊息,stream 開得比 ReadTimeout 久,後面的訊息就讀不到。Watch 這類 server streaming 只送一則請求就結束 request body,不受影響。

ReadTimeout 維持 2 秒,再把 WriteTimeout 設成 2 秒,server streaming 也被截斷:

### server -write-timeout=2s
[unary] Check: SERVING
[server-stream] Watch: SERVING
[server-stream] Watch ended after 2.0s: Internal
[bidi] recv #1 at 0.0s: 3 services
[bidi] send #2 at 3.0s: EOF
[bidi] recv #2 at 3.0s: Internal: stream terminated by RST_STREAM with error code: INTERNAL_ERROR

把 ReadTimeout 設成 0,bidi 才完整跑完:

### server -read-timeout=0
[unary] Check: SERVING
[server-stream] Watch: SERVING
[server-stream] Watch ended after 3.0s: Canceled
[bidi] recv #1 at 0.0s: 3 services
[bidi] recv #2 at 3.0s: 3 services

我 review gortexa 時也遇到這個問題。共用的 h2c server 已經把 WriteTimeout 設成 0,保護 Health.Watch 和 SSE 這類長連線,read 那一側卻還留著 15 秒的 ReadTimeout,client-streaming 和 bidi 開超過 15 秒就會被截斷,這裡原本漏掉了。

可以把 ReadTimeout 設 0,只留 ReadHeaderTimeout 限制 HTTP/1.x header 與 h2c 連線初期的讀取時間,body 的 deadline 交給各個 handler 處理。也可以保留它,在分流處針對 gRPC 請求,用 http.ResponseController 清掉這個 stream 的 read deadline:

if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
	if err := http.NewResponseController(w).SetReadDeadline(time.Time{}); err != nil {
		log.Printf("clear read deadline: %v", err)
	}
	gs.ServeHTTP(w, r)
	return
}

在 2 秒 ReadTimeout 的 server 加上這段再跑,bidi 第 3 秒的訊息就收得到([bidi] recv #2 at 3.0s: 3 services),JSON 端點仍保有原本的 timeout。清掉 deadline 之後,stream 能開多久,得由 RPC deadline 和 server 自己的限制決定。keepalive 只偵測連線是否還有回應,連線正常時不會限制 stream 的存活時間,client 不帶 deadline,server 就得自己設上限。

同一次 review,還有兩個地方的入口檢查沒對齊:

  • MCP bridge 讀 body 時有用 io.LimitReader 設上限,gateway 的 JSON 入口卻沒有,超大的 JSON body 會整個讀進記憶體。http.MaxBytesReader 適合限制 JSON body,但不能直接包在分流前面,因為 gRPC stream 的 request body 包含整條 stream 累計的訊息。在範例 server 的分流 handler 開頭加上 r.Body = http.MaxBytesReader(w, r.Body, 32),再讓 client 在同一條 bidi stream 連續送 ListServices,每則請求連 gRPC frame 是 7 bytes,前 4 則正常,第 5 則就以 Unavailable: http: request body too large 結束。gRPC 這邊要用 grpc.MaxRecvMsgSize(預設 4 MB)限制單則訊息,整條 stream 的累計量另外在 handler 裡管。
  • MCP 的 tools/list 不帶 auth 也會列出所有工具,tools/call 才把 Authorization 帶進 interceptor 鏈。呼叫本身受保護,可是工具清單等於公開了這台服務能做什麼。gRPC 的 reflection service 也有一樣的問題,正式環境要嘛關掉,要嘛一樣要求驗證。

這些問題在後來一輪 production hardening 裡處理掉了:ReadTimeout 截斷長連線和 gateway JSON body 沒有上限都已修正,ServeHTTP 的限制寫進了文件;gRPC reflection 改成設定開關、預設關閉,outgoing header 也改成只放行 X-Request-Id。tools/list 不帶 auth 也會列出工具這點沒改,文件照實寫明這是現在的行為,auth 在 tools/call 才檢查。

五、把 proto 開放給別人 import

框架自己用的時候,.proto 的 package 名稱隨便取都沒事,開放成 public API 之後就不行了。

Go 的 protobuf runtime 有一份全域 registry。protobuf 官方 FAQ 說,每個宣告(message、enum 等)都有完整名稱,同一支程式裡兩個宣告的完整名稱相同就是 namespace conflict,從 google.golang.org/protobuf v1.26.0 起,啟動時就會直接報錯。protoregistry 的原始碼還會檢查 .proto 的檔案路徑,兩個不同的檔案用了同一個路徑,在全域 registry 也會報 file ... is already registered。

package 同名本身不算衝突,宣告名稱或檔案路徑重複才算,只是 package 名稱越通用,就越容易撞名。gortexa 原本的 MCP annotation 放在 ai/v1,沒有 vendor 前綴,使用框架的專案若也有 ai/v1 底下同名的檔案或宣告,兩邊一起連結就會在 init 時 panic。FAQ 建議每個 .proto 都選全域唯一的 package 名稱,例如加上公司或專案名稱。

於是 ai/v1 改成了 gortexa/ai/v1。FAQ 也提醒,事後改 package 名稱對 extension、google.protobuf.Any 和 gRPC service 都不向後相容。annotation 是 extension,二進位編碼只看欄位編號,只要編號、被擴充的 options 訊息和欄位型別都沒變,舊程式產生的 descriptor 裡的 option 仍然解得出來。編號維持不變,再用測試釘住,避免之後有人不小心改掉;引用舊 import 路徑和舊名稱的程式還是得改,二進位相容不會連原始碼相容都一起解決。

後來也把 gen/ 底下產生的程式碼全部 commit 進 repo。Go Modules Reference 寫明 go mod tidy 會載入主模組的所有套件、它們 import 的套件,以及這些套件的測試(包括其他模組的測試)。使用框架的專案 import gortexa 的套件時,go mod tidy 會一路解析到 gen/ 底下的套件,產生的程式碼不在 repo 裡,解析就失敗。當時的單元測試沒有抓到這個問題,是我在範例沙盒裡站在使用者的角度,另建一個 scratch module import gortexa,跑 go mod tidy、build 和 vet 才發現的。

產生的程式碼進版控後,還得防 .proto 改了卻忘記重新產生。CI 重新產生一次再比對,但 git diff --exit-code 只看已追蹤的檔案,新增的檔案會漏掉:

$ echo b > gen/b.pb.go
$ git diff --exit-code -- gen; echo "git diff exit=$?"
git diff exit=0
$ git status --porcelain -- gen
?? gen/b.pb.go

在既有 service 加 RPC,通常只會改到既有的產出檔,git diff 抓得到;新增 .proto 檔才會多出新檔案,git diff 就漏了。gortexa 的 CI 改用 git status --porcelain -- gen,兩種情況都會列出來,輸出不是空的就失敗。

公開產生的程式碼,也得留意 Protobuf Edition 2024 的變化。Go 官方部落格在 2024 年 12 月介紹了 Opaque API,struct 欄位不再公開,只能透過 accessor 存取;protobuf 文件寫明 Edition 2024 的 api_level 預設是 API_OPAQUE,Edition 2023 是 API_OPEN,中間的 API_HYBRID 同時保留公開欄位和 accessor,供逐步遷移使用。這類專案升 edition,會直接影響使用者的程式碼。

六、錯誤語意還得自己設計

之後我拿 gortexa 做一個遊戲商系統的 prototype,實際只用了 kernel、health 和 observability 三個套件,錯誤處理最後是自己寫的。

gortexa 的錯誤碼 registry 規定每個 gRPC code 只能對應一個 category,gRPC、HTTP、MCP 三種錯誤格式因此能從同一張表轉換。業務錯誤卻常在同一個 gRPC code 底下分好幾種,例如餘額不足和訂單狀態不對都可以是 FailedPrecondition,呼叫端得分得出來,這類自訂錯誤碼卻登記不進去。Recovery interceptor 還會把錯誤正規化,就算 handler 回傳了帶自訂碼的錯誤,經過它之後也被抹掉了。

我最後自己寫了 interceptor,加上一個實作 GRPCStatus() 的 domain error 型別,把業務錯誤碼放進 google.rpc.ErrorInfo:

package main

import (
	"errors"
	"fmt"

	"google.golang.org/genproto/googleapis/rpc/errdetails"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// DomainError 帶業務自己的錯誤碼,轉成 gRPC status 時放進 ErrorInfo。
type DomainError struct {
	Code   codes.Code
	Reason string // 業務錯誤碼,例如 INSUFFICIENT_BALANCE
	Msg    string
	Meta   map[string]string
}

func (e *DomainError) Error() string { return e.Reason + ": " + e.Msg }

// GRPCStatus 讓 status.FromError 直接拿到帶 details 的 status。
func (e *DomainError) GRPCStatus() *status.Status {
	st := status.New(e.Code, e.Msg)
	withInfo, err := st.WithDetails(&errdetails.ErrorInfo{
		Reason:   e.Reason,
		Domain:   "wallet.example.com",
		Metadata: e.Meta,
	})
	if err != nil {
		return st
	}
	return withInfo
}

func debit() error {
	de := &DomainError{
		Code:   codes.FailedPrecondition,
		Reason: "INSUFFICIENT_BALANCE",
		Msg:    "balance too low",
		Meta:   map[string]string{"required": "500", "available": "120"},
	}
	return fmt.Errorf("debit order 42: %w", de)
}

func main() {
	err := debit()
	st, ok := status.FromError(err)
	fmt.Println("ok:", ok)
	fmt.Println("code:", st.Code())
	fmt.Println("message:", st.Message())
	for _, d := range st.Details() {
		if info, ok := d.(*errdetails.ErrorInfo); ok {
			fmt.Println("reason:", info.GetReason(), "domain:", info.GetDomain(), "metadata:", info.GetMetadata())
		}
	}
	var de *DomainError
	fmt.Println("errors.As:", errors.As(err, &de))
}
ok: true
code: FailedPrecondition
message: debit order 42: INSUFFICIENT_BALANCE: balance too low
reason: INSUFFICIENT_BALANCE domain: wallet.example.com metadata: map[available:120 required:500]
errors.As: true

用 %w 包過一層之後,status.FromError 還是找得到 GRPCStatus(),code 和 ErrorInfo 都保留下來。message 則會變成整串 err.Error(),grpc-go 文件寫明包裝過的錯誤會回傳完整文字,內部錯誤細節可能跟著送到 client,對外送哪一段得在 interceptor 裡決定。

ErrorInfo 是 Google AIP-193 定義的標準欄位:reason 是 UPPER_SNAKE_CASE 的機器可讀識別碼,和 domain 一起唯一識別一種錯誤,而且要長期穩定,動態資訊放 metadata。gRPC 文件列出 C++、Go、Java、Python、Ruby 的函式庫支援這種 richer error model,但各語言的實作不一定一致,一般的 proxy 和 log 工具也看不到 details,接之前得確認呼叫端用的函式庫。

.proto 能描述請求和回應長什麼樣子,沒有內建宣告錯誤的語法,哪些錯誤會發生、呼叫端該怎麼處理,得另外在註解或自訂 option 裡約定。框架提供的錯誤模型只能當預設,業務自己的錯誤分類仍要自己設計,也得確認中間的 interceptor 沒把它抹掉。

補充筆記

  • 當年改欄位流程繁瑣、.proto 不同步,都和契約有好幾份有關。讓 .proto 當唯一來源、由它產生程式碼,能少做些人工同步;二進位難直接讀、瀏覽器不能直接講 gRPC,這些格式和協定的限制,SSOT 解決不了。
  • grpc.Server.ServeHTTP 能讓 gRPC 和一般 HTTP 共用 port,Go 1.24 起 http.Server.Protocols 可以直接開 h2c,代價是 ServeHTTP 是 Experimental,部分功能和 grpc-go 自己的 server 不同;cmux 以連線為單位分流,是另一種單 port 做法。
  • 共用 http.Server 就共用 timeout:ReadTimeout 會截斷 client-streaming 與 bidi,WriteTimeout 會截斷 server streaming,可以在分流處用 ResponseController 清掉 gRPC 請求的 read deadline。
  • body 上限和 auth 要每個入口都檢查;MaxBytesReader 套在 gRPC stream 上會限制整條 stream 的累計量,gRPC 用 MaxRecvMsgSize 限制單則訊息;MCP 的 tools/list 和 gRPC reflection 都會洩漏服務能做什麼。
  • 開放 .proto 給別人 import,宣告完整名稱或檔案路徑重複都會在 init 時 panic,package 名稱要加 vendor 前綴,extension 編號用測試釘住;產生的程式碼納入版控,CI 用 git status --porcelain 檢查重新產生後有沒有差異。
  • .proto 定義了資料結構,錯誤語意得另外設計。業務錯誤碼用實作 GRPCStatus() 的 domain error 搭配 ErrorInfo,並確認 interceptor 不會把它正規化掉。

延伸閱讀

  • gortexa 與文件站:本文提到的 contract-first 框架,README 有單一 h2c port、ServeHTTP 取捨與 cmux 替代方案的說明。
  • soheilhy/cmux:依連線開頭的 payload 在同一個 port 分流給不同 server,README 列了 TLS 與混用協定的限制。
  • grpc-go〈server.go ServeHTTP 原始碼註解〉:分流範例、和 grpc-go 自有 HTTP/2 server 的差異,以及 EXPERIMENTAL 標記的原文。
  • Go Modules Reference〈go mod tidy〉:tidy 會載入其他模組套件的測試依賴。
  • Protocol Buffers〈Go FAQ〉:namespace conflict 的定義(完整名稱重複)、從 v1.26.0 起成為啟動錯誤,以及 package 名稱要全域唯一、事後改名不相容的說明。
  • gRPC〈Keepalive〉與〈Error handling〉:keepalive 用來維持與偵測連線,以及 richer error model 各語言支援程度與限制。
  • Protocol Buffers〈Feature Settings for Editions〉與 Go 部落格〈Go Protobuf: The new Opaque API〉:api_level 在 Edition 2024 預設為 API_OPAQUE,以及 Hybrid API 的遷移步驟。
  • Google AIP〈AIP-193: Errors〉:ErrorInfo 的 reason、domain、metadata 規則。
  • grpc-gateway:從 gRPC service 定義產生 RESTful JSON reverse proxy 的 protoc plugin。
  • ConnectRPC〈Getting started with connect-go〉與 CNCF 專案頁:同一個 handler 接受 Connect、gRPC、gRPC-Web,2024 年 4 月 13 日進入 CNCF Sandbox。

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Go 1.27.2、grpc-go v1.84.0 實測。