Protobuf 3 入门
1. 什么是序列化?
1.1 概念
序列化(Serialization 或 Marshalling) 是指将数据结构或对象的状态转换成可存储或传输的格式。反向操作称为反序列化(Deserialization 或 Unmarshalling),它的作用是将序列化的数据恢复成原始的数据结构或对象。
简单来说,序列化就像“打包”,反序列化就像“解包”。
2.1 为什么需要序列化?
在计算机系统中,数据通常是以内存中的对象(如 struct
、class
)形式存在的,而内存数据不能直接在不同程序之间传输,必须先转换成可存储或可传输的格式。序列化的主要用途包括:
- 数据存储:将数据保存到文件、数据库等,例如:
- 日志文件
- 配置文件(如 JSON、YAML)
- 持久化存储(如 Redis、MongoDB)
- 数据传输:在不同进程或网络之间传输数据,例如:
- 前端和后端通信(Web API)
- 微服务之间的通信
- 远程调用(RPC,如 gRPC)
- 数据缓存:比如将复杂的对象序列化后存入 Redis,提高访问速度。
- 跨语言兼容:不同编程语言的数据结构不一样,序列化后可以在不同语言之间传输数据。
3.1 序列化的方式
不同的序列化格式适用于不同的应用场景,常见的格式包括:
格式 | 特点 | 可读性 | 序列化速度 | 数据大小 | 适用场景 |
---|---|---|---|---|---|
JSON | 文本格式,广泛使用 | 可读 | 适中 | 较大 | Web API,前端后端通信 |
XML | 结构化文本,标签冗余 | 可读 | 慢 | 大 | 早期 Web API,配置文件 |
YAML | 结构更简洁,适合人阅读 | 可读 | 适中 | 较大 | 配置文件(Kubernetes、Docker) |
Protobuf | Google 开发的高效二进制格式 | 不可读 | 快 | 小 | 微服务、gRPC、高性能应用 |
MessagePack | 类似 JSON,但体积更小 | 不可读 | 快 | 小 | 移动端、嵌入式系统 |
Thrift | Facebook 开发的高效序列化格式 | 不可读 | 快 | 小 | 分布式系统,RPC |
Avro | 适用于大数据(如 Hadoop) | 不可读 | 适中 | 小 | 大数据处理 |
BSON | MongoDB 的序列化格式 | 不可读 | 适中 | 适中 | MongoDB 数据存储 |
2. 什么是 Protobuf?
2.1 概念
Protobuf(Protocol Buffers)是 Google 开发的一种高效、跨平台、可扩展的数据序列化协议。它可以将数据转换为紧凑的二进制格式,用于不同系统之间进行高效的数据传输和存储。
简单理解:
- 它类似于 JSON,但比 JSON 体积更小、速度更快。
- 它类似于 XML,但格式更紧凑、解析更高效。
- 它适用于微服务、RPC(远程调用)、数据存储等高性能场景。
2.2 为什么使用 Protobuf?
特点 | Protobuf | JSON | XML |
---|---|---|---|
格式 | 二进制 | 文本 | 文本 |
体积 | 最小 | 较大 | 最大 |
解析速度 | 最快 | 一般 | 最慢 |
可读性 | 不可读 | 可读 | 可读 |
跨语言支持 | 是 | 是 | 是 |
支持 RPC | 是(gRPC) | 否 | 否 |
如果你的项目涉及:
- 高性能数据通信(微服务、RPC、物联网、游戏服务器)
- 跨语言数据传输(Go、Java、Python、C++、Rust 等)
- 大规模数据存储(日志、数据库、缓存)
那么 Protobuf 是比 JSON、XML 更好的选择。
2.3 Protobuf 的使用场景
- 微服务通信(gRPC)
- 适用于 Go、Java、Python、C++ 等语言的微服务之间高效通信。
- 结合
gRPC
使用,可以比传统REST API
更快。
- 数据存储
- 存储日志、缓存数据(如存入 Redis)时,Protobuf 体积小,能节省存储空间。
- 跨语言数据交换
- 由于 Protobuf 支持多种编程语言,可以在不同语言的系统之间进行高效数据传输。
- 移动端和 IoT(物联网)
- 移动端和 IoT 设备通常带宽和存储受限,Protobuf 适用于传输小体积数据,提高性能。
3. 简单解释 Protobuf 例子
3.1 Protobuf 文件 simple.proto
syntax = "proto3"; // 使用 proto3 语法
message SearchRequest { // 定义一个数据结构(类似 JSON 对象)
string query = 1; // 搜索关键词(字符串)
int32 page_number = 2; // 页码(整数)
int32 result_per_page = 3; // 每页返回的结果数(整数)
}
解释
syntax = "proto3";
指定使用proto3
语法。message SearchRequest
定义了一个数据结构(类似 JSON 对象)。- 每个字段的格式:
- 类型(
string
、int32
) - 字段名称(
query
、page_number
、result_per_page
) - 字段编号(
1
、2
、3
,用于唯一标识字段,不能重复)
- 类型(
3.2 编译 Protobuf 代码
Protobuf 需要编译后才能用于编程语言(Go、Java、Python 等)。 在终端运行:
protoc --go_out=. simple.proto
protoc
是 Protobuf 编译器--go_out=.
表示生成 Go 代码,并存放在当前目录simple.proto
是需要编译的 Protobuf 文件
不同语言对应的参数:
语言 | 编译参数 |
---|---|
C++ | --cpp_out=. |
Java | --java_out=. |
Python | --python_out=. |
C# | --csharp_out=. |
Rust | --rust_out=. |
最终会生成 simple.pb.go
,这个文件包含 Go 代码,用于操作 SearchRequest
结构。
3.3 生成的 Go 代码
编译后会生成如下 Go 结构:
type SearchRequest struct {
Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"`
PageNumber int32 `protobuf:"varint,2,opt,name=page_number,json=pageNumber,proto3" json:"page_number,omitempty"`
ResultPerPage int32 `protobuf:"varint,3,opt,name=result_per_page,json=resultPerPage,proto3" json:"result_per_page,omitempty"`
}
解释
SearchRequest
是struct
,对应.proto
文件中的message SearchRequest
。Query
、PageNumber
、ResultPerPage
变量对应.proto
里的字段。protobuf:"..."
里的信息用于 Protobuf 序列化和解析。
3.4 如何使用这个 Go 结构
package main
import (
"fmt"
"google.golang.org/protobuf/proto"
)
func main() {
// 创建 SearchRequest 实例
request := &SearchRequest{
Query: "golang protobuf",
PageNumber: 1,
ResultPerPage: 10,
}
// **序列化**
data, _ := proto.Marshal(request)
// **反序列化**
newRequest := &SearchRequest{}
proto.Unmarshal(data, newRequest)
fmt.Println(newRequest) // 输出: {Query:golang protobuf PageNumber:1 ResultPerPage:10}
}
解释
- 创建
SearchRequest
结构,并填充数据。 - 使用
proto.Marshal(request)
序列化,转换成二进制格式(适合网络传输)。 - 使用
proto.Unmarshal(data, newRequest)
反序列化,把二进制恢复成 Go 结构。
4. Protobuf 的数据类型
4.1 标量数据类型(Scalar Types)
Protobuf 提供了一些常见的基本数据类型,对应不同语言的变量类型。
4.1.1 数值类型
Protobuf 类型 | 说明 | 适用场景 |
---|---|---|
int32 | 32 位整数(默认编码) | 适用于较小的整数 |
int64 | 64 位整数(默认编码) | 适用于较大的整数 |
uint32 | 无符号 32 位整数 | 适用于只能为正数的情况 |
uint64 | 无符号 64 位整数 | 适用于大数且不允许负数 |
sint32 | 32 位有符号整数(ZigZag 编码) | 适用于可能包含负数的整数 |
sint64 | 64 位有符号整数(ZigZag 编码) | 适用于包含负数的长整数 |
fixed32 | 32 位整数(固定长度编码) | 适用于数值分布较均匀的场景 |
fixed64 | 64 位整数(固定长度编码) | 适用于较大的定长整数 |
sfixed32 | 32 位有符号整数(固定长度编码) | 适用于负数较多的场景 |
sfixed64 | 64 位有符号整数(固定长度编码) | 适用于较大的负数 |
区别:
int32/int64
:默认使用 Varint 编码(数据小的时候占用字节更少)。sint32/sint64
:使用 ZigZag 编码,负数编码更高效。fixed32/fixed64
:使用固定长度存储,适合数值分布均匀的情况。sfixed32/sfixed64
:固定长度的有符号整数。
4.1.2 浮点数类型
Protobuf 类型 | 说明 | 适用场景 |
---|---|---|
float | 32 位浮点数 | 适用于存储小数 |
double | 64 位浮点数 | 适用于更高精度的小数 |
注意:
float
占 4 个字节,精度有限。double
占 8 个字节,适用于更高精度计算。
4.1.3 布尔类型
Protobuf 类型 | 说明 | 适用场景 |
---|---|---|
bool | 布尔值 (true/false ) | 适用于开关、状态等 |
示例:
message Example {
bool is_active = 1; // true or false
}
4.1.4 字符串和字节类型
Protobuf 类型 | 说明 | 适用场景 |
---|---|---|
string | UTF-8 编码的字符串 | 存储文本信息 |
bytes | 原始字节数据 | 适用于存储二进制数据(如文件、图片等) |
示例:
message Example {
string name = 1;
bytes file_data = 2;
}
注意:
string
只能存储 文本(UTF-8 编码)。bytes
可以存储 任意二进制数据(如图片、视频等)。
4.2 复杂数据类型
4.2.1 数组(Repeated)
使用 repeated
关键字表示 列表/数组:
message Example {
repeated string hobbies = 1;
repeated int32 scores = 2;
}
repeated string hobbies = 1;
→ 表示字符串数组repeated int32 scores = 2;
→ 表示整数数组
注意:
- 在 Protobuf 3 中,
repeated
类型默认是可选的,不需要额外的optional
关键字。
4.2.2 键值对(Map)
Protobuf 3 提供 map<K, V>
类型来存储键值对:
message Example {
map<string, int32> scores = 1; // key: string, value: int32
}
map<string, int32>
表示 键为字符串,值为整数 的字典。- 生成代码后,会转换成 Go 语言的
map[string]int32
。
4.2.3 枚举类型(Enum)
enum Status {
UNKNOWN = 0; // 枚举必须从 0 开始
ACTIVE = 1;
INACTIVE = 2;
}
message User {
Status status = 1;
}
enum
只能用于定义固定的值(类似int
)。- 第一个枚举值必须是 0,防止解析错误。
4.2.4 嵌套 Message
message Address {
string city = 1;
string street = 2;
}
message Person {
string name = 1;
Address address = 2; // 直接嵌套 Address
}
Person
结构里包含Address
结构,可以用于复杂数据存储。
4.3 Protobuf 类型与不同语言的对应关系
Protobuf 类型 | Go | Java | Python | C++ |
---|---|---|---|---|
int32 | int32 | int | int | int32_t |
int64 | int64 | long | int | int64_t |
float | float32 | float | float | float |
double | float64 | double | float | double |
bool | bool | boolean | bool | bool |
string | string | String | str | std::string |
bytes | []byte | byte[] | bytes | std::string |
map<K,V> | map[K]V | Map<K,V> | dict | std::map<K,V> |
repeated | []T | List<T> | list | std::vector<T> |
4.4 Protobuf 3 语法示例
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
bool is_active = 3;
repeated string hobbies = 4;
map<string, int32> scores = 5;
}
这个 Person
结构包含:
string name
→ 姓名int32 age
→ 年龄bool is_active
→ 是否激活repeated string hobbies
→ 兴趣爱好(数组)map<string, int32> scores
→ 课程成绩(键值对)
5. Protobuf 其他字段
5.1 Oneof(互斥字段)
5.1.1 什么是 oneof
?
oneof
关键字用于定义一组互斥字段,即同一时间只能有一个字段被设置。它的作用类似于 C 语言的 union
,但比 union
更智能,可以判断当前设置的是哪个字段。
5.1.2 为什么要用 oneof
?
在 proto3
版本中,所有字段都有默认值,比如:
message Example {
int64 id = 1;
}
- 如果
id
没有被设置,默认值是0
。 - 但如果
id
被显式设置为0
,你就无法判断这个0
是默认值,还是用户真的设置了0
。
oneof
解决了这个问题,因为它提供了一个字段状态检查功能,让你可以判断哪个字段被设置了。
5.1.3 oneof
语法
message Response {
oneof result {
string success_message = 1; // 成功时的消息
int32 error_code = 2; // 失败时的错误码
}
}
oneof
内的字段是互斥的,最多只能设置一个。- 如果
success_message
被设置,error_code
就不能被设置,反之亦然。 - 如果不设置任何字段,
oneof
字段为空。
适用场景
- API 响应(成功返回
success_message
,失败返回error_code
)。- 状态表示(例如订单可能是“待支付”或“已完成”,但不能同时处于这两个状态)。
5.1.4 oneof
在 Go 语言中的使用
Protobuf 生成的 Go
代码会使用 isXxx()
方法 来判断哪个字段被赋值。
示例:Go 代码
package main
import (
"fmt"
"google.golang.org/protobuf/proto"
)
// 假设 Protobuf 生成的 Go 结构如下:
type Response struct {
// 这是 oneof 生成的字段
Result isResponse_Result `protobuf_oneof:"result"`
}
type isResponse_Result interface {
isResponse_Result()
}
type Response_SuccessMessage struct {
SuccessMessage string
}
type Response_ErrorCode struct {
ErrorCode int32
}
// 实现 isResponse_Result 接口
func (*Response_SuccessMessage) isResponse_Result() {}
func (*Response_ErrorCode) isResponse_Result() {}
func main() {
// **成功时返回 success_message**
resp1 := &Response{Result: &Response_SuccessMessage{SuccessMessage: "Operation successful"}}
// **失败时返回 error_code**
resp2 := &Response{Result: &Response_ErrorCode{ErrorCode: 404}}
// 判断是哪个字段被设置
switch v := resp1.Result.(type) {
case *Response_SuccessMessage:
fmt.Println("Success:", v.SuccessMessage)
case *Response_ErrorCode:
fmt.Println("Error:", v.ErrorCode)
}
switch v := resp2.Result.(type) {
case *Response_SuccessMessage:
fmt.Println("Success:", v.SuccessMessage)
case *Response_ErrorCode:
fmt.Println("Error:", v.ErrorCode)
}
}
输出
Success: Operation successful
Error: 404
oneof
生成了isResponse_Result
接口,允许我们判断哪个字段被设置。switch v := resp.Result.(type)
语法用于检查当前oneof
字段的类型。
5.1.5 oneof
的应用场景
- REST API / gRPC 响应(成功返回数据,失败返回错误码)
- 订单状态(
未支付
/已支付
) - 用户身份验证(
邮箱登录
/手机号登录
) - 存储不同类型的数据(
文本
/图片
/视频
)
5.2 Reserved(保留字段)
5.2.1 什么是reserved
?
在 Protobuf 3 中,reserved
关键字用于保留字段编号或名称,防止将来代码演进时误用已删除的字段。
这可以避免 API 变更时的兼容性问题,确保旧数据不会被错误解析。
5.2.2 为什么需要 reserved
?
当你删除或修改字段时,如果不使用 reserved
,那么:
- 未来新添加的字段可能意外复用旧的字段编号,导致数据解析出错。
- 旧数据仍然可能使用被删除的字段,导致意外行为。
5.2.3 reserved
语法
你可以用 reserved
关键字保留字段编号或名称,防止后续被重新使用。
保留字段编号
message User {
reserved 2, 4 to 6; // 不能再使用编号 2、4、5、6
}
- 以后不能再使用 2、4、5、6 作为字段编号。
- 如果后续尝试用
field = 2;
,编译时会报错。
保留字段名称
message User {
reserved "old_name", "deprecated_field"; // 不能再使用这些字段名
}
- 以后不能再使用 “old_name” 和 “deprecated_field” 作为字段名。
同时保留编号和名称
message User {
reserved 2, 4 to 6;
reserved "old_name", "deprecated_field";
}
- 这样可以同时保留编号和字段名称,防止意外复用。
5.2.4 reserved
作用
- 避免旧数据解析错误:如果编号被误用,旧数据可能被错误解析。
- 防止 API 兼容性问题:如果 API 变更,保留字段可以确保旧客户端不会收到无效数据。
- 让代码更可维护:明确告诉后续开发者哪些字段不能使用。
5.3 Any(存储任意数据)
5.3.1 什么是 Any
?
Any
是 Protobuf 3 提供的一种特殊类型,允许存储任意类型的 Protobuf 消息,适用于动态数据场景。
它可以在不修改 .proto
结构的情况下,支持不同类型的数据,类似于 JSON 里的 object
或 map<string, any>
。
5.3.2 Any
的作用
- 存储动态数据:如果一个字段的类型可能变化(例如可能是
User
或Order
),可以使用Any
而不需要改.proto
文件。 - 实现灵活的 API 设计:适用于插件系统、事件系统、日志系统,让不同的子系统传递不同的数据结构。
- 避免频繁修改 Protobuf 定义:当不同的客户端需要传输不同的数据类型时,使用
Any
可以减少 API 变更的影响。
5.3.3 Any
的基本用法
(1)导入 Any
类型
Any
需要导入 google/protobuf/any.proto
:
import "google/protobuf/any.proto"; // 引入 Any 类型
message Response {
string message = 1;
google.protobuf.Any data = 2; // 存储任意类型
}
message
是普通字段,存储文本信息。data
是Any
类型,可以存储任何 Protobuf 消息。
(2)嵌套不同的消息
假设你有两种不同的消息 User
和 Order
:
message User {
string name = 1;
int32 age = 2;
}
message Order {
int32 order_id = 1;
double price = 2;
}
message Response {
string message = 1;
google.protobuf.Any data = 2; // 可以存储 User 或 Order
}
data
字段可以存储User
或Order
,而不需要修改Response
结构。- 这样,
Response
可以在不同场景下使用,不受数据类型影响。
5.3.4 Any
在 Go 语言中的使用
(1)安装 Protobuf 依赖
在 Go 代码中,需要 proto
和 anypb
(处理 Any
类型):
go get google.golang.org/protobuf/proto
go get google.golang.org/protobuf/types/known/anypb
(2)Go 代码示例
package main
import (
"fmt"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// 定义 User 和 Order 结构
type User struct {
Name string
Age int32
}
type Order struct {
OrderId int32
Price float64
}
// Response 结构,包含 Any 字段
type Response struct {
Message string
Data *anypb.Any
}
func main() {
// 创建 User 结构
user := &User{Name: "Alice", Age: 25}
// 将 User 结构封装到 Any 里
anyData, _ := anypb.New(user)
// 创建 Response 并存储 User 数据
resp := &Response{
Message: "User data",
Data: anyData,
}
// **序列化**
data, _ := proto.Marshal(resp)
// **反序列化**
newResp := &Response{}
proto.Unmarshal(data, newResp)
// **解析 Any 字段**
newUser := &User{}
newResp.Data.UnmarshalTo(newUser)
fmt.Println("Message:", newResp.Message)
fmt.Println("User Name:", newUser.Name, "Age:", newUser.Age)
}
(3)Go 代码解释
- 封装数据:
- 使用
anypb.New(user)
把User
结构转换成Any
类型。
- 使用
- 序列化
Response
:- 使用
proto.Marshal(resp)
进行序列化,便于存储或传输。
- 使用
- 反序列化
Response
:- 使用
proto.Unmarshal(data, newResp)
解析Response
结构。
- 使用
- 解析
Any
数据:newResp.Data.UnmarshalTo(newUser)
解析Any
字段,恢复User
结构。
6. Protobuf 编码原理
Protobuf 使用高效的二进制格式来存储和传输数据,其中最关键的编码方式之一是 Varint(变长整数编码)。它的核心思想是:
- 数值越小,占用字节越少
- 数值越大,占用字节越多
- 高效存储,减少带宽消耗
6.1 什么是 Varint?
Varint(变长整数编码) 是一种特殊的编码方式,它可以使用 1 到 N 个字节 表示整数。
- 小数占用更少字节(如
1
只需要 1 个字节)。 - 大数会自动扩展到多个字节(如
300
需要 2 个字节)。
6.2 Varint 编码规则
- 每个字节的最高位(MSB,Most Significant Bit)是“是否还有后续字节的标志”
- 最高位为
0
:表示这是最后一个字节。 - 最高位为
1
:表示后面还有字节。
- 最高位为
- 剩下的 7 位存储数据(低位优先,LSB)。
6.3 具体示例
(1)数字 1
的 Varint 编码
0000 0001 (只有 1 个字节)
- 最高位
0
:表示这是最后一个字节。 - 其余 7 位
000 0001
(= 1)。
存储方式:
[0000 0001] → 1 字节
(2)数字 300
的 Varint 编码
先看二进制表示:
300 = 100101100(9 位)
需要拆成 7 位 + 剩余部分:
低 7 位: 0101100 → 0x2C(44)
高 2 位: 0000010 → 0x02(2)
- 第一字节:
1010 1100
(0xAC)- 最高位
1
(表示后面还有字节)。 - 剩余
7
位存010 1100
(= 44)。
- 最高位
- 第二字节:
0000 0010
(0x02)- 最高位
0
(表示这是最后一个字节)。 - 剩余
7
位存000 0010
(= 2)。
- 最高位
最终编码
[1010 1100] [0000 0010] → 2 字节(0xAC 0x02)
6.4 Wire Type(数据类型编码)
Protobuf 数据存储为 键值对(key-value) 形式,每个字段的 key
也需要编码。
字段的 key
由字段编号 + Wire Type 组成。
Wire Type | 值 | 作用 |
---|---|---|
Varint | 0 | 变长整数(int32, int64, bool, enum ) |
Fixed64 | 1 | 64 位定长(double, fixed64, sfixed64 ) |
Length-delimited | 2 | 变长数据(string, bytes, message, repeated ) |
Start group | 3 | 已废弃(用于嵌套数据) |
End group | 4 | 已废弃 |
Fixed32 | 5 | 32 位定长(float, fixed32, sfixed32 ) |
存储格式
[字段编号 << 3] | [Wire Type] [数据]
字段编号左移 3 位,低 3 位存 Wire Type。
6.5 例子:Protobuf 编码解析
假设 Person
结构如下:
message Person {
int32 id = 1; // 1 字段编号
string name = 2; // 2 字段编号
}
数据:
{
"id": 150,
"name": "Alice"
}
编码过程
-
字段
id = 150
- 字段编号 =
1
- Wire Type =
0
(Varint) key = (1 << 3) | 0 = 0000 1000 (0x08)
150
的 Varint 编码:1001 0110 0000 0001
(0x96 0x01)
最终存储:
[0x08] [0x96 0x01] (字段编号 1,Varint)
- 字段编号 =
-
字段
name = "Alice"
- 字段编号 =
2
- Wire Type =
2
(Length-delimited,字符串) key = (2 << 3) | 2 = 0001 0001 (0x12)
"Alice"
= 5 个字节(0x41 0x6C 0x69 0x63 0x65
)
最终存储:
[0x12] [0x05] [0x41 0x6C 0x69 0x63 0x65]
- 字段编号 =
6.6 解析 Protobuf 二进制数据
假设收到如下二进制数据:
08 96 01 12 05 41 6C 69 63 65
逐字节解析:
08
=0000 1000
(字段编号1
,Wire Type0
,Varint)96 01
= 150(Varint 解码)12
=0001 0010
(字段编号2
,Wire Type2
,字符串)05
= 长度5
41 6C 69 63 65
="Alice"
最终解析为:
{
"id": 150,
"name": "Alice"
}