All writing Notes & observations

About Protocol Buffer

Protobuf:更高效的结构化数据序列化格式

在这份文档中,我们将从最基本的概念开始,一步步深入了解 Google 开发的 Protocol Buffers(简称 Protobuf),掌握它的语法、编译流程以及在实际项目中的最佳实践。

1. 什么是 Protobuf?

Protocol Buffers 是一种轻便高效的结构化数据存储格式,可以用于结构化数据的序列化(Serialization)。

它非常适合做数据存储或作为网络通信的数据交换格式。

你可以把它理解为 “增强版、二进制化、带严格约束的 JSON”。

序列化与反序列化

  • 序列化 (Serialization): 将程序中的对象或数据结构转换成一种特定的格式(如二进制流、JSON 字符串),以便在网络上传输或保存到文件中。
  • 反序列化 (Deserialization): 将网络上接收到的或文件中读取的格式化数据,重新还原成程序中的对象或数据结构.

2. 为什么要用 Protobuf?(对比 JSON/XML)

在 Web 开发中,JSON 是绝对的霸主,那为什么还要学 Protobuf?

优势:

  1. 体积小、省带宽: Protobuf 采用二进制编码(如 Varint 技术),去掉了 JSON 中的 {}、:、双引号以及字段名本身(仅保留字段编号)。相比 JSON,体积通常能缩小 3 到 10 倍。
  2. 解析速度快: 计算机处理二进制数据远比解析字符串(JSON/XML)快得多。Protobuf 的解析速度通常是 JSON 的 20 到 100 倍。
  3. 跨语言、跨平台: 只需维护一份 .proto 接口文件,就能自动生成 C++、Java、Python、Go 等几乎所有主流语言的代码。
  4. 极强的向前/向后兼容性: 只要遵守一定的规则(不修改现有字段的编号),你可以随意增加新字段,老版本的代码依然能完美解析新数据(自动忽略未知字段)。

劣势:

  1. 可读性差: 序列化后是纯二进制数据,人类无法直接阅读(需要工具转码)。
  2. 缺乏自描述性: 只有二进制数据是没用的,必须配合对应的 .proto 结构文件才能反序列化。而 JSON 本身就包含了字段名(自描述)。

总结: 如果是对性能、带宽要求极高的场景(如微服务内部的 gRPC 调用、大型多人在线游戏同步、海量数据存储),Protobuf 是首选;如果是对外的 Web API 接口,JSON 依然是最好的选择。

3. Protobuf 的工作流

使用 Protobuf 通常遵循以下三大步:

  1. 编写 .proto 文件: 定义数据结构(Message)。
  2. 编译 .proto 文件: 使用 protoc 编译器,将协议文件编译成目标语言(如 Go/Python/Java)的源码文件。
  3. 在业务代码中使用: 引入生成的源码文件,调用现成的 API 进行数据的赋值和序列化。

4. 动手实战:编写你的第一个 .proto 文件

目前主流使用的是 proto3 语法(相比旧版的 proto2 更简洁)。我们来定义一个“用户资料”的数据结构。

创建一个名为 user.proto 的文件,写入以下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 1. 指定使用 proto3 语法,必须写在第一行  
syntax = "proto3";

// 2. 定义包名,防止不同项目间的命名冲突  
package tutorial;

// 3. (Go语言专属配置) 为生成的 Go 代码指定特定路径或包名。这里表示生成在当前目录下的 pb 文件夹中。  
option go_package = "./pb"; 

// 4. 定义数据结构,使用 message 关键字,类似于编程语言中的 struct 或 class  
message User {  
  // 格式:[字段规则] 数据类型 字段名 = 字段编号;

  int32 id = 1;               // 用户的唯一 ID  
  string username = 2;        // 用户名  
  string email = 3;           // 邮箱  
  bool is_active = 4;         // 账号是否激活  
}

核心概念解析:

  • message:定义数据的关键字,里面包含多个字段。
  • 数据类型:int32, string, bool 等。
  • 字段编号 (= 1, = 2):这是 Protobuf 最核心的设计! * 在二进制数据中,Protobuf 不保存字段名(不保存 “username” 这个字符串),只保存编号(2)。
    • 编号 1 到 15 在编码时只占用 1 个字节,16 到 2047 占用 2 个字节。因此,应该把最常使用的字段编号设为 1 到 15。
    • 一旦投入使用,千万不要更改现有字段的编号!

5. 进阶语法:复合数据类型

真实的业务数据往往很复杂,Protobuf 提供了丰富的类型来应对:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
syntax = "proto3";  
package tutorial;  
option go_package = "./pb";

// 定义一个枚举类型  
enum PhoneType {  
  MOBILE = 0;  // proto3 中,枚举的第一个值必须是 0(作为默认值)  
  HOME = 1;  
  WORK = 2;  
}

message PhoneNumber {  
  string number = 1;  
  PhoneType type = 2;  
}

message UserProfile {  
  int32 id = 1;  
  string name = 2;  
    
  // 1. 数组 / 列表:使用 repeated 关键字 (Go中会生成切片 slice)  
  repeated string hobbies = 3;   
    
  // 2. 嵌套 Message:引用其他的 message 作为类型  
  repeated PhoneNumber phones = 4;  
    
  // 3. 字典 / 键值对:使用 map 关键字 (Go中会生成 map)  
  map<string, string> attributes = 5; // 例如: {"level": "vip", "region": "cn"}  
    
  // 4. 互斥字段:oneof 里面的字段同时只能有一个被设置(节省空间)  
  oneof avatar {  
    string image_url = 6;  
    bytes image_data = 7; // 直接存图片的二进制数据  
  }  
}

6. 编译 .proto 文件 (生成 Go 代码)

为了将 .proto 文件转化为 Go 语言的 struct 结构体,你需要安装编译器和 Go 的专属插件。

  1. 安装 protoc 编译器: 可以从 Github 的 Protocol Buffers Releases 页面下载对应系统的压缩包并配置环境变量。

  2. 安装 Go 语言代码生成插件: 打开终端,运行以下命令(确保你的 GOPATH/bin 已经加入了系统的环境变量中):
    go install google.golang.org/protobuf/cmd/protoc-gen-go@latest

  3. 执行编译命令: 在 user.proto 文件所在的目录下运行:

1
2
3
4
   # --go_out=. 指定生成的 Go 代码存放到当前目录  
   # 结合代码中的 option go_package = "./pb";  
   # 最终会在当前目录下自动创建一个 pb 文件夹,里面有生成的 user.pb.go 文件  
   protoc --go_out=. user.proto

编译完成后,你的项目里会多出一个 pb/user.pb.go 文件。不要手动去修改这个文件,它是编译器自动生成的。

7. 在代码中使用 (以 Go 为例)

为了在项目中使用,我们需要先下载 Go 的 protobuf 核心库:

1
go get google.golang.org/protobuf/proto

接下来,我们编写 main.go 来演示极致的序列化体验:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main

import (  
	"fmt"  
	"log"

	"google.golang.org/protobuf/proto"  
	// 引入刚刚生成的 pb 包 (将 "your_project" 替换为你的实际 module 名称)  
	// 例如: "\[github.com/myname/myproject/pb\](https://github.com/myname/myproject/pb)"  
	"your_project/pb"   
)

func main() {  
	// 1. 实例化 Message 对象 (对应 Go 中的 Struct)  
	user := &pb.User{  
		Id:       1001,  
		Username: "Alice",  
		Email:    "alice@example.com",  
		IsActive: true,  
	}

	// 2. 序列化为二进制流 (发送给网络或保存到文件)  
	data, err := proto.Marshal(user)  
	if err != nil {  
		log.Fatalf("序列化失败: %v", err)  
	}  
	  
	fmt.Printf("序列化成功!数据长度: %d 字节\n", len(data))  
	// 打印原始二进制数据(通常是乱码或十六进制)  
	fmt.Printf("二进制数据: %x\n\n", data) 

	// 3. 反序列化 (从网络接收到二进制数据后还原)  
	// 先创建一个空的 User 结构体指针  
	newUser := &pb.User{}   
	  
	// 解析二进制数据 data,将结果反推入 newUser 中  
	err = proto.Unmarshal(data, newUser)  
	if err != nil {  
		log.Fatalf("反序列化失败: %v", err)  
	}

	// 4. 优雅地访问数据  
	// 生成的代码为每个字段提供了 Getter 方法 (如 GetId(), GetUsername())  
	// 推荐使用 Getter 方法,因为它们能在指针为空时安全地返回默认值  
	fmt.Println("反序列化还原的数据:")  
	fmt.Printf("ID: %d\n", newUser.GetId())  
	fmt.Printf("姓名: %s\n", newUser.GetUsername())  
	fmt.Printf("邮箱: %s\n", newUser.GetEmail())  
	fmt.Printf("账号状态: %v\n", newUser.GetIsActive())  
}

8. Protobuf 核心最佳实践 (避坑指南)

在团队协作和系统迭代中,如果不遵守规范,很容易导致严重的线上事故(如数据解析错乱)。请牢记以下几点:

规则 1:绝对不要修改已有字段的编号!

如果 username = 2;,你把它改成了 username = 3;。那么老版本的客户端发来的数据(带着编号2),新版服务端就再也认不出来了。

规则 2:不要直接删除字段,使用 reserved

如果业务变更,email 字段不再使用了。不要直接把那行代码删掉!

因为如果直接删掉,以后的新同事可能会不知道,把编号 3 分配给了新字段,导致老版本的数据被错误解析。

正确做法:将废弃的编号和名称“保留”起来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
message User {  
  // 告诉编译器,编号 3 和字段名 email 已被预留,任何人不得再次使用  
  reserved 3;  
  reserved "email";

  int32 id = 1;  
  string username = 2;  
  // string email = 3;  // 注释掉或删掉  
  bool is_active = 4;  
}

规则 3:所有字段都是可选的 (proto3)

在 proto3 中,取消了 required(必填)关键字。如果一个字段没有被赋值,序列化时就不会包含它;反序列化时,会赋予它一个默认值(如数字默认是 0,布尔默认是 false,字符串默认是空字符串)。

业务逻辑的“必填校验”应该放在代码层做,而不是协议层。

结语

Protobuf 通过放弃部分人类可读性,换取了机器处理的极致效率。当你掌握了它,就等于掌握了现代微服务架构(特别是 gRPC 体系)中最核心的数据通信基石。希望这份文档能帮助你顺利将 Protobuf 应用到 Go 项目中!