你在学习protobuf
或gRPC
时是否遇到过这些问题:
- 生成的pb.go文件的位置奇奇怪怪,搞不清楚规则
- 不知道
protoc --go_opt=paths=source_relative
有什么用,但加了它,代码好像就生成到正确的位置了
如果是的,那这篇文章可以帮你解决你心中的疑惑。
本文主要介绍在
protobuf-go
中,paths
参数和proto文件中的option go_package=xxx
是如何共同配合,影响pb.go的生成位置的。
文章大量引用了
protobuf-go
的源码,懒得看分析过程的同学可以直接下拉到最后的结论部分。
背景知识
现在假定我们有如下所示的目录结构
text
proto-example
├── api
│ └── protobuf
│ └── foo.proto
└── internal
└── foo
foo.proto
的内容如下:
protoc
syntax = "proto3";
package foo;
option go_package = "github.com/bootun/proto-example/internal/foo";
// ...
我们执行如下命令:
sh
protoc \
--proto_path=api/protobuf/foo.proto \
--go_out=internal/foo \
--go_opt=paths=source_relative
你是否会有以下疑惑?
- 代码会生成在
go_out
指定的地方吗? paths=source_relative
有什么用?- proto文件中的
go_package
有什么用? - 他们之间有什么关系吗?
反正我初学protobuf的时候是被这些奇怪的参数搞得头疼,这次带你彻底缕清他们之间的关系。
开始吧
我们执行protoc --xx_out=yyy -xx_opt=yyy
时,其实是告诉protoc,你要执行protoc-gen-xx
这个程序。protoc
会在你系统定义的PATH环境变量中寻找这个可执行文件。
在背景里定义的例子中,xx
就是go
。在我们传入--go_out
时,protoc会在$PATH中寻找protoc-gen-go
这个可执行文件(gRPC同理)。因此我们有问题直接去查看protoc-gen-go
的源码即可。
我们从protobuf-go
的main函数中开始梳理,发现命令行参数处理的逻辑位于protobuf-go/compiler/protogen/protogen.go
中,
go
for _, param := range strings.Split(req.GetParameter(), ",") {
// ...
// 处理参数
switch param {
...
case "paths":
switch value {
case "import":
gen.pathType = pathTypeImport
case "source_relative":
gen.pathType = pathTypeSourceRelative
default:
return nil, fmt.Errorf(`unknown path type %q: want "import" or "source_relative"`, value)
}
// ...
}
上面这段代码的逻辑很简单明了,不多做解释。这里如果不传递paths
参数,gen.pathType
默认为零值。
我们一起来看下,gen.pathType
的零值是什么呢?
go
type pathType int
const (
pathTypeImport pathType = iota
pathTypeSourceRelative
)
如果我们不传paths=source_relative
,pathType
的默认值就是pathTypeImport
,等同于--go_opt=paths=import
参数。
这两个参数有什么区别呢?我们暂且按下不表,现在我们来看看proto文件里的内容。
option go_package = "xxx"
还记得吗?我们的proto文件里定义了
proto
option go_package = "github.com/bootun/proto-example/internal/foo";
这个go-package
有什么用呢?我们来看看google\protobuf\descriptor.proto
中给go_package
的定义:
go
// Sets the Go package where structs generated from this .proto will be
// placed. If omitted, the Go package will be derived from the following:
// - The basename of the package import path, if provided.
// - Otherwise, the package statement in the .proto file, if present.
// - Otherwise, the basename of the .proto file, without extension.
optional string go_package = 11;
大概意思就是: 它决定了这个proto文件生成的pb.go应该放在哪个go包中,如果设置了,那生成的.pb.go文件的import路径就是go_package
里所指定的路径。如果没有设置,那则由.proto文件的package语句或.proto文件的名称来决定。
可能不是特别好理解,我们来看看这部分实际的逻辑,这是protobuf-go
的代码中对go_package
的处理方式:
go
for _, fdesc := range gen.Request.ProtoFile {
// fdesc.GetOptions().GetGoPackage() 里就是proto文件中 option go_package设置的内容
impPath, pkgName := splitImportPathAndPackageName(fdesc.GetOptions().GetGoPackage())
// ...
}
// 附上splitImportPathAndPackageName的定义
func splitImportPathAndPackageName(s string) (GoImportPath, GoPackageName) {
if i := strings.Index(s, ";"); i >= 0 {
return GoImportPath(s[:i]), GoPackageName(s[i+1:])
}
return GoImportPath(s), ""
}
go_package
里设置的参数被分为了两个部分: impPath
和pkgName
,在本例中,impPath
是github.com/bootun/proto-example/internal/foo,pkgName
是空字符串。
你可能见过这种使用分号分隔 的写法: option go_package = "xxxx/xxxx;yyy"; 这样写的话我们上边得到的
pkgName
就是yyy
了。
我们来看一下impPath
会被如何使用?
go
// prefix决定了最终生成的.pb.go文件的前缀
// 此时prefix等于文件名(foo.proto)
prefix := p.GetName()
if ext := path.Ext(prefix); ext == ".proto" || ext == ".protodevel" {
// 去掉后缀
prefix = prefix[:len(prefix)-len(ext)]
}
// 此时prefix等于foo
// 😉看!上文提到的gen.pathType出场了
switch gen.pathType {
case pathTypeImport:
// 如果paths=import(或者没传),
// 就会给prefix拼上刚刚得到的impPath(f.GoImportPath)
prefix = path.Join(string(f.GoImportPath), path.Base(prefix))
// 此时prefix = github.com/bootun/proto-example/internal/foo/foo
case pathTypeSourceRelative:
// 如果paths=source_relative则什么也不加前缀
// 此时prefix依旧等于foo
}
f.GoDescriptorIdent = GoIdent{
GoName: "File_" + strs.GoSanitized(p.GetName()),
GoImportPath: f.GoImportPath,
}
// 😲嘿,看这里
// 文件的GeneratedFilenamePrefix被设为了prefix。
// 在我们的例子中就是foo
f.GeneratedFilenamePrefix = prefix
为什么我要你注意GeneratedFilenamePrefix
呢?
我们来看看它的注释:
go
// GeneratedFilenamePrefix is used to construct filenames for generated
// files associated with this source file.
//
// For example, the source file "dir/foo.proto" might have a filename prefix
// of "dir/foo". Appending ".pb.go" produces an output file of "dir/foo.pb.go".
GeneratedFilenamePrefix string
注释中说最终生成的文件就是GeneratedFilenamePrefix
加上.pb.go,在我们的例子中,最终生成的文件名就是foo.pb.go
。
如果你忘记写paths=source_relative
或写的是paths=import
,那最终生成的文件名就是github.com/bootun/proto-example/internal/foo/foo.pb.go
。也就是说,会在--go_out
指定的目录下,再创建github.com/bootun/proto-example/internal/foo
等一系列的目录!我猜这肯定不是你想要的结果。
结论
总结一下,--go_out
参数指定了.go文件要输出的目录,生成的文件会以这个目录为基准。
而--go_opt=paths=source_relative
参数会告诉protobuf-go生成的文件前不要加前缀。也就是直接在--go_out
指定目录下生成.pb.go文件,最终的文件会生成在:
sh
${--go_out}/${proto文件名}.pb.go
如果不加--go_opt=paths=source_relative
,效果等同于加了--go_opt=paths=import
,会在生成的文件前加上proto文件内option go_package
的内容,最终的路径就变成了:
sh
${--go_out}/${option go_package}/${proto文件名}.pb.go
--go_out
和--go_opt
以及proto文件中的option go_package
共同控制着生成文件的最终位置。
本文首发于微信公众号 梦真日记,转载请注明出处。