跳到主要内容

Protocol Buffers

Protocol Buffers(简称 protobuf)是 Google 开发的一种数据序列化格式,比 JSON 和 XML 更小、更快、更简单。它是 gRPC 的基础,用于定义服务接口和数据结构。

什么是 Protocol Buffers?

Protocol Buffers 是一种与语言无关、平台无关的可扩展机制,用于序列化结构化数据。它的主要优势在于:

特性Protocol BuffersJSONXML
数据大小小(二进制)中等(文本)大(文本)
解析速度中等
可读性需要解码
类型安全强类型弱类型弱类型
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; // 任意字节序列
}

类型选择建议

  • 普通整数:使用 int32int64
  • 可能为负的整数:使用 sint32sint64
  • 大正整数:使用 uint32uint64
  • 需要固定大小:使用 fixed32fixed64

枚举类型

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; // 数字钱包
}
}

注意事项

  1. oneof 字段不能是 repeated
  2. oneof 字段不能使用 map 类型
  3. 设置 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 选择

特性AnyOneof
类型安全运行时检查编译时检查
可扩展性高,无需修改 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字段存在性EXPLICITIMPLICITLEGACY_REQUIRED
repeated_field_encoding重复字段编码PACKEDEXPANDED
enum_type枚举类型OPENCLOSED
utf8_validationUTF-8 验证VERIFYNONE

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;
}

语法变化要点

  1. 版本声明syntax 改为 edition
  2. 保留字段:不再需要引号包裹字段名
    // 传统方式
    reserved "foo", "bar";
    // Editions 方式
    reserved foo, bar;
  3. 符号可见性:新增 exportlocal 关键字控制符号导出
  4. 选项导入:使用 import option 仅导入自定义选项

符号可见性控制

Edition 2024 新增了 exportlocal 关键字,用于控制消息和枚举的符号可见性,即哪些符号可以从其他 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 工具帮助迁移

注意事项

  1. Editions 不改变二进制、文本或 JSON 序列化格式
  2. 生成的代码可能有变化,但 wire 格式保持兼容
  3. 可以在 Editions 文件中导入 proto2/proto3 定义,反之亦然

[!TIP] 截至目前,最新发布的是 Edition 2024。建议新项目采用 Editions,现有项目可根据需要逐步迁移。更多信息请参考 Protobuf Editions 官方文档

小结

本章我们学习了:

  1. Protocol Buffers 概念:二进制序列化格式的优势
  2. Proto3 语法:字段定义、数据类型、枚举
  3. 复合类型:嵌套消息、数组、Map
  4. 服务定义:gRPC 服务的 proto 定义
  5. 最佳实践:字段编号、保留字段、导入
  6. Protobuf Editions:2024 年新特性,特性机制和渐进式演进

下一章我们将深入学习如何定义 gRPC 服务。