讓 .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.httpannotation,產生把 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 實測。