聊聊為什么 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 只能追加杜絕修改 (產品測試階段隨變改,無所謂)