聊聊為什麼 IDL 只能擴充套件欄位而非修改
前幾年業界流行使用 thrift, 比如滴滴。這幾年 grpc 越來越流行,很多開源框架也集成了,我司大部分服務都同時開放 grpc 和 http 介面
相比於傳統的 http1 + json 組合,這兩種技術都用到了 IDL, 即 Interface description language
介面描述語言,相當於增加了 endpoint schema 約束,不同語言只需要一份相同的 IDL 檔案即可生成介面程式碼。
很多人喜歡問:proto buf 與 json 比起來有哪些優勢?比較經典的面試題
IDL 檔案管理每個公司不一樣,有的儲存在單獨 gitlab 庫,有的是 mono repo 大倉庫。當業務變更時,IDL 檔案經常需要修改,很多新手總是容易踩坑,本文聊聊 grpc proto 變更時的相容問題,核心只有一條: 對擴充套件開放,對修改關閉,永遠只增加欄位而不修改
測試修改相容性
本文測試使用 grpc-go example 官方用例,感興趣自查
syntax = "proto3";
option go_package = "google.golang.org/grpc/examples/helloworld/helloworld";
package helloworld;
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
string additional = 2;
int32 age = 3;
int64 id = 4;
}
每次修改後使用 protoc
重新生成程式碼
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative helloworld/helloworld.proto
Server 每次接受請求後,返回 HelloReply
結構體
// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", in.GetName())
return &pb.HelloReply{
Message: "Hello addidional " + in.GetName(),
Additional: "this is addidional field",
Age: 10,
Id: 12345,
}, nil
}
Client 每次只打印 Server 返回的結果
修改欄位編號
將 HelloReply
結構體欄位 age
編號變成 12, 然後 server 使用新生成的 IDL 庫,client 使用舊版本
zerun.dong$ ./greeter_client
......
2021/12/08 22:23:38 Greeting: {
"message": "Hello addidional world",
"additional": "this is addidional field",
"id": 12345
}
可以看到 client 沒有讀到 age 欄位, 因為 IDL 是根據序號傳輸的,client 讀不到 seq 3, 所以修改序號不相容
修改欄位 name
修改 HelloReploy
欄位 id
, 變成 score
型別和序號不變
// The response message containing the greetings
message HelloReply {
string message = 1;
string additional = 2;
int32 age = 3;
int64 score = 4;
}
重新編譯 server, 並用舊版本 client 訪問
zerun.dong$ ./greeter_client
......
2021/12/08 22:29:18 Greeting: {
"message": "Hello addidional world",
"additional": "this is addidional field",
"age": 10,
"id": 12345
}
可以看到,雖然修改了欄位名,但是 client 仍然讀到了正確的值 12345, 如果欄位含義不變,那麼只修改名稱是相容的
修改型別
有些型別是相容的,有些不可以,而且還要考慮不同的語言。這裡測試三種
1.字串與位元組陣列
// The response message containing the greetings
message HelloReply {
string message = 1;
bytes additional = 2;
int32 age = 3;
int64 id = 4;
}
我們將 additional
欄位由 string 型別修改為 bytes
// The response message containing the greetings
type HelloReply struct {
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
Additional []byte `protobuf:"bytes,2,opt,name=additional,proto3" json:"additional,omitempty"`
Age int32 `protobuf:"varint,3,opt,name=age,proto3" json:"age,omitempty"`
Id int64 `protobuf:"varint,4,opt,name=id,proto3" json:"id,omitempty"`
}
可以看到 go 結構體由 string 變成了 []byte, 我們知道這兩個其實可以互換
zerun.dong$ ./greeter_client
......
2021/12/08 22:35:43 Greeting: {
"message": "Hello addidional world",
"additional": "this is addidional field",
"age": 10,
"id": 12345
}
最後結果也證明 client 可以正確的處理資料,即 修改成相容型別沒有任何問題
2.int32 int64 互轉
message HelloReply {
string message = 1;
string additional = 2;
int64 age = 3;
int64 id = 4;
}
這裡我們將 age
由 int32 修改成 int64 欄位,位數不一樣,如果同樣小於 int32 最大值沒有問題,此時我們在 server 端將 age 賦於 2147483647 + 1 剛好超過最大值
zerun.dong$ ./greeter_client
......
2021/12/08 22:43:32 Greeting: {
"message": "Hello addidional world",
"additional": "this is addidional field",
"age": -2147483648,
"id": 12345
}
我們可以看到 age
變成了負數,如果業務剛好允許負值,那麼此時一定會出邏輯問題,而且難以排查 bug, 這其實是非常典型的向上向下相容問題
3.非相容型別互轉
message HelloReply {
string message = 1;
string additional = 2;
string age = 3;
int64 id = 4;
}
我們將 age
由 int32 變成 string 字串,依舊使用 client 舊版本測試
zerun.dong$ ./greeter_client
......
2021/12/08 22:55:21 Greeting: {
"message": "Hello addidional world",
"additional": "this is addidional field",
"id": 12345
}
2021/12/08 22:55:21 message:"Hello addidional world" additional:"this is addidional field" id:12345 3:"this is age"
2021/12/08 22:57:56 r.Age is 0
可以看到結構體 json 序列化列印時不存在 Age
欄位,但是 log 列印時發現了不相容的 3:"this is age"
, 注意 grpc 會保留不相容的資料
同時 r.Age
預設是 0 值,即
非相容型別修改是有問題的
刪除欄位
message HelloReply {
string message = 1;
string additional = 2;
// string age = 3;
int64 id = 4;
}
刪除欄位 age
也就是說序號此時有空洞,執行 client 舊版本協義
zerun.dong$ ./greeter_client
......
2021/12/08 23:02:12 Greeting: {
"message": "Hello addidional world",
"additional": "this is addidional field",
"id": 12345
}
2021/12/08 23:02:12 message:"Hello addidional world" additional:"this is addidional field" id:12345
2021/12/08 23:02:12 0
沒有問題,列印 r.Age
當然是預設值 0, 即刪除欄位是相容的
為什麼 required 在 proto3 中取消了?
message SearchRequest {
required string query = 1;
optional int32 page_number = 2;
optional int32 result_per_page = 3;
}
熟悉 thrift
或是使用 proto2
協議的都習慣使用 required
optional
來定義欄位屬於,擴充套件欄位一般標記為 optional
, 必傳欄位使用 required
來約束
官方解釋如下 issues2497
[1]
,
簡單說就是
required
打破了更新 IDL 時的相容性
-
永遠不能安全地向 proto 定義新增 required 欄位,也不能安全地刪除現有的 required 欄位,因為這兩個操作都會破壞相容性
-
在一個複雜的系統中,proto 定義在系統的許多不同元件中廣泛共享,新增/刪除 required 欄位可以輕鬆地降低系統的多個部分
-
多次看到由此造成的生產問題,並且 Google 內部幾乎禁止任何人新增/刪除 required 欄位
上面是谷歌得出的結論,大家可以借鑑一下,但也不能唯 G 家論
小結
IDL 修改還有很多測試用例,感興趣的可以多玩玩,比如結構體間的轉換問題,比如 enum 列舉型別。上文測試的都是 server 端使用新協義,client 使用舊協義,如果反過來呢?想測試 thrift 的可以看看這篇 thrift missing guide [2]
本文能過測試 case 想告訴大家, IDL 只能追加杜絕修改 (產品測試階段隨變改,無所謂)
寫文章不容易,如果對大家有所幫助和啟發,請大家幫忙點選 在看
, 點贊
, 分享
三連
關於 IDL 相容問題
大家有什麼看法,歡迎留言一起討論,大牛多留言 ^_^
參考資料
why remove required: http://github.com/protocolbuffers/protobuf/issues/2497,
thrift missing guide: http://diwakergupta.github.io/thrift-missing-guide/,
- 為什麼泛型使你的程式變慢
- 每個 gopher 都需要了解的 Go AST
- 體驗 http3: 基於 nginx quic 分支
- 如何應對不斷膨脹的介面
- Gopher 需要知道的幾個結構體騷操作
- 聊聊為什麼 IDL 只能擴充套件欄位而非修改
- 閱讀 redis 原始碼,學習快取淘汰演算法 W-TinyLFU
- Go 操作 Kafka 如何保證無訊息丟失
- 強烈推薦| 郝大分享 GraphQL 實踐的那些經歷
- 實踐出真知,聊聊 HTTP 鑑權那些事
- 做業務真的沒有技術含量嘛?不想做 crud boy 的可以好好讀讀
- 你真的瞭解 CDC 嘛
- 藉助 Pod 刪除事件的傳播實現 Pod 摘流
- Go timer 是如何被排程的?
- Go Context 最佳實踐
- PingCAP 故障注入利器 fail-rs
- Fail at Scale 讀後感
- 聊聊 rust trait
- 你真的瞭解 JWT 嘛
- 讀者提問:如何學習 redis 原始碼