業務場景
我們會有這樣的需求:在不同的文件夾中定義了不同的 proto 文件,這些不同的文件夾可能是一些不同的 gRPC 服務。因為不想重復定義某一個 message
,所以其中一個服務可能會用到其他服務中定義的 message
,那么這個時候就需要使用到 proto 文件的 import
功能。
接下來說說我在 Go 項目中使用 protobuf 的 import
時所遇到的坑。
案例
首先,我們來創建一個實驗項目作為案例,便以說明,結構如下:
文件 go.mod 中聲明了該項目模塊名 module github.com/xvrzhao/pb-demo
,proto 文件夾中含有兩個 gRPC 服務,分別為 article 和 user,我們在這兩個文件夾中定義各自所需要的 messages 和 services。
一般情況下,我們會將編譯生成的 pb.go 文件生成在與 proto 文件相同的目錄,這樣我們就不需要再創建相同的目錄層級結構來存放 pb.go 文件了。由於同一文件夾下的 pb.go 文件同屬於一個 package,所以在定義 proto 文件的時候,相同文件夾下的 proto 文件也應聲明為同一的 package,並且和文件夾同名,這是因為生成的 pb.go 文件的 package 是取自 proto package 的。
同屬於一個包內的 proto 文件之間的引用也需要聲明 import
,因為每個 proto 文件都是相互獨立的,這點不像 Go(包內所有定義均可見)。我們的項目 user 模塊下 service.proto 就需要用到 message.proto 中的 message 定義,代碼是這樣寫的:
user/service.proto:
syntax = "proto3"; package user; // 聲明所在包 option go_package = "github.com/xvrzhao/pb-demo/proto/user"; // 聲明生成的 go 文件所屬的包 import "proto/user/message.proto"; // 導入同包內的其他 proto 文件 import "proto/article/message.proto"; // 導入其他包的 proto 文件 service User { rpc GetUserInfo (UserID) returns (UserInfo); rpc GetUserFavArticle (UserID) returns (article.Articles.Article); }
user/message.proto:
syntax = "proto3"; package user; option go_package = "github.com/xvrzhao/pb-demo/proto/user"; message UserID { int64 ID = 1; } message UserInfo { int64 ID = 1; string Name = 2; int32 Age = 3; gender Gender = 4; enum gender { MALE = 0; FEMALE = 1; } }
可以看到,我們在每個 proto 文件中都聲明了 package
和 option go_package
,這兩個聲明都是包聲明,到底兩者有什么關系,這也是我開始比較迷惑的。
我是這樣理解的,package
屬於 proto 文件自身的范圍定義,與生成的 go 代碼無關,它不知道 go 代碼的存在(但 go 代碼的 package
名往往會取自它)。這個 proto 的 package
的存在是為了避免當導入其他 proto 文件時導致的文件內的命名沖突。所以,當導入非本包的 message
時,需要加 package 前綴,如 service.proto 文件中引用的 Article.Articles
,點號選擇符前為 package,后為 message。同包內的引用不需要加包名前綴。
article/message.proto:
syntax = "proto3"; package article; option go_package = "github.com/xvrzhao/pb-demo/proto/article"; message Articles { repeated Article Articles = 1; message Article { int64 ID = 1; string Title = 2; } }
而 option go_package
的聲明就和生成的 go 代碼相關了,它定義了生成的 go 文件所屬包的完整包名,所謂完整,是指相對於該項目的完整的包路徑,應以項目的 Module Name 為前綴。如果不聲明這一項會怎么樣?最開始我是沒有加這項聲明的,后來發現 依賴這個文件的 其他包的 proto 文件 所生成的 go 代碼 中(注意斷句,已用斜體和正體標示),引入本文件所生成的 go 包時,import
的路徑並不是基於項目 Module 的完整路徑,而是在執行 protoc
命令時相對於 --proto_path
的包路徑,這在 go build 時是找不到要導入的包的。這里聽起來可能有點繞,建議大家親自嘗試一下。
protoc 命令
另外,我們說說編譯 proto 文件時的命令參數。
首先 protoc
編譯生成 go 代碼所用的插件 protoc-gen-go 是不支持多包同時編譯的,執行一次命令只能同時編譯一個包,關於該討論可以查看該項目的 issue#39。
接下來講講我遇到的另外一個坑。通常情況下我們編譯命令是這樣的(基於本項目來說,執行命令的 pwd
為項目根目錄):
$ protoc --proto_path=. --go_out=. ./proto/user/*.proto # 編譯 user 路徑下所有 proto 文件
其中,--proto_path
或者 -I
參數用以指定所編譯源碼(包括直接編譯的和被導入的 proto 文件)的搜索路徑,proto 文件中使用 import
關鍵字導入的路徑一定是要基於 --proto_path
參數所指定的路徑的。該參數如果不指定,默認為 pwd
,也可以指定多個以包含所有所需文件。
其中,--go_out
參數是用來指定 protoc-gen-go 插件的工作方式 和 go 代碼目錄架構的生成位置,可以向 --go_out
傳遞很多參數,見 golang/protobuf 文檔 。主要的兩個參數為 plugins 和 paths ,代表 生成 go 代碼所使用的插件 和 生成的 go 代碼的目錄怎樣架構。--go_out
參數的寫法是,參數之間用逗號隔開,最后加上冒號來指定代碼目錄架構的生成位置,例如:--go_out=plugins=grpc,paths=import:.
。paths 參數有兩個選項,import
和 source_relative
。默認為 import
,代表按照生成的 go 代碼的包的全路徑去創建目錄層級,source_relative
代表按照 proto 源文件的目錄層級去創建 go 代碼的目錄層級,如果目錄已存在則不用創建。
在上面的示例命令中,--go_out
默認使用了 paths=import
所以,我的 go 文件都被編譯到了 ./github.com/xvrzhao/pb-demo/proto/user/
下,后來閱讀 文檔 才發現:
However, the output directory is selected in one of two ways. Let us say we have
inputs/x.proto
with ago_package
option ofgithub.com/golang/protobuf/p
. The corresponding output file may be:
- Relative to the import path:
$ protoc --go_out=. inputs/x.proto # writes ./github.com/golang/protobuf/p/x.pb.go
( This can work well with
--go_out=$GOPATH
)
- Relative to the input file:
$ protoc --go_out=paths=source_relative:. inputs/x.proto # generate ./inputs/x.pb.go
所以,我們應該將 --go_out
參數改為 --go_out=paths=source_relative:.
。
請切記 option go_package
聲明和 --go_out=paths=source_relative:.
命令行參數缺一不可 。
option go_package
聲明 是為了讓生成的其他 go 包(依賴方)可以正確import
到本包(被依賴方)--go_out=paths=source_relative:.
參數 是為了讓加了option go_package
聲明的 proto 文件可以將 go 代碼編譯到與其同目錄。
一般用法
為了統一性,我會將所有 proto 文件中的 import
路徑寫為相對於項目根目錄的路徑,然后 protoc
的執行總是在項目根目錄下進行:
$ protoc --go_out=plugins=grpc,paths=source_relative:. ./proto/user/*.proto $ protoc --go_out=plugins=grpc,paths=source_relative:. ./proto/article/*.proto
如果你覺得每個包都需要單獨編譯,有些麻煩,可以執行腳本( **/*
代表遞歸獲取當前目錄下所有的文件和文件夾):
pb-demo 下執行:
$ for x in **/*.proto; do protoc --go_out=plugins=grpc,paths=source_relative:. $x; done
循環依賴
注意,不同包之間的 proto 文件不可以循環依賴,這會導致生成的 go 包之間也存在循環依賴,導致 go 代碼編譯不通過。
總結
感覺 protobuf 的使用非常繁雜,文檔散落在各處( protobuf 官方文檔 / golang protobuf 文檔 / grpc 文檔 ),要注意的細節也很多,需要多加實踐,多加總結。
https://segmentfault.com/a/1190000021456180
https://www.jianshu.com/p/a0286b58098e