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

> 讓 .proto 成為唯一契約來源，型別與各個入口都從它產生。本文用 Go 範例示範 gRPC 與 HTTP/JSON 共用 h2c port 和共用 timeout 的代價，並以 gortexa 說明 MCP 的接法。

原文：https://sheng.page/posts/protobuf-as-single-source-of-truth/ · 發布：2026-10-21 · 作者：Sheng

幾年前我和同事討論即時通訊要用什麼格式，兩個人都對 protobuf 很反感，最後決定內部用 msgpack、對瀏覽器維持 JSON。後來我自己寫了一個 contract-first 的 gRPC 框架 [gortexa](https://github.com/yshengliao/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 一直有吸引力的原因](/posts/semantics-across-boundaries-and-the-monolith/)〉提到，資料跨過服務邊界之後只剩 bytes，原本由型別系統替你核對的語意，得靠契約補回來。那篇沒有展開契約要怎麼補，這篇算是延伸。

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

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

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

- gRPC：`protoc-gen-go` 與 `protoc-gen-go-grpc` 產生的型別與 service 介面。
- HTTP/JSON：[grpc-gateway](https://grpc-ecosystem.github.io/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](/posts/gortex-why-kitchen-sink-framework/) 走另一條路，用 [struct tag 宣告路由](/posts/gortex-struct-tag-routing/)，契約來源是 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 呼叫轉送，兩者不同：

```go
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 都能進來：

```text
$ 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：

```go
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：

```text
### 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 也被截斷：

```text
### 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 才完整跑完：

```text
### 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：

```go
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` 只看已追蹤的檔案，新增的檔案會漏掉：

```text
$ 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`：

```go
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))
}
```

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

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