彻底搞清protobuf和gRPC的文件生成位置

你在学习protobufgRPC时是否遇到过这些问题:

  • 生成的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

你是否会有以下疑惑?

  1. 代码会生成在go_out指定的地方吗?
  2. paths=source_relative有什么用?
  3. proto文件中的go_package有什么用?
  4. 他们之间有什么关系吗?

反正我初学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_relativepathType的默认值就是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里设置的参数被分为了两个部分: impPathpkgName,在本例中,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共同控制着生成文件的最终位置。

本文首发于微信公众号 梦真日记,转载请注明出处。

相关推荐
mtngt1111 小时前
AI DDD重构实践
go
Minilinux20182 天前
Google ProtoBuf 简介
开发语言·google·protobuf·protobuf介绍
Grassto2 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto4 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室5 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题5 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉6 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想
asaotomo7 天前
一款 AI 驱动的新一代安全运维代理 —— DeepSentry(深哨)
运维·人工智能·安全·ai·go
码界奇点8 天前
基于Gin与GORM的若依后台管理系统设计与实现
论文阅读·go·毕业设计·gin·源代码管理
迷迭香与樱花8 天前
Gin 框架
go·gin