Go 代码生成指南(不透明)

描述了协议缓冲区编译器为任何给定的协议定义生成的 Go 代码的具体内容。

突出显示了 proto2 和 proto3 生成代码之间的任何差异 - 请注意,这些差异在于本文档中描述的生成代码,而不是基本 API,基本 API 在两个版本中是相同的。在阅读本文档之前,您应该阅读 proto2 语言指南 和/或 proto3 语言指南

编译器调用

协议缓冲区编译器需要一个插件才能生成 Go 代码。使用 Go 1.16 或更高版本运行以下命令安装它

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

这将在 $GOBIN 中安装一个 protoc-gen-go 二进制文件。设置 $GOBIN 环境变量以更改安装位置。它必须在您的 $PATH 中,以便协议缓冲区编译器找到它。

当使用 go_out 标志调用时,协议缓冲区编译器会生成 Go 输出。go_out 标志的参数是您希望编译器写入 Go 输出的目录。编译器为每个 .proto 输入文件创建一个源文件。输出文件的名称是通过将 .proto 扩展名替换为 .pb.go 创建的。

生成的 .pb.go 文件在输出目录中的位置取决于编译器标志。有几种输出模式

  • 如果指定了 paths=import 标志,则输出文件将放置在以 Go 包的导入路径命名的目录中(例如 .proto 文件中 go_package 选项提供的导入路径)。例如,Go 导入路径为 example.com/project/protos/fizz 的输入文件 protos/buzz.proto 将生成一个输出文件,其路径为 example.com/project/protos/fizz/buzz.pb.go。如果未指定 paths 标志,则这是默认输出模式。
  • 如果指定了 module=$PREFIX 标志,则输出文件将放置在以 Go 包的导入路径命名的目录中(例如 .proto 文件中 go_package 选项提供的导入路径),但输出文件名中会删除指定的目录前缀。例如,Go 导入路径为 example.com/project/protos/fizzexample.com/project 指定为 module 前缀的输入文件 protos/buzz.proto 将生成一个输出文件,其路径为 protos/fizz/buzz.pb.go。在模块路径之外生成任何 Go 包都会导致错误。此模式对于将生成的文件直接输出到 Go 模块中非常有用。
  • 如果指定了 paths=source_relative 标志,则输出文件将放置在与输入文件相同的相对目录中。例如,输入文件 protos/buzz.proto 将生成一个输出文件,其路径为 protos/buzz.pb.go

protoc-gen-go 特有的标志通过在调用 protoc 时传递 go_opt 标志来提供。可以传递多个 go_opt 标志。例如,当运行

protoc --proto_path=src --go_out=out --go_opt=paths=source_relative foo.proto bar/baz.proto

编译器将从 src 目录中读取输入文件 foo.protobar/baz.proto,并将输出文件 foo.pb.gobar/baz.pb.go 写入 out 目录。如果需要,编译器会自动创建嵌套的输出子目录,但不会创建输出目录本身。

为了生成 Go 代码,必须为每个 .proto 文件(包括正在生成的 .proto 文件传递依赖的文件)提供 Go 包的导入路径。有两种方法可以指定 Go 导入路径

  • 通过在 .proto 文件中声明它,或者
  • 通过在调用 protoc 时在命令行中声明它。

我们建议在 .proto 文件中声明它,以便 .proto 文件的 Go 包可以集中地与 .proto 文件本身关联,并简化调用 protoc 时传递的标志集。如果给定的 .proto 文件的 Go 导入路径由 .proto 文件本身和命令行同时提供,则以后者为准。

Go 导入路径在 .proto 文件中本地指定,方法是声明一个 go_package 选项,其中包含 Go 包的完整导入路径。用法示例

option go_package = "example.com/project/protos/fizz";

Go 导入路径可以在调用编译器时在命令行中指定,方法是传递一个或多个 M${PROTO_FILE}=${GO_IMPORT_PATH} 标志。用法示例

protoc --proto_path=src \
  --go_opt=Mprotos/buzz.proto=example.com/project/protos/fizz \
  --go_opt=Mprotos/bar.proto=example.com/project/protos/foo \
  protos/buzz.proto protos/bar.proto

由于所有 .proto 文件到其 Go 导入路径的映射可能非常大,因此指定 Go 导入路径的这种模式通常由某些构建工具(例如 Bazel)执行,这些工具可以控制整个依赖树。如果给定的 .proto 文件存在重复条目,则以后指定的条目为准。

对于 go_package 选项和 M 标志,该值可以包含显式包名称,并用分号与导入路径分隔。例如:"example.com/protos/foo;package_name"。不鼓励使用这种用法,因为包名称默认情况下将以合理的方式从导入路径派生。

导入路径用于确定当一个 .proto 文件导入另一个 .proto 文件时必须生成哪些 import 语句。例如,如果 a.proto 导入 b.proto,则生成的 a.pb.go 文件需要导入包含生成的 b.pb.go 文件的 Go 包(除非这两个文件都在同一个包中)。导入路径也用于构造输出文件名。有关详细信息,请参阅上面的“编译器调用”部分。

Go 导入路径与 .proto 文件中的 package 说明符 之间没有关联。后者仅与 protobuf 命名空间相关,而前者仅与 Go 命名空间相关。此外,Go 导入路径与 .proto 导入路径之间也没有关联。

API 级别

生成的代码使用开放结构 API 或不透明 API。有关介绍,请参阅 Go Protobuf:新的不透明 API 博客文章。

根据您的 .proto 文件使用的语法,以下是将使用的 API

.proto 语法API 级别
proto2开放结构 API
proto3开放结构 API
edition 2023开放结构 API
edition 2024+不透明 API

您可以通过在 .proto 文件中设置 api_level 版本特性来选择 API。这可以按文件或按消息设置

edition = "2023";

package log;

import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;

message LogEntry {  }

为了您的方便,您还可以使用 protoc 命令行标志覆盖默认 API 级别

protoc […] --go_opt=default_api_level=API_HYBRID

要覆盖特定文件(而不是所有文件)的默认 API 级别,请使用 apilevelM 映射标志(类似于 导入路径的 M 标志

protoc […] --go_opt=apilevelMhello.proto=API_HYBRID

命令行标志也适用于仍使用 proto2 或 proto3 语法的 .proto 文件,但如果您想从 .proto 文件中选择 API 级别,则需要先将所述文件迁移到版本。

消息

给定一个简单的消息声明

message Artist {}

协议缓冲区编译器会生成一个名为 Artist 的结构体。*Artist 实现了 proto.Message 接口。

proto 提供了操作消息的函数,包括与二进制格式之间的转换。

proto.Message 接口定义了一个 ProtoReflect 方法。此方法返回一个 protoreflect.Message,它提供了消息的基于反射的视图。

optimize_for 选项不会影响 Go 代码生成器的输出。

当多个 goroutine 并发访问同一消息时,适用以下规则

  • 并发访问(读取)字段是安全的,但有一个例外
  • 在同一消息中修改不同的字段是安全的。
  • 并发修改字段是不安全的。
  • 以任何方式并发修改消息以及 proto 的函数(例如 proto.Marshalproto.Size)是不安全的。

嵌套类型

消息可以在另一个消息内部声明。例如

message Artist {
  message Name {
  }
}

在这种情况下,编译器会生成两个结构体:ArtistArtist_Name

字段

协议缓冲区编译器为消息中定义的每个字段生成访问器方法(setter 和 getter)。

请注意,即使 .proto 文件中的字段名称使用带下划线的小写字母(应该如此),生成的 Go 访问器方法始终使用驼峰式命名。大小写转换的工作方式如下

  1. 第一个字母大写以导出。如果第一个字符是下划线,则将其删除并添加大写 X 前缀。
  2. 如果内部下划线后跟一个小写字母,则删除下划线,并将后面的字母大写。

因此,您可以使用 Go 中的 GetBirthYear() 方法访问 proto 字段 birth_year,并使用 GetXBirthYear_2() 访问 _birth_year_2

单数标量字段 (proto2)

对于以下任一字段定义

optional int32 birth_year = 1;
required int32 birth_year = 1;

编译器会生成以下访问器方法

func (m *Artist) GetBirthYear() int32 { ... }
func (m *Artist) SetBirthYear(v int32) { ... }
func (m *Artist) HasBirthYear() bool { ... }
func (m *Artist) ClearBirthYear() { ... }

访问器方法 GetBirthYear() 返回 birth_year 中的 int32 值,如果字段未设置,则返回默认值。如果未显式设置默认值,则会使用该类型的 零值(数字为 0,字符串为空字符串)。

对于其他标量字段类型(包括 boolbytesstring),int32 将根据 标量值类型表 替换为相应的 Go 类型。

单数标量字段 (proto3)

对于此字段定义

int32 birth_year = 1;
optional int32 first_active_year = 2;

编译器会生成以下访问器方法

func (m *Artist) GetBirthYear() int32 { ... }
func (m *Artist) SetBirthYear(v int32) { ... }
// NOTE: No HasBirthYear() or ClearBirthYear() methods;
// proto3 fields only have presence when declared as optional:
// /programming-guides/field_presence.md

func (m *Artist) GetFirstActiveYear() int32 { ... }
func (m *Artist) SetFirstActiveYear(v int32) { ... }
func (m *Artist) HasFirstActiveYear() bool { ... }
func (m *Artist) ClearFirstActiveYear() { ... }

访问器方法 GetBirthYear() 返回 birth_year 中的 int32 值,如果字段未设置,则返回该类型的 零值(数字为 0,字符串为空字符串)。

对于其他标量字段类型(包括 boolbytesstring),int32 将根据 标量值类型表 替换为相应的 Go 类型。proto 中的未设置值将表示为该类型的 零值(数字为 0,字符串为空字符串)。

单数消息字段

给定消息类型

message Band {}

对于具有 Band 字段的消息

// proto2
message Concert {
  optional Band headliner = 1;
  // The generated code is the same result if required instead of optional.
}

// proto3
message Concert {
  Band headliner = 1;
}

编译器将生成一个具有以下访问器方法的 Go 结构体

type Concert struct { ... }

func (m *Concert) GetHeadliner() *Band { ... }
func (m *Concert) SetHeadliner(v *Band) { ... }
func (m *Concert) HasHeadliner() bool { ... }
func (m *Concert) ClearHeadliner() { ... }

即使 m 为 nil,也可以安全地调用 GetHeadliner() 访问器方法。这使得可以在没有中间 nil 检查的情况下链式调用 getter

var m *Concert // defaults to nil
log.Infof("GetFoundingYear() = %d (no panic!)", m.GetHeadliner().GetFoundingYear())

如果字段未设置,则 getter 将返回字段的默认值。对于消息,默认值为 nil 指针。

与 getter 相反,setter 不会为您执行 nil 检查。因此,您不能安全地在可能为 nil 的消息上调用 setter。

重复字段

对于重复字段,访问器方法使用切片类型。对于具有重复字段的此消息

message Concert {
  // Best practice: use pluralized names for repeated fields:
  // /programming-guides/style#repeated-fields
  repeated Band support_acts = 1;
}

编译器会生成一个具有以下访问器方法的 Go 结构体

type Concert struct { ... }

func (m *Concert) GetSupportActs() []*Band { ... }
func (m *Concert) SetSupportActs(v []*Band) { ... }

同样,对于字段定义 repeated bytes band_promo_images = 1;,编译器将生成使用 [][]byte 类型的访问器。对于重复的 枚举 repeated MusicGenre genres = 2;,编译器会生成使用 []MusicGenre 类型的访问器。

以下示例展示了如何使用 构建器 构建 Concert 消息。

concert := Concert_builder{
  SupportActs: []*Band{
    {}, // First element.
    {}, // Second element.
  },
}.Build()

或者,您可以使用 setter

concert := &Concert{}
concert.SetSupportActs([]*Band{
    {}, // First element.
    {}, // Second element.
})

要访问字段,您可以执行以下操作

support := concert.GetSupportActs() // support type is []*Band.
b1 := support[0] // b1 type is *Band, the first element in support_acts.

Map 字段

每个 map 字段都会生成使用 map[TKey]TValue 类型的访问器,其中 TKey 是字段的键类型,TValue 是字段的值类型。对于具有 map 字段的此消息

message MerchItem {}

message MerchBooth {
  // items maps from merchandise item name ("Signed T-Shirt") to
  // a MerchItem message with more details about the item.
  map<string, MerchItem> items = 1;
}

编译器会生成一个具有以下访问器方法的 Go 结构体

type MerchBooth struct { ... }

func (m *MerchBooth) GetItems() map[string]*MerchItem { ... }
func (m *MerchBooth) SetItems(v map[string]*MerchItem) { ... }

Oneof 字段

对于 oneof 字段,protobuf 编译器会为 oneof 中的每个 单数字段 生成访问器。

对于具有 oneof 字段的此消息

package account;
message Profile {
  oneof avatar {
    string image_url = 1;
    bytes image_data = 2;
  }
}

编译器会生成一个具有以下访问器方法的 Go 结构体

type Profile struct { ... }

func (m *Profile) WhichAvatar() case_Profile_Avatar { ... }
func (m *Profile) GetImageUrl() string { ... }
func (m *Profile) GetImageData() []byte { ... }

func (m *Profile) SetImageUrl(v string) { ... }
func (m *Profile) SetImageData(v []byte) { ... }

func (m *Profile) HasAvatar() bool { ... }
func (m *Profile) HasImageUrl() bool { ... }
func (m *Profile) HasImageData() bool { ... }

func (m *Profile) ClearAvatar() { ... }
func (m *Profile) ClearImageUrl() { ... }
func (m *Profile) ClearImageData() { ... }

以下示例展示了如何使用 构建器 设置字段

p1 := accountpb.Profile_builder{
  ImageUrl: proto.String("https://example.com/image.png"),
}.Build()

…或者,等效地,使用 setter

// imageData is []byte
imageData := getImageData()
p2 := &accountpb.Profile{}
p2.SetImageData(imageData)

要访问字段,您可以在 WhichAvatar() 结果上使用 switch 语句

switch m.WhichAvatar() {
case accountpb.Profile_ImageUrl_case:
    // Load profile image based on URL
    // using m.GetImageUrl()

case accountpb.Profile_ImageData_case:
    // Load profile image based on bytes
    // using m.GetImageData()

case accountpb.Profile_Avatar_not_set_case:
    // The field is not set.

default:
    return fmt.Errorf("Profile.Avatar has an unexpected new oneof field %v", x)
}

构建器

构建器是一种在单个表达式中构建和初始化消息的便捷方法,尤其是在处理像单元测试这样的嵌套消息时。

与其他语言(如 Java)中的构建器相反,Go protobuf 构建器并非旨在在函数之间传递。相反,请立即调用 Build() 并传递生成的 proto 消息,然后使用 setter 修改字段。

枚举

给定一个枚举,例如

message Venue {
  enum Kind {
    KIND_UNSPECIFIED = 0;
    KIND_CONCERT_HALL = 1;
    KIND_STADIUM = 2;
    KIND_BAR = 3;
    KIND_OPEN_AIR_FESTIVAL = 4;
  }
  Kind kind = 1;
  // ...
}

协议缓冲区编译器会生成一个类型和一系列具有该类型的常量

type Venue_Kind int32

const (
    Venue_KIND_UNSPECIFIED       Venue_Kind = 0
    Venue_KIND_CONCERT_HALL      Venue_Kind = 1
    Venue_KIND_STADIUM           Venue_Kind = 2
    Venue_KIND_BAR               Venue_Kind = 3
    Venue_KIND_OPEN_AIR_FESTIVAL Venue_Kind = 4
)

对于消息内的枚举(如上面的枚举),类型名称以消息名称开头

type Venue_Kind int32

对于包级枚举

enum Genre {
  GENRE_UNSPECIFIED = 0;
  GENRE_ROCK = 1;
  GENRE_INDIE = 2;
  GENRE_DRUM_AND_BASS = 3;
  // ...
}

Go 类型名称从 proto 枚举名称修改。

type Genre int32

此类型具有 String() 方法,该方法返回给定值的名称。

Enum() 方法使用给定值初始化新分配的内存,并返回相应的指针

func (Genre) Enum() *Genre

协议缓冲区编译器为枚举中的每个值生成一个常量。对于消息内的枚举,常量以封闭消息的名称开头

const (
    Venue_KIND_UNSPECIFIED       Venue_Kind = 0
    Venue_KIND_CONCERT_HALL      Venue_Kind = 1
    Venue_KIND_STADIUM           Venue_Kind = 2
    Venue_KIND_BAR               Venue_Kind = 3
    Venue_KIND_OPEN_AIR_FESTIVAL Venue_Kind = 4
)

对于包级枚举,常量以枚举名称开头

const (
    Genre_GENRE_UNSPECIFIED   Genre = 0
    Genre_GENRE_ROCK          Genre = 1
    Genre_GENRE_INDIE         Genre = 2
    Genre_GENRE_DRUM_AND_BASS Genre = 3
)

protobuf 编译器还会生成一个从整数值到字符串名称的映射和一个从名称到值的映射

var Genre_name = map[int32]string{
    0: "GENRE_UNSPECIFIED",
    1: "GENRE_ROCK",
    2: "GENRE_INDIE",
    3: "GENRE_DRUM_AND_BASS",
}
var Genre_value = map[string]int32{
    "GENRE_UNSPECIFIED":   0,
    "GENRE_ROCK":          1,
    "GENRE_INDIE":         2,
    "GENRE_DRUM_AND_BASS": 3,
}

请注意,.proto 语言允许多个枚举符号具有相同的数值。具有相同数值的符号是同义词。这些在 Go 中以完全相同的方式表示,多个名称对应于相同的数值。反向映射为数值到 .proto 文件中首先出现的名称包含单个条目。

扩展(proto2)

给定一个扩展定义

extend Concert {
  optional int32 promo_id = 123;
}

协议缓冲区编译器将生成一个名为 E_Promo_idprotoreflect.ExtensionType 值。此值可以与 proto.GetExtensionproto.SetExtensionproto.HasExtensionproto.ClearExtension 函数一起使用,以访问消息中的扩展。GetExtension 函数和 SetExtension 函数分别返回和接受包含扩展值类型的 interface{} 值。

对于单数标量扩展字段,扩展值类型是 标量值类型表 中的相应 Go 类型。

对于单数嵌入消息扩展字段,扩展值类型是 *M,其中 M 是字段消息类型。

对于重复扩展字段,扩展值类型是单数类型的切片。

例如,给定以下定义

extend Concert {
  optional int32 singular_int32 = 1;
  repeated bytes repeated_strings = 2;
  optional Band singular_message = 3;
}

扩展值可以按以下方式访问

m := &somepb.Concert{}
proto.SetExtension(m, extpb.E_SingularInt32, int32(1))
proto.SetExtension(m, extpb.E_RepeatedString, []string{"a", "b", "c"})
proto.SetExtension(m, extpb.E_SingularMessage, &extpb.Band{})

v1 := proto.GetExtension(m, extpb.E_SingularInt32).(int32)
v2 := proto.GetExtension(m, extpb.E_RepeatedString).([][]byte)
v3 := proto.GetExtension(m, extpb.E_SingularMessage).(*extpb.Band)

扩展可以在另一种类型内部声明为嵌套类型。例如,一种常见的模式是执行以下操作

message Promo {
  extend Concert {
    optional int32 promo_id = 124;
  }
}

在这种情况下,ExtensionType 值名为 E_Promo_Concert

服务

默认情况下,Go 代码生成器不生成服务的输出。如果您启用 gRPC 插件(请参阅 gRPC Go 快速入门指南),则将生成代码以支持 gRPC。