Protocol Buffers
Protocol Buffers(简称 protobuf)是 Google 开发的一种数据序列化格式,比 JSON 和 XML 更小、更快、更简单。它是 gRPC 的基础,用于定义服务接口和数据结构。
什么是 Protocol Buffers?
Protocol Buffers 是一种与语言无关、平台无关的可扩展机制,用于序列化结构化数据。它的主要优势在于:
| 特性 | Protocol Buffers | JSON | XML |
|---|---|---|---|
| 数据大小 | 小(二进制) | 中等(文本) | 大(文本) |
| 解析速度 | 快 | 中等 | 慢 |
| 可读性 | 需要解码 | 高 | 高 |
| 类型安全 | 强类型 | 弱类型 | 弱类型 |
| Schema | 必需 | 可选 | 可选(DTD/XSD) |
Proto3 语法
gRPC 推荐使用 Proto3 语法,它比 Proto2 更简洁,支持更多语言。
基本结构
// 指定语法版本
syntax = "proto3";
// 包名,防止命名冲突
package mypackage;
// Go 语言的包路径
option go_package = "./mypackage";
// Java 的包名
option java_package = "com.example.mypackage";
// 消息定义
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
字段编号
每个字段都有一个唯一的编号,用于在二进制格式中标识字段:
message Example {
string field1 = 1; // 编号 1
int32 field2 = 2; // 编号 2
bool field3 = 3; // 编号 3
// 编号 4-15 占用1字节
// 编号 16-2047 占用2字节
// 建议频繁使用的字段使用 1-15
}
重要规则:
- 编号 1-15 用一个字节编码,建议用于频繁使用的字段
- 编号 19000-19999 保留给 Protocol Buffers 内部使用
- 一旦字段被使用,编号不能改变(向后兼容)
数据类型
标量类型
message Scalars {
// 数值类型
double double_val = 1; // 64位浮点数
float float_val = 2; // 32位浮点数
int32 int32_val = 3; // 32位整数(负数效率低)
int64 int64_val = 4; // 64位整数(负数效率低)
uint32 uint32_val = 5; // 无符号32位整数
uint64 uint64_val = 6; // 无符号64位整数
sint32 sint32_val = 7; // 有符号32位整数(负数效率高)
sint64 sint64_val = 8; // 有符号64位整数(负数效率高)
fixed32 fixed32_val = 9; // 固定长度32位无符号
fixed64 fixed64_val = 10; // 固定长度64位无符号
sfixed32 sfixed32_val = 11; // 固定长度32位有符号
sfixed64 sfixed64_val = 12; // 固定长度64位有签名
// 布尔类型
bool bool_val = 13;
// 字符串类型
string string_val = 14; // UTF-8 编码字符串
// 字节类型
bytes bytes_val = 15; // 任意字节序列
}
类型选择建议:
- 普通整数:使用
int32或int64 - 可能为负的整数:使用
sint32或sint64 - 大正整数:使用
uint32或uint64 - 需要固定大小:使用
fixed32或fixed64
枚举类型
enum Status {
UNKNOWN = 0; // 第一个值必须是0
ACTIVE = 1;
INACTIVE = 2;
DELETED = 3;
}
message User {
string name = 1;
Status status = 2;
}
枚举规则:
- 第一个枚举值必须为 0
- 0 值是默认值
- 可以使用
reserved保留已删除的值
enum Status {
option allow_alias = true; // 允许别名
UNKNOWN = 0;
ACTIVE = 1;
ENABLED = 1; // 别名
reserved 2, 3, 4 to 10; // 保留的编号
reserved "DELETED", "BANNED"; // 保留的名称
}
复合类型
嵌套消息
message Address {
string street = 1;
string city = 2;
string country = 3;
}
message Person {
string name = 1;
Address address = 2; // 嵌套消息
}
// 或内部定义
message Company {
string name = 1;
message Department {
string name = 1;
int32 employee_count = 2;
}
Department department = 2;
}
数组(重复字段)
message Book {
string title = 1;
repeated string authors = 2; // 字符串数组
repeated int32 ratings = 3; // 整数数组
}
Map 类型
message Config {
map<string, string> settings = 1;
map<int32, double> scores = 2;
}
Map 注意事项:
- 不支持枚举类型作为 key
- Map 是无序的
- Map 不能是 repeated
Oneof 类型
oneof 用于表示一组字段中只能同时设置一个的场景,类似于 C 语言中的联合体(union)。这对于需要在一个字段中存储多种可能类型的数据非常有用。
message Notification {
string id = 1;
// 只能设置其中一个字段
oneof channel {
string email = 2; // 邮件通知
string phone = 3; // 短信通知
string webhook = 4; // Webhook 通知
}
}
工作原理:
当你设置 oneof 中的任意一个字段时,其他字段会自动被清空。这是 oneof 的核心特性,确保同一时间只有一个字段有值。
// Go 示例
notif := &Notification{
Channel: &Notification_Email{"[email protected]"}, // 设置 email
}
// 此时 phone 和 webhook 都为 nil
// 切换到 phone
notif.Channel = &Notification_Phone{"13800138000"}
// 此时 email 被自动清空,只有 phone 有值
检查当前设置的是哪个字段:
switch c := notif.Channel.(type) {
case *Notification_Email:
fmt.Printf("邮件通知: %s\n", c.Email)
case *Notification_Phone:
fmt.Printf("短信通知: %s\n", c.Phone)
case *Notification_Webhook:
fmt.Printf("Webhook: %s\n", c.Webhook)
default:
fmt.Println("未设置通知渠道")
}
Oneof 使用场景:
- 表示多种可选的配置类型
- 不同类型的事件或消息
- 多种认证方式的选择
- API 响应中不同类型的返回数据
// 实际案例:API 响应
message ApiResponse {
int32 code = 1;
string message = 2;
oneof data {
User user = 3; // 用户数据
Order order = 4; // 订单数据
ErrorResponse error = 5; // 错误详情
}
}
// 实际案例:支付方式
message Payment {
string order_id = 1;
double amount = 2;
oneof method {
CreditCard credit_card = 3; // 信用卡
BankTransfer bank = 4; // 银行转账
DigitalWallet wallet = 5; // 数字钱包
}
}
注意事项:
oneof字段不能是repeatedoneof字段不能使用 map 类型- 设置
oneof字段会清空其他字段,这在更新时可能导致数据丢失
Any 类型
Any 类型允许你在不知道具体类型的情况下嵌入任意消息,类似于动态类型。它是 Protocol Buffers 提供的 Well-Known Types 之一。
import "google/protobuf/any.proto";
message Response {
string status = 1;
google.protobuf.Any payload = 2; // 可以包含任意消息类型
}
使用示例:
import "google.golang.org/protobuf/types/known/anypb"
// 打包消息到 Any
user := &User{Name: "张三", Email: "[email protected]"}
anyUser, _ := anypb.New(user)
response := &Response{
Status: "success",
Payload: anyUser,
}
// 从 Any 解包消息
if response.Payload.MessageIs(&User{}) {
user := &User{}
response.Payload.UnmarshalTo(user)
fmt.Printf("用户: %s\n", user.Name)
}
Python 示例:
from google.protobuf.any_pb2 import Any
from google.protobuf.message import DecodeError
# 打包
any_msg = Any()
any_msg.Pack(user_message)
# 解包
if any_msg.Is(User.DESCRIPTOR):
user = User()
any_msg.Unpack(user)
Any 类型的使用场景:
- API 响应中返回不同类型的数据
- 消息队列中传递不同类型的消息
- 插件系统中的动态消息处理
- 需要向后兼容的扩展字段
Any vs Oneof 选择:
| 特性 | Any | Oneof |
|---|---|---|
| 类型安全 | 运行时检查 | 编译时检查 |
| 可扩展性 | 高,无需修改 proto | 需要修改 proto |
| 性能 | 需要序列化/反序列化 | 直接访问 |
| 适用场景 | 开放类型系统 | 固定类型集合 |
Well-Known Types
Protocol Buffers 提供了一组预定义的标准消息类型,称为 Well-Known Types,位于 google.protobuf 包中。这些类型解决了常见的数据表示需求。
Timestamp - 时间戳
import "google/protobuf/timestamp.proto";
message Event {
string id = 1;
string name = 2;
google.protobuf.Timestamp created_at = 3;
google.protobuf.Timestamp updated_at = 4;
}
Go 使用示例:
import "google.golang.org/protobuf/types/known/timestamppb"
// 创建时间戳
event := &Event{
Id: "evt-001",
Name: "用户注册",
CreatedAt: timestamppb.Now(), // 当前时间
UpdatedAt: timestamppb.New(time.Now()), // 从 time.Time 创建
}
// 转换为 time.Time
createdAt := event.CreatedAt.AsTime()
fmt.Printf("创建时间: %v\n", createdAt)
Duration - 时间间隔
import "google/protobuf/duration.proto";
message Task {
string id = 1;
google.protobuf.Duration estimated_time = 2; // 预估时间
}
import "google.golang.org/protobuf/types/known/durationpb"
// 创建 Duration
task := &Task{
EstimatedTime: durationpb.New(2 * time.Hour + 30 * time.Minute),
}
// 转换为 time.Duration
duration := task.EstimatedTime.AsDuration()
Empty - 空消息
用于不需要参数或返回值的场景:
import "google/protobuf/empty.proto";
service HealthService {
// 不需要参数
rpc Ping(google.protobuf.Empty) returns (google.protobuf.Empty);
// 返回空响应
rpc DeleteUser(DeleteUserRequest) returns (google.protobuf.Empty);
}
import "google.golang.org/protobuf/types/known/emptypb"
// 调用
_, err := client.Ping(ctx, &emptypb.Empty{})
Wrapper Types - 包装类型
用于区分默认值和未设置的情况:
import "google/protobuf/wrappers.proto";
message Product {
string id = 1;
// 使用包装类型可以区分 0 和未设置
google.protobuf.Int32Value stock = 2;
google.protobuf.DoubleValue discount = 3;
google.protobuf.StringValue description = 4;
google.protobuf.BoolValue is_active = 5;
}
为什么需要包装类型?
在 proto3 中,普通字段无法区分"设置为默认值"和"未设置"。包装类型解决了这个问题:
// 普通字段
product.Stock = 0 // 无法区分是设置为0还是未设置
// 包装类型
product.Stock = &wrapperspb.Int32Value{Value: 0} // 明确设置为0
product.Stock = nil // 未设置
Struct - 动态结构
用于表示类似 JSON 的动态数据结构:
import "google/protobuf/struct.proto";
message Config {
string name = 1;
google.protobuf.Struct settings = 2; // 动态配置
}
import "google.golang.org/protobuf/types/known/structpb"
// 创建 Struct
settings, _ := structpb.NewStruct(map[string]interface{}{
"theme": "dark",
"notifications": map[string]interface{}{
"email": true,
"sms": false,
},
"max_items": 100,
})
config := &Config{
Name: "user-config",
Settings: settings,
}
// 读取 Struct
theme := config.Settings.Fields["theme"].GetStringValue()
默认值
Protocol Buffers 有默认值机制:
message Defaults {
int32 id = 1; // 默认值: 0
bool active = 2; // 默认值: false
string name = 3; // 默认值: ""(空字符串)
repeated int32 values = 4; // 默认值: [](空列表)
Status status = 5; // 默认值: UNKNOWN(第一个枚举值)
}
无法区分:
- 字段被设置为默认值
- 字段未被设置
如需区分,可以使用 optional(Proto3)或包装类型:
import "google/protobuf/wrappers.proto";
message Example {
// 使用 optional
optional int32 id = 1; // 可以区分未设置
// 使用包装类型
google.protobuf.Int32Value count = 2;
google.protobuf.StringValue name = 3;
}
字段规则
message FieldRules {
// 单数字段(默认)
string single = 1;
// 可选字段(Proto3)
optional string optional_field = 2;
// 重复字段(数组)
repeated string repeated_field = 3;
}
保留字段
当删除字段时,应该保留编号防止重用:
message Person {
string name = 1;
int32 age = 2;
// 保留已删除的字段
reserved 3, 4;
reserved "old_field", "deleted_field";
}
服务定义
gRPC 服务在 proto 文件中定义:
syntax = "proto3";
package myservice;
// 定义服务
service Greeter {
// 一元 RPC:发送问候
rpc SayHello (HelloRequest) returns (HelloReply);
// 服务端流:返回多个问候
rpc SayHelloStream (HelloRequest) returns (stream HelloReply);
// 客户端流:发送多个请求
rpc SendGreetings (stream HelloRequest) returns (HelloReply);
// 双向流:双方都可以发送流
rpc Chat (stream HelloRequest) returns (stream HelloReply);
}
// 请求消息
message HelloRequest {
string name = 1;
}
// 响应消息
message HelloReply {
string message = 1;
}
导入其他 Proto 文件
// 导入标准 proto
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
// 导入自定义 proto
import "common.proto";
message Order {
string id = 1;
google.protobuf.Timestamp created_at = 2;
}
完整示例
// product.proto
syntax = "proto3";
package ecommerce;
option go_package = "./ecommerce";
option java_package = "com.example.ecommerce";
import "google/protobuf/timestamp.proto";
// 产品服务
service ProductService {
// 获取单个产品
rpc GetProduct (GetProductRequest) returns (Product);
// 列出产品(服务端流)
rpc ListProducts (ListProductsRequest) returns (stream Product);
// 创建订单(客户端流)
rpc CreateOrder (stream OrderItem) returns (Order);
// 实时库存更新(双向流)
rpc StreamInventory (stream InventoryUpdate) returns (stream InventoryNotification);
}
// 产品消息
message Product {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
int32 stock = 5;
Category category = 6;
google.protobuf.Timestamp created_at = 7;
}
// 产品分类
enum Category {
CATEGORY_UNKNOWN = 0;
CATEGORY_ELECTRONICS = 1;
CATEGORY_CLOTHING = 2;
CATEGORY_FOOD = 3;
}
// 获取产品请求
message GetProductRequest {
string id = 1;
}
// 列出产品请求
message ListProductsRequest {
Category category = 1;
int32 page_size = 2;
string page_token = 3;
}
// 订单项
message OrderItem {
string product_id = 1;
int32 quantity = 2;
}
// 订单
message Order {
string id = 1;
repeated OrderItem items = 2;
double total = 3;
OrderStatus status = 4;
google.protobuf.Timestamp created_at = 5;
}
// 订单状态
enum OrderStatus {
ORDER_STATUS_UNKNOWN = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_DELIVERED = 4;
}
// 库存更新
message InventoryUpdate {
string product_id = 1;
int32 quantity_change = 2;
}
// 库存通知
message InventoryNotification {
string product_id = 1;
int32 current_stock = 2;
bool low_stock_alert = 3;
}
Protobuf Editions(2024 新特性)
Protobuf Editions 是 Protocol Buffers 在 2024 年推出的新语法体系,它替代了传统的 proto2 和 proto3 版本指定方式。Editions 采用更灵活的特性机制,允许语言随时间渐进式演进。
什么是 Editions?
传统的 proto 文件使用 syntax = "proto2" 或 syntax = "proto3" 来指定版本,而 Editions 使用版本号来指定默认行为:
// 传统方式
syntax = "proto3";
// Editions 方式
edition = "2024";
Editions 的优势
Editions 引入了**特性(Features)**机制,每个特性都有默认值,你可以在文件、消息、字段等不同层级覆盖这些默认值:
- 渐进式演进:语言可以每年发布新版本,平滑过渡
- 细粒度控制:可以在特定层级覆盖特定特性
- 向后兼容:可以导入 proto2 和 proto3 文件,wire 格式保持不变
主要特性说明
| 特性 | 说明 | 可选值 |
|---|---|---|
field_presence | 字段存在性 | EXPLICIT、IMPLICIT、LEGACY_REQUIRED |
repeated_field_encoding | 重复字段编码 | PACKED、EXPANDED |
enum_type | 枚举类型 | OPEN、CLOSED |
utf8_validation | UTF-8 验证 | VERIFY、NONE |
Proto3 到 Editions 迁移示例
Proto3 语法:
syntax = "proto3";
package com.example;
message Player {
optional string name = 1;
int32 id = 2;
repeated int32 scores = 3;
enum Handed {
HANDED_UNSPECIFIED = 0;
HANDED_LEFT = 1;
HANDED_RIGHT = 2;
}
optional Handed handed = 4;
}
Editions 语法:
edition = "2024";
package com.example;
option features.utf8_validation = NONE;
message Player {
// 显式存在性是 Edition 2024 的默认行为
string name = 1;
// 匹配 proto3 的隐式存在性行为
int32 id = 2 [features.field_presence = IMPLICIT];
// PACKED 是默认值,这里仅作演示
repeated int32 scores = 3 [features.repeated_field_encoding = PACKED];
export enum Handed {
HANDED_UNSPECIFIED = 0;
HANDED_LEFT = 1;
HANDED_RIGHT = 2;
}
Handed handed = 4;
}
语法变化要点
- 版本声明:
syntax改为edition - 保留字段:不再需要引号包裹字段名
// 传统方式
reserved "foo", "bar";
// Editions 方式
reserved foo, bar; - 符号可见性:新增
export和local关键字控制符号导出 - 选项导入:使用
import option仅导入自定义选项
符号可见性控制
Edition 2024 新增了 export 和 local 关键字,用于控制消息和枚举的符号可见性,即哪些符号可以从其他 proto 文件导入使用。
默认行为:
- 顶层符号(文件级别的消息和枚举)默认可导出
- 嵌套符号(消息内部定义的消息和枚举)默认不可导出
使用示例:
edition = "2024";
// 顶层消息默认可导出
message TopLevelMessage {
int32 value = 1;
// 嵌套枚举默认不可导出
enum InnerStatus {
STATUS_UNKNOWN = 0;
STATUS_ACTIVE = 1;
}
// 使用 export 使嵌套符号可导出
export enum ExportedStatus {
EXPORTED_UNKNOWN = 0;
EXPORTED_ACTIVE = 1;
}
// 使用嵌套消息
InnerStatus status = 2;
ExportedStatus exported = 3;
}
// 使用 local 阻止顶层符号被导出
local message InternalMessage {
// 此消息不能被其他 proto 文件导入使用
string internal_data = 1;
}
导入后的使用:
// other.proto
edition = "2024";
import "original.proto";
message OtherMessage {
// 可以使用导出的符号
TopLevelMessage top = 1;
TopLevelMessage.ExportedStatus status = 2;
// 以下会导致编译错误:
// InternalMessage internal = 3; // local 不可导出
// TopLevelMessage.InnerStatus inner = 4; // 嵌套符号默认不导出
}
选项导入
import option 是 Edition 2024 新增的语法,用于仅导入自定义选项而不导入其他符号。这避免了为选项生成不必要的代码。
定义自定义选项:
// options.proto
edition = "2024";
import "google/protobuf/descriptor.proto";
// 定义自定义选项
extend google.protobuf.MessageOptions {
string custom_option = 50000;
}
// 定义一个消息(不会被 import option 导入)
message HelperMessage {
string data = 1;
}
使用 import option 导入:
// main.proto
edition = "2024";
// 普通 import 会导入所有符号
// import "options.proto"; // HelperMessage 也会被导入
// import option 只导入选项定义
import option "options.proto";
message MyMessage {
option (custom_option) = "custom value";
int32 id = 1;
// 以下会导致编译错误:
// HelperMessage helper = 2; // 未导入
}
为什么使用 import option:
- 减少生成代码的体积
- 避免生成永远不会使用的代码
- 更清晰地表达依赖意图
- 替代了旧版的
import weak(Edition 2024 已移除)
在构建系统中使用:
proto_library(
name = "foo",
srcs = ["foo.proto"],
option_deps = [":custom_option_proto"] # 使用 option_deps 而非 deps
)
已移除的特性
Edition 2024 移除了一些旧特性:
java_multiple_files 文件选项:
Edition 2024 中 java_multiple_files 文件选项不再可用,应使用 features.(pb.java).multiple_files 特性代替。
ctype 字段选项:
Edition 2024 中 ctype 字段选项不再可用,应使用 features.(pb.cpp).string_type 特性代替。
import weak:
Edition 2024 中弱导入 (import weak) 不再允许使用,应迁移到 import option。
词法作用域
Editions 支持词法作用域,可以在高层级设置默认行为,然后在低层级覆盖:
edition = "2024";
// 文件级别:所有枚举默认为 CLOSED
option features.enum_type = CLOSED;
message Person {
string name = 1;
// 字段级别:覆盖默认存在性
int32 id = 2 [features.field_presence = IMPLICIT];
enum Employment {
// 枚举级别:此枚举为 OPEN
option features.enum_type = OPEN;
EMPLOYMENT_UNSPECIFIED = 0;
EMPLOYMENT_FULLTIME = 1;
}
}
什么时候使用 Editions?
- 新项目:推荐使用 Editions,获得最新特性和更好的灵活性
- 现有项目:proto2/proto3 完全兼容,可以继续使用,无需立即迁移
- 迁移工具:官方提供 Prototiller 工具帮助迁移
注意事项
- Editions 不改变二进制、文本或 JSON 序列化格式
- 生成的代码可能有变化,但 wire 格式保持兼容
- 可以在 Editions 文件中导入 proto2/proto3 定义,反之亦然
[!TIP] 截至目前,最新发布的是 Edition 2024。建议新项目采用 Editions,现有项目可根据需要逐步迁移。更多信息请参考 Protobuf Editions 官方文档。
小结
本章我们学习了:
- Protocol Buffers 概念:二进制序列化格式的优势
- Proto3 语法:字段定义、数据类型、枚举
- 复合类型:嵌套消息、数组、Map
- 服务定义:gRPC 服务的 proto 定义
- 最佳实践:字段编号、保留字段、导入
- Protobuf Editions:2024 年新特性,特性机制和渐进式演进
下一章我们将深入学习如何定义 gRPC 服务。