在现代软件开发中,系统之间的高效通信至关重要,尤其是在微服务架构和分布式系统中。为了高效地传输数据并保证跨语言的兼容性,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.1
protoc-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 删除。