vault backup: 2026-05-11 19:02:38
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
---
|
||||
tags: [gRPC, Protobuf, proto, message, enum, oneof, map, IDL]
|
||||
create time: 2026-05-11 16:40
|
||||
---
|
||||
|
||||
# Protobuf 语法与消息定义
|
||||
|
||||
## 概述
|
||||
|
||||
gRPC 使用 Protobuf(Protocol Buffers)作为接口定义语言(IDL)。所有 gRPC 服务契约都以 `.proto` 文件编写——这是你的 API「蓝图」,任何调用方、服务端都从这里生成代码。**学会写 `.proto` 文件,你就拿到了整个 gRPC 体系的入场券。**
|
||||
|
||||
> [!question] 为什么选 Protobuf 而不是 JSON Schema?
|
||||
> JSON Schema 描述的是数据格式,但不提供序列化协议和跨语言代码生成能力。Protobuf 则是一套完整的 IDL:它定义了数据结构、wire format、序列化规则,并且为多语言自动生成强类型 Stub。对于内部微服务通信,这意味着**契约即代码**,编译期就能发现类型不匹配。
|
||||
|
||||
## .proto 文件骨架
|
||||
|
||||
一个 `.proto` 文件由若干顶层声明组成。先看完整骨架:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3"; // ① 版本声明
|
||||
|
||||
package user.v1; // ② 包名(命名空间)
|
||||
option go_package = "github.com/example/svc/user/v1;v1"; // ③ Go 输出路径
|
||||
|
||||
import "google/protobuf/timestamp.proto"; // ④ 引用外部 Proto
|
||||
|
||||
message GetUserRequest { // ⑤ 消息
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetUserResponse { // ⑤ 消息
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message User { // ⑤ 消息
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
}
|
||||
|
||||
service UserService { // ⑥ RPC 服务
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
rpc ListUsers(ListUsersRequest) returns (stream User);
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] proto2 vs proto3
|
||||
> - **proto3** 是当前默认版本,移除了 `required`/`optional` 字段修饰符、枚举必须从 0 开始等限制,语法更简洁。
|
||||
> - **proto2** 仍被部分遗留系统使用,支持更完整的特性如 `required`/`optional`、manually-implemented map field 等。
|
||||
> - **新项目一律使用 `syntax = "proto3"`**。除非你在维护十年前的遗留服务,否则没有理由用 proto2。
|
||||
|
||||
### 核心组成部分速查
|
||||
|
||||
| 声明 | 作用 | 是否必需 |
|
||||
|------|------|----------|
|
||||
| `syntax` | 指定 Protobuf 版本 | ✅ |
|
||||
| `package` | 命名空间隔离,避免名称冲突 | ⚠️ 推荐 |
|
||||
| `option go_package` | Go 生成的包路径和导出前缀 | ✅ Go 项目必需 |
|
||||
| `import` | 引用其他 `.proto` 文件 | ❌ |
|
||||
| `message` | 定义结构化数据类型 | ❌ |
|
||||
| `enum` | 定义枚举类型 | ❌ |
|
||||
| `service` / `rpc` | 定义远程调用接口 | ❌(纯数据 Proto 不需要) |
|
||||
|
||||
## Message 消息结构
|
||||
|
||||
Message 是 Protobuf 中最基本的结构化类型,对应 Go 的 `struct`:
|
||||
|
||||
```protobuf
|
||||
message LoginRequest {
|
||||
string username = 1; // 用户名
|
||||
string password = 2; // 密码(生产环境走 TLS 加密通道)
|
||||
bool remember_me = 3; // 记住登录状态
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
string token = 1; // JWT Token
|
||||
int64 expire_at = 2; // 过期时间戳(Unix seconds)
|
||||
User profile = 3; // 嵌套消息
|
||||
}
|
||||
|
||||
message User {
|
||||
string id = 1; // UUID 格式
|
||||
string name = 2; // 显示名称
|
||||
string email = 3; // 邮箱地址
|
||||
int32 age = 4; // 年龄
|
||||
bool active = 5; // 是否活跃
|
||||
repeated string roles = 6; // 角色列表(repeated 详见 [02-数据类型详解](./02-数据类型详解.md))
|
||||
}
|
||||
```
|
||||
|
||||
每个字段包含三部分:**类型 + 字段名 + tag number**。tag number 是字段在二进制 wire format 中的唯一标识——**一旦分配就不会再变**。后续讨论兼容性时会深入理解它的重要性。
|
||||
|
||||
> [!tip] tag number 分配原则
|
||||
> 1. 从 1 开始连续编号,不要跳号
|
||||
> 2. 预留编号区间给未来可能新增的字段(如保留 1-99 给常用字段,100+ 给扩展字段)
|
||||
> 3. 已使用的编号永远不要重用或删除 —— 这会导致序列化数据解析错乱
|
||||
> 4. 具体规则参见 [03-字段编号与前向兼容](./03-字段编号与前向兼容.md)
|
||||
|
||||
> [!question] 为什么不用 JSON 那样的"无编号"设计?
|
||||
> tag number 的核心价值在于**向后兼容**:当你新增字段时,老版本客户端遇到未知的 tag number 会直接跳过该字节块继续解析。如果没有编号,你只能换字段名 —— 但改了名就是 breaking change。Protobuf 的二进制设计让它在小体积、高性能之余,还能优雅地处理版本演进。
|
||||
|
||||
### 字段的默认值行为
|
||||
|
||||
proto3 中所有字段都有明确的默认值:
|
||||
|
||||
| 类型 | 默认值 |
|
||||
|------|--------|
|
||||
| string | `""`(空串) |
|
||||
| bytes | 空字节序列 |
|
||||
| bool | `false` |
|
||||
| numeric (int32, uint64, double…) | `0` 或 `0.0` |
|
||||
| enum | 值为 `0` 的那个枚举值 |
|
||||
| message | 返回"默认实例"(Go 中为零值 struct) |
|
||||
| repeated | 空列表(Go 中为 nil slice) |
|
||||
| map | nil map(Go 中为 nil) |
|
||||
|
||||
```go
|
||||
// Go 中读取默认值 — 无法区分"未设置"和"显式设为零值"
|
||||
var req LoginRequest
|
||||
fmt.Println(req.Username) // "" — 到底是没传还是传了 ""?
|
||||
```
|
||||
|
||||
**这就是 proto3 最著名的陷阱:客户端读不到"未设置"和"设为零值"的区别。** 解决之道是在需要使用包装类型时用 Wrapper Types,详情见 [02-数据类型详解](./02-数据类型详解.md)。
|
||||
|
||||
## Enum 枚举类型
|
||||
|
||||
枚举用于定义一组命名的整数值:
|
||||
|
||||
```protobuf
|
||||
enum Role {
|
||||
ROLE_UNSPECIFIED = 0; // 未指定(proto3 要求第一个值为 0)
|
||||
ROLE_ADMIN = 1; // 管理员
|
||||
ROLE_EDITOR = 2; // 编辑者
|
||||
ROLE_VIEWER = 3; // 只读者
|
||||
}
|
||||
|
||||
enum Status {
|
||||
STATUS_OFFLINE = 0; // 离线
|
||||
STATUS_ONLINE = 1; // 在线
|
||||
STATUS_BUSY = 2; // 忙碌
|
||||
STATUS_AWAY = 3; // 离开
|
||||
}
|
||||
|
||||
message User {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
Role role = 3; // 引用枚举类型
|
||||
Status status = 4; // 引用枚举类型
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 枚举铁律
|
||||
> 1. **第一个枚举值必须是 0**(通常以 `_UNSPECIFIED` 或 `_UNKNOWN` 结尾),proto3 强制要求
|
||||
> 2. 新增枚举值是向后兼容的,但旧版本客户端收到未知枚举值时会回退到 0(即第一个值)
|
||||
> 3. **不要删除已有枚举值的编号**,否则可能引发不可预期的兼容问题
|
||||
> 4. 枚举值可以打同一个数值做 alias,但需要在 enum 选项里声明 `allow_alias = true`
|
||||
|
||||
## Oneof 排他选择
|
||||
|
||||
当多个字段互斥、每次请求只能填其中一个时,使用 `oneof`:
|
||||
|
||||
```protobuf
|
||||
message UpdateProfileRequest {
|
||||
string id = 1;
|
||||
|
||||
oneof update_field {
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
Role role = 4;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样保证了 `Name`、`Email`、`Role` 三个字段在序列化时只有一个会出现,节省带宽且语义清晰。在 Go 生成的代码中,oneof 会变成一个接口类型:
|
||||
|
||||
```go
|
||||
type UpdateProfileRequest struct {
|
||||
Id string
|
||||
// 只能设置其中之一
|
||||
UpdateField isUpdateProfileRequest_UpdateField
|
||||
}
|
||||
|
||||
switch req.UpdateField.(type) {
|
||||
case *UpdateProfileRequest_Name:
|
||||
fmt.Println("更新了 name:", req.Name)
|
||||
case *UpdateProfileRequest_Email:
|
||||
fmt.Println("更新了 email:", req.Email)
|
||||
}
|
||||
```
|
||||
|
||||
> [!question] oneof vs 单独字段?什么时候该用 oneof?
|
||||
> 如果你希望业务逻辑保证「每次请求只更新一个字段」,用 oneof 可以让编译器帮你 enforcing 这个约束。但如果只是"几个可选字段可能同时出现"的场景,反而应该用单独的 field —— oneof 会增加代码复杂度(需要 switch/case 判断哪个被设置了)。**本质区别:oneof 表达的是"二选一或多选一"的互斥关系。**
|
||||
|
||||
## Map 键值映射
|
||||
|
||||
Protobuf 原生支持 key-value 映射,key 只能是整数或字符串类型:
|
||||
|
||||
```protobuf
|
||||
message UserProfile {
|
||||
string id = 1;
|
||||
|
||||
// 标签映射:string → string
|
||||
map<string, string> tags = 2;
|
||||
|
||||
// 统计映射:string → int32
|
||||
map<string, int32> login_count_by_day = 3;
|
||||
}
|
||||
```
|
||||
|
||||
在 Go 中生成的对应类型为 `map[string]string`,**注意默认为 nil(而非空 map)**。如果需要确保非 nil,可以用 `repeated` + key-value message 替代。
|
||||
|
||||
## Reserved 保留字段
|
||||
|
||||
当你的 proto 文件 evolve 到新版本,可能需要移除某个字段。**但不能简单地删除——因为旧版本的客户端可能还在发送带有该字段编号的数据,新服务器解析时会把它塞进下一个字段里。** `reserved` 关键字就是为此而生:
|
||||
|
||||
```protobuf
|
||||
message User {
|
||||
reserved 7, 11; // 保留单个编号
|
||||
reserved 9 to 13; // 保留编号区间
|
||||
reserved "username", "telephone"; // 保留字段名
|
||||
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string nick = 8; // 7 不能用了,这里只能用 >= 14 的编号
|
||||
}
|
||||
```
|
||||
|
||||
> [!example] 典型场景:用户表迭代
|
||||
> v1: `message User { string username = 1; string email = 2; string phone = 3; }`
|
||||
>
|
||||
> v2: 业务发现 `username` 改名了,决定删除并保留编号:
|
||||
> ```protobuf
|
||||
> message User {
|
||||
> reserved 1; // 告诉 protoc:1 号编号作废
|
||||
> string id = 1; // 重新用编号 1 放 id
|
||||
> string email = 2;
|
||||
> string nickname = 3; // 新的昵称字段
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 这样如果 v1 客户端发来 `username` 的数据(tag=1),protoc 会自动丢弃而不会错误地填入 `id` 字段。
|
||||
|
||||
> [!tip] reserved 最佳实践
|
||||
> 1. 删除字段时,同时记录被删字号的**原因注释**(可以在 git commit message 里说明,也可以加一行 `// reserved: replaced by xxx at YYYY-MM-DD`)
|
||||
> 2. 不要把正在使用的编号标记为 reserved——编译不过就是最大的提示
|
||||
> 3. 具体兼容策略参见 [03-字段编号与前向兼容](./03-字段编号与前向兼容.md)
|
||||
|
||||
## Package 与 Import
|
||||
|
||||
Protobuf 的 `package` 机制类似于 Go 的 import path,提供命名空间隔离:
|
||||
|
||||
```protobuf
|
||||
// file: user/v1/user.proto
|
||||
package user.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto"; // Well-Known Type
|
||||
|
||||
message User {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
google.protobuf.Timestamp created_at = 3;
|
||||
}
|
||||
|
||||
// file: order/v1/order.proto
|
||||
package order.v1;
|
||||
|
||||
import "user/v1/user.proto"; // 引用 user 包的 message
|
||||
|
||||
message Order {
|
||||
string id = 1;
|
||||
user.v1.User buyer = 2; // 跨包引用
|
||||
int64 amount_cents = 3;
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] import 路径约定
|
||||
> `import "user/v1/user.proto"` 中的路径应当与文件的实际磁盘路径一致(相对于 `protoc -I` 参数指定的目录)。保持一致性是关键。
|
||||
|
||||
## 构建流程总览
|
||||
|
||||
下图展示从 `.proto` 源文件到最终 Go Stub 的完整编译链:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[".proto 源文件"] --> B["protoc 编译器"]
|
||||
B --> C["protoc-gen-go 插件"]
|
||||
B --> D["protoc-gen-go-grpc 插件"]
|
||||
C --> E["pb.go — 消息结构体"]
|
||||
D --> F["_grpc.go — client/server stub"]
|
||||
E --> G["业务层调用 Client / Server"]
|
||||
F --> G
|
||||
|
||||
style A fill:#EAB308,color:#fff
|
||||
style B fill:#3B82F6,color:#fff
|
||||
style C fill:#4FC08D,color:#fff
|
||||
style D fill:#4FC08D,color:#fff
|
||||
style E fill:#A0AEC0,color:#fff
|
||||
style F fill:#A0AEC0,color:#fff
|
||||
```
|
||||
|
||||
> [!info] 工具链细节
|
||||
> `protoc` 负责解析 `.proto` 语法树,各类插件将其翻译成目标语言的代码。Go 生态需要两个插件协同工作:`protoc-gen-go` 生成消息结构体,`protoc-gen-go-grpc` 生成 gRPC 客户端和服务端 Stub。具体配置方法参见 [17-protoc 工具链与 Makefile](../6.%20工程实践篇/17-protoc%20工具链与%20Makefile.md)。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/gRPC/README]] — gRPC 知识库全景索引
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/02-Protobuf 数据类型详解]] — Scalar、Wrapper、Well-Known、Repeated 详细对照
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/03-Protobuf 字段编号与前向兼容]] — Field Number 分配规则、Reserved、版本演进策略
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/04-Protobuf Oneof 与包装类型]] — Oneof 高级用法、Google.Protobuf.Value、Any 泛型封装
|
||||
@@ -0,0 +1,418 @@
|
||||
---
|
||||
tags: [gRPC, Protobuf, scalar types, wrapper types, WKT, repeated, packed, oneof, map, wire encoding]
|
||||
create time: 2026-05-11 16:40
|
||||
---
|
||||
|
||||
# 数据类型详解
|
||||
|
||||
## 概述
|
||||
|
||||
Protobuf 的类型系统看起来简单,但有很多容易被忽略的细节:`optional`/`required` 的区别、packed vs unpacked repeated 编码差异、以及 Well-Known Types 的威力。这篇帮你把常见坑一次性踩完。
|
||||
|
||||
> [!question] 为什么 Protobuf 没有 required?
|
||||
> 早期的 proto3 移除了 `required`/`optional` 关键字,因为工程实践中很难真正验证——服务端删除了字段后,客户端无法区分"字段没传"和"服务端没设值"。**如果需要保证某个字段一定存在,该用什么方式替代?** 提示:见文末 `oneof` 用法。
|
||||
|
||||
## Protobuf 类型体系一览
|
||||
|
||||
在深入每个类型之前,先看全貌:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Protobuf 类型系统"] --> B["Scalar Types\n标量类型"]
|
||||
A --> C["Composite Types\n复合类型"]
|
||||
A --> D["Well-Known Types\n内置类型"]
|
||||
|
||||
B --> B1["整数系: int32 / int64 / uint32 / uint64 / sint32 / sint64"]
|
||||
B --> B2["浮点系: float / double"]
|
||||
B --> B3["其他: bool / string / bytes"]
|
||||
|
||||
C --> C1["repeated\n(动态列表)"]
|
||||
C --> C2["map<string, T>\n(键值对)"]
|
||||
C --> C3["message\n(自定义结构)"]
|
||||
C --> C4["oneof\n(互斥字段)"]
|
||||
|
||||
D --> D1["Timestamp\n(time.Time)"]
|
||||
D --> D2["Duration\n(time.Duration)"]
|
||||
D --> D3["StringValue\n(*string 指针)"]
|
||||
D --> D4["Any / Value / Struct\n(通用 JSON)"]
|
||||
D --> D5["FieldMask\n(partial update)"]
|
||||
```
|
||||
|
||||
### proto2 vs proto3 关键差异
|
||||
|
||||
| 特性 | proto2 | proto3 |
|
||||
|------|--------|--------|
|
||||
| `required` / `optional` | 支持 | ❌ 移除(proto3 用默认零值语义) |
|
||||
| `enum default value` | 不允许 0 以外的默认值 | ✅ 允许任意枚举值作为默认 |
|
||||
| map | ❌ 不支持 | ✅ 原生支持 |
|
||||
| repeated packed | 需显式声明 `[packed = true]` | ✅ 数字类型默认 packed |
|
||||
| `Has()` 判断 | 自动生成 | ❌ 不再为 scalar 生成(wrapper type 替代) |
|
||||
|
||||
> [!tip] proto3 的 optional 回来了!
|
||||
> 虽然 proto3 最初去掉了 optional,但从 **protobuf 3.12+** 开始重新引入了 `optional` 关键字,不过它仍然受 wire compatibility 限制——加上 optional 后会改变 field number 的行为,所以生产环境中更推荐用 **wrapper types**。
|
||||
|
||||
## Scalar Types 标量类型
|
||||
|
||||
Protobuf 提供了一套语言无关的标量类型,每种都有确定的 wire encoding。选型的核心原则是:**在保证正确性的前提下,选最小的类型**。
|
||||
|
||||
| Protobuf 类型 | Go 生成类型 | Wire Encoding | 说明 |
|
||||
|---------------|------------|---------------|------|
|
||||
| `double` | `float64` | 8 bytes | 双精度浮点 |
|
||||
| `float` | `float32` | 4 bytes | 单精度浮点 |
|
||||
| `int32` | `int32` | varint | **最常用**,小整数高效编码 |
|
||||
| `int64` | `int64` | zigzag varint | 大整数或时间戳 |
|
||||
| `uint32` | `uint32` | varint | 无符号 32 位 |
|
||||
| `uint64` | `uint64` | varint | 无符号 64 位 |
|
||||
| `sint32` | `int32` | zigzag varint | 有符号整数,负数编码更小 |
|
||||
| `sint64` | `int64` | zigzag varint | 同上,64 位 |
|
||||
| `fixed32` | `uint32` | 4 bytes | 固定 4 字节,适合频繁序列化的场景 |
|
||||
| `fixed64` | `uint64` | 8 bytes | 固定 8 字节 |
|
||||
| `sfixed32` | `int32` | 4 bytes | 有符号固定 4 字节 |
|
||||
| `sfixed64` | `int64` | 8 bytes | 有符号固定 8 字节 |
|
||||
| `bool` | `bool` | varint (0/1) | — |
|
||||
| `string` | `string` | len-delimited | UTF-8 编码 |
|
||||
| `bytes` | `[]byte` | len-delimited | 任意二进制数据 |
|
||||
|
||||
### 性能选型建议
|
||||
|
||||
下面展示两个典型场景:
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:盲目使用 int64 增加序列化体积
|
||||
// 每个 int64 可能占用 10+ bytes(varint 随数值增长)
|
||||
message Request {
|
||||
int64 user_id = 1; // 2^31 ≈ 21 亿,99% 的用户 ID 不会超过
|
||||
int64 amount = 2; // float 存金额会丢失精度,且编码更大
|
||||
}
|
||||
|
||||
// ✅ 推荐:按实际范围选型
|
||||
message Request {
|
||||
int32 user_id = 1; // 大多数用户 ID < 2^31
|
||||
int32 amount_cents = 2; // 以"分"为单位存,避免 float,更节省
|
||||
}
|
||||
|
||||
// ⭐ 极端优化:正负波动且范围小的场景
|
||||
message Offset {
|
||||
sint32 delta = 1; // zigzag 编码,-1 只占 1 byte(int32 需 5 byte)
|
||||
}
|
||||
```
|
||||
|
||||
上面的代码对应三种策略:
|
||||
1. **默认选择 `int32`**:覆盖 ±21 亿的范围,对于 ID、计数等绝大多数场景足够。
|
||||
2. **金额用最小货币单位存为整数**:比如 `100` 代表 ¥1.00,避免 IEEE 754 精度损失。
|
||||
3. **`sint32` 用于小范围正负波动**:如 offset、delta,zigzag 编码让 `-1` 和 `1` 都只需 1 byte。
|
||||
|
||||
> [!tip] float vs double 取舍
|
||||
> HTTP/2 + TLS 已经压缩了网络传输,**节省几个字节对延迟的影响微乎其微**。优先选择 `float32`,除非你的业务需要 IEEE 754 双精度精度(如金融计算)。
|
||||
|
||||
### Varint 编码与 zigzag 的关系
|
||||
|
||||
很多人分不清 varint 和 zigzag,这里简单拆解:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["原始整数"] --> B{"是否为负数?"}
|
||||
B -- 否 --> C["varint: 每 7 bits 一组, MSB 标记 continuation"]
|
||||
B -- 是 --> D["zigzag: n → (n << 1) ^ (n >> 31)"]
|
||||
D --> C
|
||||
C --> E["变长字节序列: 小数字仅 1 byte"]
|
||||
```
|
||||
|
||||
- **varint**:只处理非负数,数字越小占的字节越少。`1` 占 1 byte,`2^31` 占 5 bytes。
|
||||
- **zigzag**:将有符号整数映射为非负数,公式 `(n << 1) ^ (n >> 31)`,让 `-1` 变成 `1`,`-2` 变成 `3`,从而也能用 varint 紧凑编码。
|
||||
|
||||
## Repeated 与 Packed
|
||||
|
||||
`repeated` 字段表示一个动态长度的列表。在 proto3 中,numeric 类型的 repeated 默认采用 **packed encoding**(打包编码),非 numeric 类型(如 string、message)只能是 unpacked:
|
||||
|
||||
```protobuf
|
||||
message TagList {
|
||||
repeated string tags = 1; // string 类型无法 packed,总是 len-delimited
|
||||
repeated int32 scores = 2; // int32 默认 packed
|
||||
repeated float32 weights = 3; // float 默认 packed(4-byte fixed)
|
||||
}
|
||||
```
|
||||
|
||||
### Packed Encoding 原理与对比
|
||||
|
||||
考虑一组 `repeated int32` 字段 `[1, 2, 3]`,两种编码方式的 wire format 对比:
|
||||
|
||||
```mermaid
|
||||
block
|
||||
column "Unpacked (legacy)"
|
||||
B1["tag(1B)"] B2["val 1(1B)"] B3["tag(1B)"] B4["val 2(1B)"] B5["tag(1B)"] B6["val 3(1B)"]
|
||||
style B1 fill:#f9d
|
||||
style B3 fill:#f9d
|
||||
style B5 fill:#f9d
|
||||
note1["重复写 tag\n共 6 bytes"]
|
||||
|
||||
column "Packed (proto3 默认)"
|
||||
C1["tag(1B)"] C2["len(1B)"] C3["val 1(1B)"] C4["val 2(1B)"] C5["val 3(1B)"]
|
||||
style C1 fill:#9df
|
||||
style C2 fill:#9df
|
||||
style C3 fill:#dfd
|
||||
style C4 fill:#dfd
|
||||
style C5 fill:#dfd
|
||||
note2["只写一次 tag\n共 5 bytes"]
|
||||
```
|
||||
|
||||
随着元素数量增长,差距越来越明显:
|
||||
|
||||
| 元素数量 | Unpacked | Packed | 节省比例 |
|
||||
|---------|----------|--------|---------|
|
||||
| 3 | 6B | 5B | 17% |
|
||||
| 10 | 20B | 12B | 40% |
|
||||
| 100 | 200B | 109B | 46% |
|
||||
| 1000 | 2000B | 1037B | 48% |
|
||||
|
||||
> [!note] 手动关闭 packed
|
||||
> 如果出于兼容性考虑需要关闭 packed,可以在 proto2 中使用:
|
||||
> ```protobuf
|
||||
> repeated int32 scores = 1 [packed = false]; // proto2 语法
|
||||
> ```
|
||||
> proto3 不允许此属性(必须 packed)。
|
||||
|
||||
## Wrapper Types 包装类型
|
||||
|
||||
Proto3 移除了 `required` 后,引入了 `google.protobuf.*_wrapper` 类型来区分「未设置」和「零值」:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
message UserUpdate {
|
||||
string id = 1;
|
||||
google.protobuf.StringValue display_name = 2; // 可选的字符串
|
||||
google.protobuf.BoolValue is_active = 3; // 可选的布尔值
|
||||
google.protobuf.Int32Value age = 4; // 可选的整数
|
||||
google.protobuf.FloatValue height_cm = 5; // 可选的浮点数
|
||||
}
|
||||
```
|
||||
|
||||
在 Go 生成的代码中,wrapper 类型生成的是**指针**:
|
||||
|
||||
```go
|
||||
type UserUpdate struct {
|
||||
Id string
|
||||
DisplayName *string // nil = 未设置;"" = 明确设为空串
|
||||
IsActive *bool // nil = 未设置;*false = 明确设为 false
|
||||
Age *int32 // nil = 未设置;0 = 明确设为 0
|
||||
}
|
||||
```
|
||||
|
||||
这样就能清晰表达三种状态:**没传这个字段**(nil)、**传了但值是零**(指向零值的指针)、**传了正常值**(指向非零值的指针)。
|
||||
|
||||
### 原始类型 vs Wrapper 类型对比
|
||||
|
||||
| 场景 | 原始类型 `string` | Wrapper `StringValue` |
|
||||
|------|-------------------|----------------------|
|
||||
| Go 零值 | `""`(与"未设置"无法区分) | `nil`(清晰表达缺失) |
|
||||
| JSON 序列化 | `"name": ""` | `"name": null` 或省略 |
|
||||
| 判断是否传值 | 需要额外逻辑 | `if v != nil` 即可 |
|
||||
| wire 大小 | 同左 | 同左(额外一层 wrapper overhead ≈ 0) |
|
||||
|
||||
> [!warning] Wrapper 不是银弹
|
||||
> 不要把所有字段都用 wrapper。只有在 **你需要区分"未设置"和"零值"** 时才用 wrapper,否则会增加 nil-check 的心智负担。
|
||||
|
||||
### 哪些 Wrapper 可用
|
||||
|
||||
Protobuf 提供了所有标量类型的 wrapper,Go 中一一对应:
|
||||
|
||||
| Wrapper Type | Go 指针类型 | 典型用途 |
|
||||
|-------------|-----------|---------|
|
||||
| `StringValue` | `*string` | 可选文本 |
|
||||
| `BoolValue` | `*bool` | 可选开关 |
|
||||
| `Int32Value` | `*int32` | 可选小整数 |
|
||||
| `Int64Value` | `*int64` | 可选大整数 / 时间戳 |
|
||||
| `FloatValue` | `*float32` | 可选浮点 |
|
||||
| `DoubleValue` | `*float64` | 可选双精度 |
|
||||
| `BytesValue` | `*[]byte` | 可选二进制数据 |
|
||||
|
||||
## Well-Known Types
|
||||
|
||||
Protobuf 内置了一组通用的消息类型,称为 Well-Known Types(WKT),全部定义在 `google/protobuf/` 下。它们在不同语言中有各自的 native 映射,是实现跨语言兼容的关键。
|
||||
|
||||
核心 WWT 分类如下:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["Well-Known Types"] --> B["日期/时间\nTimestamp / Duration"]
|
||||
A --> C["可选包装\nWrapper Types × 7"]
|
||||
A --> D["泛型/动态\nAny / Value / Struct"]
|
||||
A --> E["实用工具\nFieldMask / Empty / ..."]
|
||||
```
|
||||
|
||||
### 时间相关:Timestamp & Duration
|
||||
|
||||
```protobuf
|
||||
import (
|
||||
"google/protobuf/timestamp.proto"
|
||||
"google/protobuf/duration.proto"
|
||||
)
|
||||
|
||||
message Task {
|
||||
string title = 1;
|
||||
google.protobuf.Timestamp deadline = 2; // 绝对时间点
|
||||
google.protobuf.Duration timeout = 3; // 相对时长
|
||||
}
|
||||
```
|
||||
|
||||
在 Go 端,这两个类型直接映射为 `time.Time` 和 `time.Duration`,无需手动转换:
|
||||
|
||||
```go
|
||||
task := &pb.Task{
|
||||
Title: "发布版本",
|
||||
Deadline: timestamppb.Now(), // 自动转当前 time.Time
|
||||
Timeout: durationpb.New(30*time.Second), // 自动转 30s
|
||||
}
|
||||
```
|
||||
|
||||
> [!important] Timestamp 的序列化差异
|
||||
> 在 JSON 映射中,`Timestamp` 默认序列化为 `RFC3339` 格式的 string:`"2026-05-11T08:30:00Z"`。但在 binary protobuf 中,它是两个 int64:seconds + nanoseconds。**跨语言调用时需确保对方也理解这种语义**。
|
||||
|
||||
### FieldMask:精准 Partial Update
|
||||
|
||||
`FieldMask` 是 gRPC 生态中最被低估的 WKT 之一。配合 `google.golang.org/protobuf/proto` 提供的 `ApplyFieldMask` 函数,可以实现精准的增量更新:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/field_mask.proto";
|
||||
|
||||
message UserPatchRequest {
|
||||
google.protobuf.FieldMask update_mask = 1; // ["display_name", "email"]
|
||||
User user = 2;
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 服务器端:只对 mask 中指定的字段做更新
|
||||
updatedUser := &existingUser
|
||||
proto.ApplyFieldMask(&updatedUser, req.GetUser())
|
||||
```
|
||||
|
||||
JSON 传递时也很简洁:`{ "updateMask": "display_name,email", "user": { "display_name": "新名字" } }`。
|
||||
|
||||
### Any:泛型消息容器
|
||||
|
||||
`Any` 允许你在不知道具体消息类型的情况下传递消息,常用于事件总线或插件架构:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/any.proto";
|
||||
|
||||
message Event {
|
||||
google.protobuf.Any payload = 1; // 任意 protobuf message
|
||||
}
|
||||
```
|
||||
|
||||
反序列化时需要注册 type registry:
|
||||
|
||||
```go
|
||||
// 注册已知类型
|
||||
ptypes.RegisterAnyType(reflect.TypeFor[OrderCreated]())
|
||||
|
||||
// 从 Any 中提取具体类型
|
||||
event := &Event{}
|
||||
payload, _ := ptypes.UnmarshalAny(event.Payload)
|
||||
```
|
||||
|
||||
> [!danger] 谨慎使用 Any
|
||||
> `Any` 绕过了静态类型检查,滥用会导致调试困难。只在**真正的扩展点**(如插件系统、事件溯源)使用,不要用它来替代正常的消息设计。
|
||||
|
||||
## Map 类型细节
|
||||
|
||||
Map 在 wire format 中被编码为 `repeated key_value message`,底层实现其实就是一个 repeated:
|
||||
|
||||
```protobuf
|
||||
message UserPreferences {
|
||||
map<string, string> theme_settings = 1;
|
||||
map<int32, string> role_permissions = 2;
|
||||
}
|
||||
```
|
||||
|
||||
关键行为:
|
||||
- **迭代顺序不保证**:JSON/binary 序列化后顺序不可预测,不能依赖顺序做比较。
|
||||
- **不能有嵌套 map**:`map<string, map<string,int>>` 非法。
|
||||
- **key 只能是整数或字符串**,不支持 message 类型作为 key。
|
||||
- Go 中初始值为 `nil`(而非 `make(map[string]string)`),使用前需判空或初始化。
|
||||
|
||||
### Map vs Message + repeated
|
||||
|
||||
当需要额外元数据时,map 就不够用了,需要改用 message + repeated:
|
||||
|
||||
```protobuf
|
||||
// ❌ map 只能存 key-value,无法携带额外信息
|
||||
message Bad {
|
||||
map<string, string> roles = 1;
|
||||
}
|
||||
|
||||
// ✅ 用 message 承载完整信息
|
||||
message Good {
|
||||
message RoleMapping {
|
||||
string role = 1;
|
||||
string permission = 2;
|
||||
}
|
||||
repeated RoleMapping mappings = 1;
|
||||
}
|
||||
```
|
||||
|
||||
## Optional 与 Oneof
|
||||
|
||||
回到开头的问题:proto3 没有 `required`,如何保证字段一定存在?
|
||||
|
||||
**方案一:Wrapper Type**(见上文)— 适合"可选但可零值"的场景。
|
||||
|
||||
**方案二:Oneof** — 适合"多个字段中必须有且仅有一个"的场景:
|
||||
|
||||
```protobuf
|
||||
message PaymentRequest {
|
||||
string order_id = 1;
|
||||
|
||||
oneof payment_method {
|
||||
string alipay_token = 2;
|
||||
string wechat_pay_nonce = 3;
|
||||
string bank_card_number = 4;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 Go 生成的代码中,oneof 会生成一个接口来标识哪个字段被设置了:
|
||||
|
||||
```go
|
||||
// Go 端生成的 interface
|
||||
type PaymentRequest_PaymentMethod interface {
|
||||
isPaymentRequest_PaymentMethod()
|
||||
}
|
||||
```
|
||||
|
||||
使用时通过类型断言判断:
|
||||
|
||||
```go
|
||||
switch req.GetPaymentMethod().(type) {
|
||||
case *PaymentRequest_AlipayToken:
|
||||
// 走支付宝
|
||||
case *PaymentRequest_WechatPayNonce:
|
||||
// 走微信支付
|
||||
default:
|
||||
// 错误:payment method 未设置
|
||||
}
|
||||
```
|
||||
|
||||
> [!example] Oneof 的实际应用场景
|
||||
> - **多态请求参数**:搜索时可以按关键词、ID 或模糊匹配,三者选一
|
||||
> - **协议切换**:同一个连接支持多种子协议
|
||||
> - **互斥配置**:比如渲染模式只能选一种(WebGL / Canvas / SVG)
|
||||
|
||||
## 最佳实践总结
|
||||
|
||||
- **优先使用 `int32`**,除非确定数据范围超过 ±21 亿才用 `int64`。
|
||||
- **金额相关用整型存储最小货币单位**(如 cents),永远不要用 `float` 存钱。
|
||||
- **需要表达"可选"时优先考虑 wrapper types**,比 oneof 更简洁,比裸 scalar 更能区分零值和缺失。
|
||||
- **timestamp 统一用 RFC3339 string**,跨语言互通性最好。
|
||||
- **sint32/sint64** 仅在小范围内有正负波动的场景(如 offset、delta)中使用。
|
||||
- **oneof 用在"多选一"的互斥场景**,而不是用来模拟 optional。
|
||||
- **FieldMask 是实现 RESTful PATCH 语义的神器**,别自己解析 JSON 路径了。
|
||||
- **慎用 Any**,只在真正的扩展点使用,避免绕过类型安全。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 语法入门,建议先读本篇再来看本文
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容]] — 字段编号管理、向前向后兼容规则
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 深度使用 + Wrapper Type 实战模式
|
||||
@@ -0,0 +1,306 @@
|
||||
---
|
||||
tags: [gRPC, Protobuf, field number, reserved, backward compatibility, forward compatibility, versioning]
|
||||
create time: 2026-05-11 16:40
|
||||
---
|
||||
|
||||
# 字段编号与前向兼容
|
||||
|
||||
## 概述
|
||||
|
||||
每一个字段都有一个 tag number,这行简单的数字背后藏着 Protobuf 最核心的设计原则:**向后兼容**。理解这套机制,你就能放心地修改 protobuf schema 而不用担心打坏线上服务。
|
||||
|
||||
> [!note] 核心概念速记
|
||||
> - **向后兼容**(Old → New):旧版客户端跑新版服务端返回的数据 — Protobuf 保证**未知字段被安全忽略**
|
||||
> - **向前兼容**(New → Old):新版客户端跑旧版服务端返回的数据 — 缺失字段取**类型默认值**
|
||||
|
||||
> [!warning] 注意
|
||||
> 一旦字段编号被分配并部署,就**永远不能再复用**它。这是 Protobuf 的硬伤——编号就像 UUID,一旦发出去就是它的了。
|
||||
|
||||
## Tag Number 分配规则
|
||||
|
||||
每个字段的 tag number(通常称为 field number)取值范围为 **1 ~ 536,870,911**(即 `2^29 - 1`)。这个范围不是随机的:
|
||||
|
||||
```protobuf
|
||||
message User {
|
||||
string id = 1; // 核心标识符,高频使用 → 留给 1~15
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
int32 age = 4;
|
||||
bool active = 5;
|
||||
|
||||
string phone = 6;
|
||||
string address = 7;
|
||||
Role role = 8;
|
||||
|
||||
// ... 中间跳过一些编号供未来添加 ...
|
||||
// (reserved 10 to 20)
|
||||
|
||||
google.protobuf.Timestamp created_at = 100;
|
||||
google.protobuf.Timestamp updated_at = 101;
|
||||
repeated string tags = 102;
|
||||
}
|
||||
```
|
||||
|
||||
### Wire Encoding 原理解析
|
||||
|
||||
Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少:
|
||||
|
||||
| 编号范围 | Wire Encoding 大小 | 说明 |
|
||||
|---------|-------------------|------|
|
||||
| 1 ~ 15 | 1 byte | 黄金区间,预留给你的核心高频字段 |
|
||||
| 16 ~ 2047 | 2 bytes | 次优先区间 |
|
||||
| 2048+ | 3~5 bytes | 低频字段可放这里 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph F1["Field Number = 3"]
|
||||
A1["field_number = 3"] --> B1["<< 3"]
|
||||
B1 --> C1["24"]
|
||||
C1 --> D1["| wire_type 0"]
|
||||
D1 --> E1["tag = 24 = 0x18"]
|
||||
E1 --> F1["1 byte ✅"]
|
||||
end
|
||||
|
||||
subgraph F2["Field Number = 100"]
|
||||
A2["field_number = 100"] --> B2["<< 3"]
|
||||
B2 --> C2["800"]
|
||||
C2 --> D2["| wire_type 0"]
|
||||
D2 --> E2["tag = 800 = 0x320"]
|
||||
E2 --> F2["2 bytes ⚠️"]
|
||||
end
|
||||
|
||||
style F1 fill:#d4edda
|
||||
style F2 fill:#fff3cd
|
||||
```
|
||||
|
||||
> [!example] 公式
|
||||
> `tag = (field_number << 3) | wire_type`
|
||||
> - `<< 3` 等价于 `field_number × 8`,把高 5 位留给 field number
|
||||
> - 低 3 位存放 wire type(0=varint, 1=64-bit, 2=length-delimited, ...)
|
||||
|
||||
对于高频通信的消息体,节省 1 byte *per message* × 每秒百万调用 = 可观的带宽节省。这就是为什么建议 core fields 用 1~15。
|
||||
|
||||
### 合理的 Field Numbering 策略
|
||||
|
||||
```protobuf
|
||||
// user/v1/user.proto
|
||||
message User {
|
||||
// === Core fields (1-9): 核心字段,几乎每次都会序列化 ===
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
|
||||
// === Secondary fields (10-19): 常用但非必需 ===
|
||||
string phone = 10;
|
||||
string avatar_url = 11;
|
||||
Role role = 12;
|
||||
bool active = 13;
|
||||
|
||||
// === Tertiary fields (20-99): 偶尔使用 ===
|
||||
string bio = 20;
|
||||
string website = 21;
|
||||
Location location = 22;
|
||||
|
||||
// === Audit & metadata (100-199): 系统字段,低频 ===
|
||||
google.protobuf.Timestamp created_at = 100;
|
||||
google.protobuf.Timestamp updated_at = 101;
|
||||
string created_by = 102;
|
||||
string updated_by = 103;
|
||||
|
||||
// === Feature flags / experimental (900-999): 灰度测试用 ===
|
||||
bool new_ui_enabled = 900;
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 预留块的好处
|
||||
> 如果你的 User 消息已经有 13 个字段,未来需要新增 5 个字段,你只需要在 10~19 之间找空位。如果所有字段从 1 开始连续排列,每加一个都需要改后面所有的编号——而且已经部署的旧客户端会认为新编号的字段属于不同的语义。
|
||||
|
||||
## Reserved 保留字段
|
||||
|
||||
当你删除或重命名字段时,必须用 `reserved` 声明来防止后人误用相同的编号:
|
||||
|
||||
```protobuf
|
||||
message User {
|
||||
// ---- 当前活跃字段 ----
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
int32 age = 4;
|
||||
|
||||
// ---- 已废弃字段的编号保留 ----
|
||||
reserved "mobile"; // 之前叫 mobile 的字段已删除
|
||||
reserved 5, 6; // 编号 5 和 6 已释放,禁止复用
|
||||
reserved 7 to 10; // 编号 7~10 连续保留
|
||||
}
|
||||
```
|
||||
|
||||
### 删除字段的正确姿势
|
||||
|
||||
三步走,确保平滑过渡:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["📝 步骤 1: 用 reserved 占位"] --> S2["string old_field = 5;\n→\nreserved 5;"]
|
||||
S2 --> S3["📢 步骤 2: 协调消费方迁移"]
|
||||
S3 --> S4["(版本发布窗口内切换)"]
|
||||
S4 --> S5["✅ 步骤 3: 下次编译锁定\n有人复用 → compile error"]
|
||||
S5 --> Safe["后续正式移除"]
|
||||
|
||||
style S2 fill:#fff3cd
|
||||
style S4 fill:#fff3cd
|
||||
style Safe fill:#d4edda
|
||||
```
|
||||
|
||||
> [!question] 为什么不能只删字段不 reserved?
|
||||
> 如果没有 reserved,同事新建字段时使用相同编号:`string feature_flag = 5;`。旧版本客户端读到这个字节流时,会把 feature_flag 的值当成旧版 mobile 字段的值——数据语义完全错位,bug 极难排查。
|
||||
|
||||
## 兼容性矩阵(重点章节)
|
||||
|
||||
Protobuf 的设计确保了大部分 schema 变更不会破坏现有二进制协议:
|
||||
|
||||
| 操作 | 向后兼容? | 向前兼容? | 说明 |
|
||||
|------|-----------|-----------|------|
|
||||
| 新增字段 | ✅ | ✅ | 老客户端忽略未知编号;新客户端用默认值 |
|
||||
| 删除字段 | ✅ | ✅ | 老客户端读取已有数据;新客户端用默认值 |
|
||||
| 修改字段类型 | ❌ | ❌ | 新旧对同一编号解读不同 |
|
||||
| 修改字段编号 | ❌ | ❌ | 同编号对应不同语义 |
|
||||
| 修改枚举值名称 | ✅ | ✅ | 枚举值名不影响 wire format(传输的是数值) |
|
||||
| 新增枚举值 | ✅ | ⚠️ | 旧客户端收到未知枚举值回退为 0(首个值) |
|
||||
| 删除枚举值 | ❌ | ⚠️ | 旧客户端收到未知枚举值回退为 0 |
|
||||
| 单个 repeated 改为 non-repeated | ⚠️ | ❌ | 有数据的单元素列表可互转,多元素场景不兼容 |
|
||||
|
||||
### 实战:安全地扩展消息
|
||||
|
||||
假设你有一个在线上运行的 v1 proto:
|
||||
|
||||
```protobuf
|
||||
// v1 - 当前生产版本
|
||||
message GetUserResponse {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string email = 3;
|
||||
}
|
||||
```
|
||||
|
||||
**需求:添加 `avatar_url` 和 `role` 两个字段,同时删除 `email`。**
|
||||
|
||||
❌ **错误示范 — 直接复用已被 reserved 的编号:**
|
||||
|
||||
```protobuf
|
||||
// v2 - ❌ 编译失败
|
||||
message GetUserResponse {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
|
||||
reserved 3; // email 的编号已保留
|
||||
|
||||
string avatar_url = 3; // ← 编译报错:field number 3 is reserved
|
||||
}
|
||||
```
|
||||
|
||||
✅ **正确做法 — 使用新编号 + reserved 占位:**
|
||||
|
||||
```protobuf
|
||||
// v2 - ✅ 安全演进
|
||||
message GetUserResponse {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
|
||||
reserved 3; // 原 email 编号锁定,防止后人误用
|
||||
|
||||
string avatar_url = 4; // 新字段分配新编号
|
||||
User_Role role = 5;
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] 正确的分步迁移方案
|
||||
> 1. 先加字段 `avatar_url = 4`、`role = 5`,email 继续保留。
|
||||
> 2. 服务端双写:同时返回 email 和 avatar_url。
|
||||
> 3. 客户端升级,切换到使用 avatar_url。
|
||||
> 4. 确认旧客户端已淘汰后,标记 email 为 reserved,下次发版正式移除。
|
||||
|
||||
## 版本演进策略
|
||||
|
||||
对于大型项目,建议使用 package-level versioning 来管理 schema 演进:
|
||||
|
||||
### 目录结构与命名约定
|
||||
|
||||
```
|
||||
protos/
|
||||
├── user/
|
||||
│ └── v1/
|
||||
│ ├── user.proto
|
||||
│ ├── auth.proto
|
||||
│ └── error.proto
|
||||
└── order/
|
||||
└── v1/
|
||||
├── order.proto
|
||||
└── payment.proto
|
||||
```
|
||||
|
||||
```protobuf
|
||||
// option go_package 包含版本路径
|
||||
option go_package = "github.com/example/service/user/v1;userpb";
|
||||
|
||||
// import 路径与目录结构一致
|
||||
import "user/v1/user.proto";
|
||||
```
|
||||
|
||||
### Major Version 迁移方案
|
||||
|
||||
当需要做不兼容变更时(如改字段类型、重构消息结构):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph A["方案 A: v2 独立演进 🌟 推荐"]
|
||||
direction TB
|
||||
A1["user/v1/user.proto"] --> A2["新旧并存\nGateway 层做转换"]
|
||||
A3["user/v2/user.proto"] --> A2
|
||||
A2 --> A4["迁移完成\n停用 v1"]
|
||||
end
|
||||
|
||||
subgraph B["方案 B: 原地破坏 ❌ 高风险"]
|
||||
B1["user/v1/user.proto\n直接改"] --> B2["已部署端受影响"]
|
||||
end
|
||||
|
||||
style A fill:#d4edda
|
||||
style B fill:#f8d7da
|
||||
style A4 fill:#28a745,color:#fff
|
||||
style B2 fill:#dc3545,color:#fff
|
||||
```
|
||||
|
||||
| 维度 | 方案 A(v2 独立文件) | 方案 B(原地修改) |
|
||||
|------|---------------------|-------------------|
|
||||
| 风险等级 | 低 | **极高** |
|
||||
| 线上影响 | Gateway 透明转换 | 所有端同时断裂 |
|
||||
| 回滚成本 | 切回 v1 即可 | 几乎无法回滚 |
|
||||
| 适用场景 | 所有已发布服务 | 仅限内部未发布 proto |
|
||||
|
||||
> [!tip] 灰度策略:双字段过渡法
|
||||
> 如果需要在同一消息中过渡一个新字段到旧字段,分三阶段进行:
|
||||
> ```protobuf
|
||||
> // 阶段一: 服务端双写两个字段
|
||||
> message User {
|
||||
> string legacy_name = 1; // 旧字段,逐步弃用
|
||||
> string display_name = 2; // 新字段,逐步启用
|
||||
> }
|
||||
>
|
||||
> // 阶段二: 客户端优先读 display_name, 回退到 legacy_name
|
||||
> //
|
||||
> // 阶段三: 确认全部升级后移除 legacy_name (步骤见上方「删除字段的正确姿势」)
|
||||
> ```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **为每个 microservice 预留独立 namespace**:`package service_name.version`。
|
||||
- **不要重复使用 field numbers**:即使在同一个文件中删除了字段也要 reserved。
|
||||
- **核心高频字段编号保持在 1~15**:节省 wire 编码开销。
|
||||
- **重大变更走 v2 而不是改现有文件**:降低线上风险。
|
||||
- **在 CI 中加入 proto linter**(如 buf lint):自动化检查编号冲突和命名规范。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 消息定义基础语法
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解]] — 字段可用的所有数据类型及默认值规则
|
||||
- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 的 field numbering 有特殊规则
|
||||
- [[hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范]] — Proto 文件的工程组织与命名规范
|
||||
@@ -0,0 +1,438 @@
|
||||
---
|
||||
tags: [gRPC, Protobuf, oneof, Any, Value, FieldMask, dynamic types]
|
||||
create time: 2026-05-11 16:40
|
||||
---
|
||||
|
||||
# Oneof 与包装类型
|
||||
|
||||
## 概述
|
||||
|
||||
Oneof 是 Protobuf 中最灵活的结构之一:它让你在多个互斥字段中只选一个。配合 `google.protobuf.Any` 和 `Value`,你可以写出几乎泛型的消息定义。这些工具如果用得好,能省去大量样板代码。
|
||||
|
||||
## Oneof 基础
|
||||
|
||||
Oneof 的核心语义:**同一时刻只有一个字段有值**:
|
||||
|
||||
```protobuf
|
||||
message PaymentRequest {
|
||||
string order_id = 1;
|
||||
|
||||
oneof payment_method {
|
||||
Alipay alipay = 2;
|
||||
Wechat wechat = 3;
|
||||
ApplePay apple = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message Alipay {
|
||||
string return_url = 1;
|
||||
string device_id = 2;
|
||||
}
|
||||
|
||||
message Wechat {
|
||||
string openid = 1;
|
||||
string scene_info = 2;
|
||||
}
|
||||
|
||||
message ApplePay {
|
||||
string payment_token = 1;
|
||||
string merchant_domain = 2;
|
||||
}
|
||||
```
|
||||
|
||||
序列化时,只有被设置的那个 oneof 成员会出现在输出中:
|
||||
|
||||
```
|
||||
// 如果设置的是 alipay 字段:
|
||||
[wire: field_number=2, value=<Alipay serialized>]
|
||||
|
||||
// 不会同时出现 field_number=3 或 4
|
||||
```
|
||||
|
||||
> [!important] 重要行为
|
||||
> oneof 字段在**没有设置任何值**时不会出现在 serialized output 中。如果客户端只填了 `order_id` 而未选支付方式,服务端收到的 `payment_method` 对应的 oneof selector 为 nil。
|
||||
|
||||
### 为什么需要 Oneof?
|
||||
|
||||
如果没有 oneof,你会这样写:
|
||||
|
||||
```protobuf
|
||||
// ❌ 无法阻止同时设置 alipay 和 wechat
|
||||
message PaymentRequest {
|
||||
Alipay alipay = 2;
|
||||
Wechat wechat = 3;
|
||||
}
|
||||
```
|
||||
|
||||
**问题在哪?** 协议层没有任何互斥约束。客户端可能同时填入两个支付方式,服务端必须自己加额外校验。Oneof 把校验推给了 protobuf 编译器——在 wire format 层面保证同一时刻只有一个字段有数据。
|
||||
|
||||
## Oneof 的 Go 实现细节
|
||||
|
||||
protoc-gen-go 为一组 oneof 生成一个接口 + 一组实现结构体:
|
||||
|
||||
```go
|
||||
// 生成的代码片段
|
||||
type isPaymentRequest_PaymentMethod interface {
|
||||
isPaymentRequest_PaymentMethod()
|
||||
}
|
||||
|
||||
type PaymentRequest_Alipay struct{ Alipay *Alipay }
|
||||
type PaymentRequest_Wechat struct{ Wechat *Wechat }
|
||||
type PaymentRequest_Apple struct{ Apple *ApplePay }
|
||||
|
||||
type PaymentRequest struct {
|
||||
OrderId string
|
||||
PaymentMethod isPaymentRequest_PaymentMethod
|
||||
}
|
||||
```
|
||||
|
||||
### 如何判断 set 的是哪个字段
|
||||
|
||||
```go
|
||||
req := &pb.PaymentRequest{
|
||||
OrderId: "ORD-123",
|
||||
PaymentMethod: &pb.PaymentRequest_Alipay{
|
||||
Alipay: &pb.Alipay{ReturnUrl: "https://example.com/return"},
|
||||
},
|
||||
}
|
||||
|
||||
switch pm := req.PaymentMethod.(type) {
|
||||
case *pb.PaymentRequest_Alipay:
|
||||
fmt.Println("使用支付宝:", pm.Alipay.ReturnUrl)
|
||||
case *pb.PaymentRequest_Wechat:
|
||||
fmt.Println("使用微信支付:", pm.Wechat.Openid)
|
||||
case *pb.PaymentRequest_Apple:
|
||||
fmt.Println("使用 Apple Pay")
|
||||
default:
|
||||
fmt.Println("未选择支付方式") // oneof 中没有设置任何值
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] oneof 赋值规则
|
||||
> 每次给 oneof 赋值会自动清除之前的值:
|
||||
> ```go
|
||||
> req.PaymentMethod = &pb.PaymentRequest_Alipay{...}
|
||||
> // 此时其他 oneof 字段自动被设为 nil
|
||||
> ```
|
||||
|
||||
> [!question] 思考题
|
||||
> 如果 oneof 里全是基本类型(如 `string`、`int32`),Go 生成的代码会是什么样子?和引用类型有什么差异?提示:去看生成代码中 `isPaymentRequest_PaymentMethod()` 的具体实现。
|
||||
|
||||
## Wrapper Types 重访
|
||||
|
||||
回到 wrapper types,这里给出决策树来帮你选择正确的工具:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["需要一个可选字段"] --> B{"是否只需要一个可选值?"}
|
||||
B -->|是| C["使用 Wrapper Type<br/>例: StringValue"]
|
||||
B -->|否| D{"字段之间是否互斥?"}
|
||||
D -->|是| E["使用 Oneof"]
|
||||
D -->|否| F["用普通字段<br/>默认零值即可"]
|
||||
C --> G{"需要动态/不确定类型?"}
|
||||
E --> G
|
||||
F --> G
|
||||
G -->|是| H["使用 Any 或 Value"]
|
||||
G -->|否| I["完成 ✓"]
|
||||
|
||||
style C fill:#10b981,color:#fff
|
||||
style E fill:#f59e0b,color:#fff
|
||||
style H fill:#3b82f6,color:#fff
|
||||
```
|
||||
|
||||
对比场景:
|
||||
|
||||
```protobuf
|
||||
// ❌ 用 oneof 表达"单个可选字段" —— 过度复杂
|
||||
message UserUpdate {
|
||||
oneof name_field {
|
||||
string name = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 等价但更简洁的写法
|
||||
message UserUpdate {
|
||||
google.protobuf.StringValue name = 1;
|
||||
}
|
||||
```
|
||||
|
||||
```protobuf
|
||||
// ❌ 用多个单独字段表达"互斥字段" —— 无法 enforcing
|
||||
message Notification {
|
||||
string email = 1; // 可能三个都有值!
|
||||
string sms = 2;
|
||||
string push_id = 3;
|
||||
}
|
||||
|
||||
// ✅ 用 oneof 确保互斥
|
||||
message Notification {
|
||||
oneof channel {
|
||||
string email = 1;
|
||||
string phone = 2;
|
||||
string push_id = 3;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Google.Protobuf.Any
|
||||
|
||||
`Any` 是一个万能容器,可以包裹任意类型的 protobuf 消息,常用于 plugin architecture、事件总线等场景:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/any.proto";
|
||||
|
||||
// 事件总线中的通用事件消息
|
||||
message Event {
|
||||
string event_id = 1;
|
||||
string event_type = 2; // e.g., "UserRegistered"
|
||||
google.protobuf.Any payload = 3; // 根据 event_type 反序列化
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
}
|
||||
|
||||
// 具体的 payload 消息
|
||||
message UserRegistered {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
string email = 3;
|
||||
}
|
||||
|
||||
message OrderCreated {
|
||||
string order_id = 1;
|
||||
string user_id = 2;
|
||||
int64 amount = 3;
|
||||
}
|
||||
```
|
||||
|
||||
### Any 的使用模式
|
||||
|
||||
```go
|
||||
// 构造:将具体消息包装进 Any
|
||||
reg := typeurl.NewRegistry()
|
||||
|
||||
userRegistered := &pb.UserRegistered{
|
||||
UserId: "USR-001", Username: "alice", Email: "alice@example.com",
|
||||
}
|
||||
anyPayload, err := anypb.New(userRegistered)
|
||||
if err != nil { ... }
|
||||
|
||||
event := &pb.Event{
|
||||
EventId: "EVT-001",
|
||||
EventType: "UserRegistered",
|
||||
Payload: anyPayload,
|
||||
Timestamp: timestamppb.Now(),
|
||||
}
|
||||
|
||||
// 反序列化:通过 registry 提取原始类型
|
||||
var extracted pb.UserRegistered
|
||||
if err := event.Payload.UnmarshalTo(&extracted); err != nil { ... }
|
||||
fmt.Println("新注册用户:", extracted.Username)
|
||||
```
|
||||
|
||||
在 JSON 映射中,Any 的表现形式:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "EVT-001",
|
||||
"event_type": "UserRegistered",
|
||||
"payload": {
|
||||
"@type": "type.googleapis.com/UserRegistered",
|
||||
"user_id": "USR-001",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"timestamp": "2026-05-11T08:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] @type URL 的含义
|
||||
> `type.googleapis.com/<FullMessageType>` 是标准的 type URL 格式。`UnmarshalTo` 会根据这个 URL 查找对应的 descriptor,从而确定如何解码 `value` 字节流。
|
||||
|
||||
### Any 的典型应用场景
|
||||
|
||||
| 场景 | 描述 | 示例 |
|
||||
|------|------|------|
|
||||
| **Plugin Architecture** | 核心消息固定结构,payload 由插件注入 | gRPC Gateway 转发自定义 header |
|
||||
| **Event Bus** | 不同事件类型有不同的 payload 格式 | Kafka/RabbitMQ 事件驱动架构 |
|
||||
| **Generic Response Wrapper** | API 返回类型不确定的数据 | GraphQL-like 查询结果 |
|
||||
| **Multi-tenant Data** | 不同租户使用不同的扩展字段 | SaaS 平台的多态配置存储 |
|
||||
|
||||
## Google.Protobuf.Value(万能类型)
|
||||
|
||||
`Value` 可以包裹任意合法的 JSON 类型,比 Any 更宽松——不需要提前注册类型:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
message MetaStore {
|
||||
string key = 1;
|
||||
google.protobuf.Value value = 2; // 可以是 object / array / string / number / bool / null
|
||||
google.protobuf.Value metadata = 3; // 另一个自由格式的存储
|
||||
}
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
```go
|
||||
// 元数据存储:key-value,但 value 的结构完全由调用方决定
|
||||
store := &pb.MetaStore{
|
||||
Key: "user:1001:preferences",
|
||||
Value: &structpb.Value{
|
||||
Kind: &structpb.Value_StructValue{
|
||||
StructValue: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"theme": structpb.NewStringValue("dark"),
|
||||
"font_size": structpb.NewNumberValue(16),
|
||||
"notifications": structpb.NewBoolValue(true),
|
||||
"languages": structpb.NewListValue(
|
||||
&structpb.ListValue{Values: []*structpb.Value{
|
||||
structpb.NewStringValue("zh-CN"),
|
||||
structpb.NewStringValue("en"),
|
||||
}},
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 代价
|
||||
> 使用 `Value` 意味着**放弃了静态类型检查**。编译器无法验证你读取的数据格式是否正确,所有的解析逻辑都需要在运行时处理。适合做 configuration store 或 audit log,不适合业务核心链路。
|
||||
|
||||
### 反序列化 Value
|
||||
|
||||
从 `Value` 中提取数据需要手动解包,这也是类型不安全的主要体现:
|
||||
|
||||
```go
|
||||
// 从 StructValue 中取数据
|
||||
preferences := store.Value.GetStructValue()
|
||||
theme := preferences.Fields["theme"].GetStringValue() // "dark"
|
||||
fontSize := preferences.Fields["font_size"].GetNumberValue() // 16
|
||||
langs := preferences.Fields["languages"].GetListValue() // []string{"zh-CN", "en"}
|
||||
|
||||
// 也可以用 ToValue 转为原生 Go 类型
|
||||
native, err := structpb.NewValue(preferences)
|
||||
if err != nil { ... }
|
||||
// native.Interface() → map[string]any
|
||||
```
|
||||
|
||||
> [!question] 思考题
|
||||
> `Any` 和 `Value` 都能包裹动态内容,该用哪个?记住一个原则:**如果你知道消息类型且想享受编译期检查,用 Any;如果你连结构都不确定(比如纯 JSON),用 Value。**
|
||||
|
||||
## FieldMask
|
||||
|
||||
`FieldMask` 用于 partial response 和 partial update,指定操作涉及的字段子集:
|
||||
|
||||
```protobuf
|
||||
import "google/protobuf/field_mask.proto";
|
||||
|
||||
message GetUserRequest {
|
||||
string id = 1;
|
||||
google.protobuf.FieldMask read_mask = 2; // 只返回指定的字段
|
||||
}
|
||||
|
||||
message UpdateUserRequest {
|
||||
string id = 1;
|
||||
google.protobuf.FieldMask update_mask = 2; // 只更新指定的字段
|
||||
User user = 3;
|
||||
}
|
||||
```
|
||||
|
||||
典型 PATCH 接口的 usage:
|
||||
|
||||
```go
|
||||
// 客户端请求:只更新 name 和 email
|
||||
updateReq := &pb.UpdateUserRequest{
|
||||
Id: "USR-001",
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"name", "email"}, // 只修改这两个字段
|
||||
},
|
||||
User: &pb.User{
|
||||
Name: "Alice Updated",
|
||||
Email: "newalice@example.com",
|
||||
Age: 999, // ← 会被忽略,因为不在 update_mask 中
|
||||
},
|
||||
}
|
||||
|
||||
// 服务端 Handler 中解析 mask
|
||||
for _, path := range updateReq.UpdateMask.Paths {
|
||||
switch path {
|
||||
case "name":
|
||||
user.Name = updateReq.User.Name
|
||||
case "email":
|
||||
user.Email = updateReq.User.Email
|
||||
// Age 不会被更新!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
FieldMask 还支持嵌套路径:
|
||||
|
||||
```go
|
||||
// 更新嵌套对象的字段
|
||||
Paths: []string{"profile.display_name", "settings.theme"}
|
||||
```
|
||||
|
||||
> [!tip] FieldMask 的安全用法
|
||||
> 永远不要直接用 client 传入的 mask 做 `reflect` 反射赋值——这会导致 security vulnerability(如覆盖 system 字段)。应当使用白名单校验:
|
||||
> ```go
|
||||
> allowed := map[string]bool{"name": true, "email": true}
|
||||
> for _, p := range mask.Paths {
|
||||
> if !allowed[p] {
|
||||
> return error.New("field not updatable")
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
|
||||
### FieldMask 实用方法
|
||||
|
||||
Google 提供了 [`fieldmaskpb`](https://pkg.go.dev/google.golang.org/protobuf/types/known/fieldmaskpb) 工具包,常见操作如下:
|
||||
|
||||
```go
|
||||
// 获取嵌套字段的扁平路径
|
||||
mask := fieldmaskpb.FieldMask{Paths: []string{"profile.display_name"}}
|
||||
flat := mask.String() // "profile.display_name"
|
||||
|
||||
// 合并两个 mask:取并集
|
||||
maskA := &fieldmaskpb.FieldMask{Paths: []string{"name", "email"}}
|
||||
maskB := &fieldmaskpb.FieldMask{Paths: []string{"avatar"}}
|
||||
merged, _ := fieldmaskpb.Merge(maskA, maskB) // ["name","email","avatar"]
|
||||
|
||||
// 从子结构推导出父 mask:只保留 user 中实际变化的字段
|
||||
changedFields := computeChangedFields(oldUser, newUser)
|
||||
effectiveMask, _ := fieldmaskpb.New(changedFields...)
|
||||
```
|
||||
|
||||
> [!note] JSON 中的 FieldMask 格式
|
||||
> 在 gRPC Gateway 等 HTTP→gRPC 桥接层,FieldMask 以逗号分隔的字符串传递:
|
||||
> ```
|
||||
> GET /users/USR-001?read_mask=name,email,profile.avatar
|
||||
> ```
|
||||
|
||||
## 本节小结
|
||||
|
||||
这一节覆盖了 Protobuf 中处理"不确定性"的四个工具:
|
||||
|
||||
| 工具 | 解决什么问题 | 一句话总结 |
|
||||
|------|-------------|-----------|
|
||||
| **Oneof** | 互斥字段 | 编译期保证"三选一",不要自己加校验逻辑 |
|
||||
| **Wrapper Type** | 单个可选字段 | `StringValue` 比 `oneof string` 简洁十倍 |
|
||||
| **Any** | 已知但可变的消息类型 | 事件总线、插件架构的核心武器 |
|
||||
| **Value** | 完全自由的 JSON 数据 | 放弃类型安全换取灵活性,用在配置层而非业务核心 |
|
||||
|
||||
## 对比总结表格
|
||||
|
||||
| 特性 | Oneof | Wrapper | Any | Value |
|
||||
|------|-------|---------|-----|-------|
|
||||
| 类型安全 | ✅ compile-time | ✅ compile-time | ⚠️ runtime | ❌ 运行时 |
|
||||
| 单个可选 | ✅ 可用 | ✅(更简洁) | N/A | N/A |
|
||||
| 多值互斥 | ✅ 核心用途 | ❌ | N/A | N/A |
|
||||
| 动态类型 | ❌ | ❌ | ✅ | ✅ |
|
||||
| JSON 互转 | ⚠️ 需额外处理 | ✅ | ✅ | ✅ |
|
||||
| wire overhead | 低 | 低 | 中(需存 type_url) | 低 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[01-Protobuf 语法与消息定义]] — Protobuf 基础语法入门
|
||||
- [[02-数据类型详解]] — 标量、枚举、map、repeated 等类型深入
|
||||
- [[03-字段编号与前向兼容]] — 字段编号管理与版本演进策略
|
||||
Reference in New Issue
Block a user