

在现代软件开发中,系统之间的高效通信至关重要,尤其是在微服务架构和分布式系统中。为了高效地传输数据并保证跨语言的兼容性,Protocol Buffers(简称 Protobuf) 应运而生。Protobuf 是 Google 开发的一种轻量、高效的序列化数据格式。它被广泛应用于微服务、RPC 框架以及大数据处理等领域。
与传统的 JSON 或 XML 格式相比,Protobuf 的优势在于其更小的体积和更快的速度。它通过定义消息结构(Schema)来进行数据的序列化和反序列化,支持多种编程语言,并且能够为开发人员提供一个明确且易于管理的数据传输模型。
本文将深入探讨如何在 Go 语言中使用 Protocol Buffers (Protobuf),全面覆盖从环境配置到实际应用的各个方面。我将逐步讲解如何安装和配置 Protobuf 编译器,编写和编译 .proto 文件,理解 Protobuf 的核心概念,如何定义和生成消息类型与服务接口。接着学习如何将其与 Go 结合,实现高效的序列化与反序列化操作。最后,文章还将介绍 Protobuf 的风格指南与最佳实践,帮助开发者在实际项目中更加规范、高效地使用 Protobuf。
准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。

1、下载 Protobuf
Windows 的 protoc-<version>-win64.zip 或 protoc-<version>-win32.zip 文件。2、解压
ZIP 文件到你希望存放 protoc 的目录。3、添加环境变量
protoc 所在的目录添加到系统的环境变量中。这样你就可以从命令行中的任何位置运行它。<protoc path>\bin 的路径。4、验证安装
protoc --version,以检查是否安装成功。$ protoc --version
libprotoc 29.3在 MacOs 系统上,你可以使用 Homebrew 安装 protoc:
brew install protobuf验证是否安装成功
$ protoc --version
libprotoc 29.3在基于 Debian 的系统(如 Ubuntu)上,你可以使用 apt 安装 protoc:
sudo apt install protobuf-compiler验证是否安装成功
$ protoc --version
libprotoc 3.6.1使用 apt 安装 protoc 时,会默认安装一个较为稳定的版本,该版本可能不是最新版本。因此,如果想要安装最新版本,建议使用其他的方式下载最新版本的发布包,然后进行安装。例如:
# 下载发布包
$ wget https://github.com/protocolbuffers/protobuf/releases/download/v25.1/protoc-25.1-linux-x86_64.zip
# 解压到 /usr/local/bin 目录下
$ unzip protoc-25.1-linux-x86_64.zip -d /usr/local/bin/protoc-25.1-linux-x86_64
# 配置环境变量
$ vim ~/.bashrc
# 添加以下内容
export PATH=$PATH:/usr/local/bin/protoc-25.1-linux-x86_64/bin
# 激活配置文件
$ source ~/.bashrc
# 验证是否安装成功
$ protoc --version
libprotoc 25.1protoc-gen-go 是 protoc 的一个插件,用于生成 Go 语言的代码。
通过下面的命令进行安装:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest验证是否安装成功:
$ protoc-gen-go --version
protoc-gen-go v1.31.0首先在项目里面新建一个 proto 文件,假设文件名为 user.proto,然后定义消息类型
syntax = "proto3";
package tutorial;
option go_package = "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user";
message User {
string name = 1;
int32 age = 2;
string email = 3;
}然后执行以下命令,生成对应的 go 文件:
protoc --go_out=. --go_opt=paths=source_relative *.proto这时我们就可以看到当前目录下多出了一个 user.pb.go 文件,该文件为 proto 代码编译后的 go 文件。
若要根据 proto 代码生成对应语言的代码(比如 Go),我们需要使用 protoc 命令,这个命令在之前已经给出安装教程。protoc 命令的常用参数如下所示:
-I 或 --proto_path:指定 import 的文件查找路径,可以指定多个路径,例如 -Isrc -Iinclude。这样编译器会在这几个路径下查找 import 的 .proto 文件。--<language>_out:指定生成所指定的语言代码的输出目录,对于 Go:go_out=/directory。--<language>_opt:传递给指定语言插件的附加选项。作为 protoc 的插件,它们有着特定的参数选项,如果我们想指定某个参数选项,需要通过 <language>_opt 参数进行传递。例如:go_opt=paths=source_relative,传递 paths 参数选项给 protoc-gen-go 插件。在大多数情况下,通过指定 <language>_out 和 <language>_opt 参数,我们就可以满足代码生成的需求。值得一提的是,这些参数不限于单次使用;如果我们需要同时为多种语言生成代码,可以通过并行使用多个 <language>_opt 和 <language>_opt 来实现这一目标。
若想了解更多的参数,可以运行 protoc --help 命令进行查看。
protoc-gen-go 是一个用于生成 Go 代码的插件,该插件有两个重要参数:
paths:控制 go 文件生成的路径paths=import 时,输出文件将放置在 以 Go 包的导入路径命名 的目录中(导入路径 由 .proto 文件中的 go_package 选项提供)。例如,Go 导入路径为 github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user,那么输出的 .go 文件将放置在 github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user/user.pb.go 。如果未指定 paths 参数,paths 的值将默认为 import。paths=source_relative 时,输出的 .go 文件将与 .proto 文件位于同一相对目录中。例如, .proto 文件位于 proto/user/user.proto,那么 .go 文将在 proto/user/user.pb.go 中生成。module:如果指定了 module 参数,例如 module=examples,则生成的 .go 文件将位于 Go 包的导入路径 加上指定的模块目录下。例如,假设 Go 包的导入路径 为 protobuf,并指定 module=examples,那么 .go 文件将生成在 protobuf/examples 目录中,例如:protobuf/examples/user.proto.go。protoc-gen-go 插件的参数需要通过 protoc 命令的 go_opt 参数进行传递,例如 go_opt=paths=source_relative。
syntax = "proto3";
option go_package = "github.com/chenmingyong0423/blog/tutorial-code/protobuf/examples";
message User {
string name = 1;
int32 age = 2;
string email = 3;
}通过 message 关键字定义一个消息类型。
消息的字段定义格式为:[关键字] 类型 字段名 = 编号;,例如 string name = 1;、optional string name = 1;。
1 到 536,870,911 之间的数字,并遵守以下限制:19,000 到 19,999 被保留给 Protocol Buffers 实现。如果你在消息中使用了这些保留的字段编号,协议缓冲区编译器会报错。proto3 中,字段默认被标记为 optional,这意味着你可以不为某个字段赋值,它会使用该字段类型的默认值,同时也可以区分该字段是否被 赋值,即使该字段的值为默认值。这些类型表示常见的数据类型,如整数、浮点数、布尔值、字符串等。
类型 | 默认值 | 备注 |
|---|---|---|
double | 0.0 | |
float | 0.0 | |
int32 | 0 | 32 位有符号整数,使用 变长编码(Variable-length encoding)。对于负数的编码效率较低。 如果字段值经常是负数,建议使用 |
int64 | 0 | 64 位有符号整数,使用 变长编码(Variable-length encoding)。对于负数的编码效率较低。 如果字段值经常是负数,建议使用 |
uint32 | 0 | 32 位无符号整数,使用 变长编码(Variable-length encoding)。 |
uint64 | 0 | 64 位无符号整数,使用 变长编码(Variable-length encoding)。 |
sint32 | 0 | 32 位有符号整数,使用 变长编码(Variable-length encoding)。与 |
sint64 | 0 | 64 位有符号整数,使用 变长编码(Variable-length encoding)。与 |
fixed32 | 0 | 始终使用 4 个字节进行编码。比 |
fixed64 | 0 | 始终使用 8 个字节进行编码。比 |
sfixed32 | 0 | 始终使用 4 个字节进行编码的有符号整数。 |
sfixed64 | 0 | 始终使用 8 个字节进行编码的有符号整数。 |
bool |
| 布尔类型,只有两个值 |
string | 空字符串 | 字符串必须始终包含 |
bytes | 空字节 | 可以包含不超过 2<sup>32</sup> 的任意任意字节序列。 |
枚举类型允许定义一组命名常量,通常用于表示状态、选项、类别等。
enum Status {
PENDING = 0;
IN_PROGRESS = 1;
COMPLETED = 2;
}message 是 Protobuf 中的复合类型,用来表示一组相关的数据字段。每个字段可以是不同的类型,包括标量类型、枚举类型、其他消息类型等。
message User {
string name = 1;
int32 age = 2;
string email = 3;
}message AddressBook {
message User {
string name = 1;
string email = 2;
}
repeated User user= 1; // 这个字段是一个列表,包含多个 User
}除了基本的标量、枚举以及消息类型,ProtoBuf 还提供了几种特殊的类型,用于处理更复杂的需求。
repeated:表示字段可以有多个值,相当于一个数组或列表。message User {
repeated string phones = 1; // 可以包含多个字符串
}map:表示键值对集合,相当于字典或哈希表。键可以是标量类型(浮点类型和 bytes 除外),值可以是除另一个 map 之外的任何类型。。message User {
map <string, int32> scores = 1;
} 使用 map 类型的一些注意事项如下:
map 字段不能使用 repeated 关键字。.proto 生成文本格式时,映射按键排序。数字键按数字排序。map 的键值对在 wire 格式中的顺序以及在迭代时的顺序是未定义的,因此你不能依赖 map 中元素的顺序。.proto 的文本格式时,map 会按键进行排序。对于数值型的键,排序会按数字顺序进行。map 或进行合并时,如果出现重复的键,最后一个键值会被使用。在从文本格式解析时,如果遇到重复的键,解析可能会失败。map 字段提供了一个键但没有提供值,则序列化时的行为取决于语言:C++、Java、Kotlin 和 Python 中,序列化时会使用该类型的默认值。map foo 的字段和一个名为 FooEntry 的符号,因为 FooEntry 已经被用于 map 的实现。Any:表示任意类型,它可以让字段存储不同类型的数据,而不需要在消息定义时提前知道这些类型。要使用 Any 类型,您需要导入 google/protobuf/any.proto。import "google/protobuf/any.proto";
message User {
google.protobuf.Any data = 1;
}oneof:一种特殊的字段类型,允许在一个消息中 定义多个字段,但在任何时候只能 设置其中一个字段。你可以添加任何类型的字段, map 字段和 repeated 字段除外。如果需要向 oneof 添加重复字段,可以使用包含重复字段的消息类型。message MyMessage {
oneof message_data {
string text = 1;
int32 number = 2;
User user = 3;
}
} 使用 oneof 类型的一些注意事项如下:
oneof 字段赋值时,它会自动清除同一 oneof 中的其他字段的值。oneof 中的多个字段,则只有最后一个字段会在解析的消息中保留其值。oneof 中的其他字段是否已经设置。如果有其他字段已设置,则清除它。oneof 一样:oneof 字段不能使用 repeated 关键字。API 对 oneof 字段有效 你可以通过反射 API 来访问和修改 oneof 字段的值。oneof 字段设置默认值(例如将 int32 类型的字段设置为 0),即使该字段的值是默认值,oneof 的 “case” 也会被设置,并且该值会被序列化到 wire 格式中。如果需要在 RPC(远程过程调用)系统中使用你的消息类型,可以在 .proto 文件中定义一个 RPC 服务接口,协议缓冲编译器会为你生成服务接口代码和存根代码,适用于你选择的编程语言。例如,如果你想定义一个 RPC 服务,包含一个方法,该方法接受 SearchRequest 并返回 SearchResponse,你可以在 .proto 文件中这样定义:
service SearchService {
rpc Search(SearchRequest) returns (SearchResponse);
}掌握了 protobuf 基本的语法之后,接下来我们要了解 proto 代码与 go 代码之间的关系。下面将围绕着以下示例代码逐步进行讲解。
syntax = "proto3";
package tutorial;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/protos/user";
message User {
int32 id = 1;
string name = 2;
int32 age = 3;
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
google.protobuf.Timestamp birth = 5;
}
enum PhoneType {
// 个人手机
PHONE_TYPE_MOBILE = 0;
// 工作电话
PHONE_TYPE_WORK = 1;
}syntax = "proto3";
package tutorial;
option go_package = "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/protos/user";.proto 文件以 package 声明开头,这有助于避免不同项目之间的命名冲突。然而,这里的 package 并不对应 Go 语言中的 package。协议缓冲编译器(protoc)会根据 .proto 文件中 go_package 字段的导入路径来确定 Go 代码中的包名,通常是该路径的最后一个部分。例如,基于示例代码生成的 Go 代码包名将是 user。
如果在 .proto 文件中引入了标准库或第三方库,编译生成的 Go 代码中也会反映这一点。例如,若引入 google/protobuf/timestamp.proto,在 Go 代码中对应的导入路径为:
timestamppb "google.golang.org/protobuf/types/known/timestamppb"message User {
int32 id = 1;
string name = 2;
int32 age = 3;
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
google.protobuf.Timestamp birth = 5;
}
enum PhoneType {
// 个人手机
PHONE_TYPE_MOBILE = 0;
// 工作电话
PHONE_TYPE_WORK = 1;
}协议缓冲编译器(protoc)会将 protobuf 中的类型转换为 Go 语言中对应的类型。例如,message 类型会转换为 Go 中的 struct 结构体,而由于 Go 没有内建的枚举类型,enum 类型会被转换为 Go 的自定义类型。所生成的部分代码如下所示:
type User struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Age int32 `protobuf:"varint,3,opt,name=age,proto3" json:"age,omitempty"`
Phones []*User_PhoneNumber `protobuf:"bytes,4,rep,name=phones,proto3" json:"phones,omitempty"`
Birth *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=birth,proto3" json:"birth,omitempty"`
}
type PhoneType int32
const (
// 个人手机
PhoneType_PHONE_TYPE_MOBILE PhoneType = 0
// 工作电话
PhoneType_PHONE_TYPE_WORK PhoneType = 1
)
type User_PhoneNumber struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Number string `protobuf:"bytes,1,opt,name=number,proto3" json:"number,omitempty"`
Type PhoneType `protobuf:"varint,2,opt,name=type,proto3,enum=PhoneType" json:"type,omitempty"`
}Protobuf 类型与 Go 类型之间有着明确的映射关系,理解这些映射关系对于正确使用 Protobuf 在 Go 中非常重要。以下是一些常见的映射规则:
Protobuf 类型 | Go 类型 |
|---|---|
double | float64 |
float | float32 |
int32 | int32 |
int64 | int64 |
uint32 | uint32 |
uint64 | uint64 |
sint32 | int32 |
sint64 | int64 |
fixed32 | uint32 |
fixed64 | uint64 |
sfixed32 | int32 |
sfixed64 | int64 |
bool | bool |
string | string |
bytes | []byte |
message | struct |
enum | 自定义类型(通常是 int32) |
repeated | slice |
map | map |
首先,我们需要创建一个名为 protobuf 的目录,并进入该目录初始化一个 Go 项目。接下来,在 proto/user 目录中创建一个名为 user.proto 的文件,文件内容使用之前提供的示例代码。项目目录结构如下所示:
.
├── go.mod
├── go.sum
└── proto
└── user
└── user.proto然后在 proto 目录下,通过以下命令使用 protoc 编译 .proto 文件,生成对应的 Go 代码:
protoc --go_out=. --go_opt=paths=source_relative *.proto接下来将基于生成的 Go 代码演示如何进行 Protobuf 消息的写入(序列化) 和 读取(反序列化) 操作。
在此之前,我们需要安装 proto 模块:
go get google.golang.org/protobuf/proto // 写入消息
user := pb.User{
Id: 1,
Name: "陈明勇",
Age: 18,
Phones: []*pb.User_PhoneNumber{
{
Number: "18888888888",
Type: pb.PhoneType_PHONE_TYPE_MOBILE,
},
{
Number: "12345678901",
Type: pb.PhoneType_PHONE_TYPE_WORK,
},
},
Birth: timestamppb.New(time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)),
}
out, err := proto.Marshal(&user)
if err != nil {
panic(err)
}
err = os.WriteFile("user.bin", out, 0644)
if err != nil {
panic(err)
} // 读取消息
in, err := os.ReadFile("user.bin")
if err != nil {
panic(err)
}
user2 := &pb.User{}
err = proto.Unmarshal(in, user2)
if err != nil {
panic(err)
}
// id:1 name:"陈明勇" age:18 phones:{number:"18888888888"} phones:{number:"12345678901" type:PHONE_TYPE_WORK} birth:{seconds:915148800}
fmt.Println(user2)新建 main.go 文件并写入以下内容:
package main
import (
"fmt"
pb "github.com/chenmingyong0423/blog/tutorial-code/go/protobuf/proto/user"
"os"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)
func main() {
// 写入消息
user := pb.User{
Id: 1,
Name: "陈明勇",
Age: 18,
Phones: []*pb.User_PhoneNumber{
{
Number: "18888888888",
Type: pb.PhoneType_PHONE_TYPE_MOBILE,
},
{
Number: "12345678901",
Type: pb.PhoneType_PHONE_TYPE_WORK,
},
},
Birth: timestamppb.New(time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)),
}
out, err := proto.Marshal(&user)
if err != nil {
panic(err)
}
err = os.WriteFile("user.bin", out, 0644)
if err != nil {
panic(err)
}
// 读取消息
in, err := os.ReadFile("user.bin")
if err != nil {
panic(err)
}
user2 := &pb.User{}
err = proto.Unmarshal(in, user2)
if err != nil {
panic(err)
}
// id:1 name:"陈明勇" age:18 phones:{number:"18888888888"} phones:{number:"12345678901" type:PHONE_TYPE_WORK} birth:{seconds:915148800}
fmt.Println(user2)
}通过 proto.Marshal 和 proto.Unmarshal 函数,我们可以对 Protobuf 消息进行序列化(写入)和反序列化(读取)操作。
使用 go run main.go 命令,程序即可成功运行。
为了确保 .proto 文件中协议缓冲消息定义及其对应类的结构一致且易于阅读。我们需要遵循这些规范。
需要注意的是,协议缓冲的风格在不断演进,因此我们可能会遇到采用不同风格或规范编写的
.proto文件。在修改这些文件时,需要尽量遵循已有的风格,保持一致性是非常重要的。当然,在创建新的.proto文件时,建议采用当前最新的的最佳实践和风格。
文件名应采用小写蛇形命名法(lower_snake_case.proto)。
所有文件应按以下顺序组织:
proto/user/user.proto,则包名可以是 proto.user对于消息名称,使用 PascalCase(首字母大写)命名风格,例如 SongServerRequest。对于缩写,推荐将其为一个整体,保持首字母大写,而不是拆分字母:例如 GetDnsRequest,而不是 GetDNSRequest。Dns 作为一个整体,首字母大写。
对于字段名称(包括 oneof 字段和扩展名),使用 lower_snake_case(小写字母,单词间用下划线分隔):例如 song_name。
对 Repeated 字段使用复数名称。例如 repeated string keys。
FooBar。FOO_BAR_UNSPECIFIED,FOO_BAR_FIRST_VALUE。UNSPECIFIED。如果你的 .proto 文件中定义了 RPC 服务,应该对 服务名称 和 RPC 方法名称 都使用 PascalCase(首字母大写)命名规则:
service FooService {
rpc GetSomething(GetSomethingRequest) returns (GetSomethingResponse);
rpc ListSomething(ListSomethingRequest) returns (ListSomethingResponse);
}proto 数据,或者其他服务的旧代码可能会受到影响。2 和 3 等数字即可。你还可以保留已删除字段的名称,避免它们被重用:例如,reserved "foo", "bar";。2 和 3 等标签号,并保留已删除的枚举值名称:例如,reserved "FOO", "BAR";。int32 转 uint32)是安全的,但改变消息类型会破坏兼容性,除非新类型是旧类型的超集。API 合同的要求。proto3 移除了必填字段的支持,所有字段应当是可选的或重复的。这样可以避免未来需求变化时强制使用不再逻辑上需要的字段。proto 文件会增加内存使用,甚至可能导致生成的代码无法编译。建议将大型消息拆分为多个小的消息。FOO_UNSPECIFIED 值,作为枚举声明的第一个值。这样在添加新值时,旧客户端会将字段视为未设置,并返回默认值(即枚举的第一个值)。此外,枚举值应使用 tag 0 作为 UNSPECIFIED 的默认值。duration、timestamp、date、money 等),而不是自己定义类似的类型。这样可以减少重复定义,同时也能确保跨语言的一致性。proto 文件最好只定义一个消息、枚举、扩展、服务或循环依赖。将相关类型放在一个文件中会更容易进行重构和维护,也能确保文件不被过度膨胀。proto3 移除了为字段设置默认值的能力,因此,最好避免更改字段的默认值。repeated 类型转换为标量类型
不要将 repeated 字段改为标量类型,这样会丢失数据。对于 proto3 的数值类型字段,转换将会丢失字段数据。JSON 和文本格式)的序列化方法并不适合用于数据交换。它们将字段和枚举值表示为字符串,因此在字段或枚举值重命名或新增字段时,旧代码会导致反序列化失败。应尽可能使用二进制格式进行数据交换,文本格式仅限于调试和人工编辑。Protobuf 的序列化稳定性无法保证跨不同的二进制文件或同一二进制文件的不同构建版本。不要依赖序列化稳定性来构建缓存键等。protobuf 自动更改字段名称或提供特殊访问方式。还应避免在文件路径中使用关键字。本文介绍了如何在 Go 中使用 Protobuf,涵盖了环境配置、语法、集成步骤、风格指南和最佳实践等内容。通过本文,你可以快速上手 Go 与 Protocol Buffers 的集成,掌握消息类型的定义、代码的生成以及消息的序列化与反序列化流程。
你好,我是陈明勇,一名热爱技术、乐于分享的开发者,同时也是开源爱好者。
成功的路上并不拥挤,有没有兴趣结个伴?
关注我,加我好友,一起学习一起进步!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。