聊聊為什麼 IDL 只能擴充套件欄位而非修改

語言: CN / TW / HK

前幾年業界流行使用 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 時的相容性

  1. 永遠不能安全地向 proto 定義新增 required 欄位,也不能安全地刪除現有的 required 欄位,因為這兩個操作都會破壞相容性

  2. 在一個複雜的系統中,proto 定義在系統的許多不同元件中廣泛共享,新增/刪除 required 欄位可以輕鬆地降低系統的多個部分

  3. 多次看到由此造成的生產問題,並且 Google 內部幾乎禁止任何人新增/刪除 required 欄位

上面是谷歌得出的結論,大家可以借鑑一下,但也不能唯 G 家論

小結

IDL 修改還有很多測試用例,感興趣的可以多玩玩,比如結構體間的轉換問題,比如 enum 列舉型別。上文測試的都是 server 端使用新協義,client 使用舊協義,如果反過來呢?想測試 thrift 的可以看看這篇 thrift missing guide [2]

本文能過測試 case 想告訴大家, IDL 只能追加杜絕修改  (產品測試階段隨變改,無所謂)

寫文章不容易,如果對大家有所幫助和啟發,請大家幫忙點選 在看點贊分享 三連

關於 IDL 相容問題 大家有什麼看法,歡迎留言一起討論,大牛多留言 ^_^

參考資料

[1]

why remove required: https://github.com/protocolbuffers/protobuf/issues/2497,

[2]

thrift missing guide: https://diwakergupta.github.io/thrift-missing-guide/,