From 0241faca23faf348274e4aaa30319a406127530f Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Tue, 12 May 2026 15:26:17 +0800 Subject: [PATCH] vault backup: 2026-05-12 15:26:17 --- .../01-Protobuf 语法与消息定义.md | 6 +- .../1. Protobuf 基础篇/02-数据类型详解.md | 209 ++++++++---- .../03-字段编号与前向兼容.md | 307 +++++++++++------- .../04-FieldMask 实战/FieldMask 实战.md | 146 +++++++++ 4 files changed, 494 insertions(+), 174 deletions(-) create mode 100644 hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md diff --git a/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md b/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md index 7b0e32e..f9c117e 100644 --- a/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md +++ b/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md @@ -120,7 +120,7 @@ var req LoginRequest fmt.Println(req.Username) // "" — 到底是没传还是传了 ""? ``` -**这就是 proto3 最著名的陷阱:客户端读不到"未设置"和"设为零值"的区别。** 解决之道是在需要使用包装类型时用 Wrapper Types,详情见 [02-数据类型详解](./02-数据类型详解.md)。 +**这就是 proto3 最著名的陷阱:客户端读不到"未设置"和"设为零值"的区别。** proto2 通过 `has_xxx` 字段解决这个问题,而 proto3 在 3.12+ 引入了 `optional` 关键字(生成时同样附带 `has_xxx`)。不过最通用的实践仍是用包装类型——详见 [02-数据类型详解](./02-数据类型详解.md)。 ## Enum 枚举类型 @@ -189,7 +189,7 @@ case *UpdateProfileRequest_Email: ``` > [!question] oneof vs 单独字段?什么时候该用 oneof? -> 如果你希望业务逻辑保证「每次请求只更新一个字段」,用 oneof 可以让编译器帮你 enforcing 这个约束。但如果只是"几个可选字段可能同时出现"的场景,反而应该用单独的 field —— oneof 会增加代码复杂度(需要 switch/case 判断哪个被设置了)。**本质区别:oneof 表达的是"二选一或多选一"的互斥关系。** +> 如果你希望业务逻辑保证「每次请求只更新一个字段」,用 oneof 可以让编译器强制约束这个规则。但如果只是"几个可选字段可能同时出现"的场景,反而应该用单独的 field —— oneof 会增加代码复杂度(需要 switch/case 判断哪个被设置了)。**本质区别:oneof 表达的是"二选一或多选一"的互斥关系。** ## Map 键值映射 @@ -207,7 +207,7 @@ message UserProfile { } ``` -在 Go 中生成的对应类型为 `map[string]string`,**注意默认为 nil(而非空 map)**。如果需要确保非 nil,可以用 `repeated` + key-value message 替代。 +在 Go 中生成的对应类型为 `map[string]string`,**注意默认为 nil**。如果需要确保非 nil,可以用 `repeated` + key-value message 替代。 ## Reserved 保留字段 diff --git a/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md b/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md index 37947c5..61bcd81 100644 --- a/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md +++ b/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md @@ -18,24 +18,23 @@ Protobuf 的类型系统看起来简单,但有很多容易被忽略的细节 ```mermaid graph TD - A["Protobuf 类型系统"] --> B["Scalar Types\n标量类型"] - A --> C["Composite Types\n复合类型"] - A --> D["Well-Known Types\n内置类型"] + Root["Protobuf\n类型系统"] --> Scalar["Scalar Types\n标量类型"] + Root --> Composite["Composite Types\n复合类型"] + Root --> WKT["Well-Known Types\n内置类型"] - B --> B1["整数系: int32 / int64 / uint32 / uint64 / sint32 / sint64"] - B --> B2["浮点系: float / double"] - B --> B3["其他: bool / string / bytes"] + Scalar --> SInt["整数系\nint32 / int64 / sint32 / ..."] + Scalar --> SFloat["浮点系\nfloat / double"] + Scalar --> SOther["其他\nbool / string / bytes"] - C --> C1["repeated\n(动态列表)"] - C --> C2["map\n(键值对)"] - C --> C3["message\n(自定义结构)"] - C --> C4["oneof\n(互斥字段)"] + Composite --> Rep["repeated\n动态列表"] + Composite --> MapType["map\n键值对"] + Composite --> Msg["message\n自定义结构"] + Composite --> OneofT["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)"] + WKT --> WTime["时间类\nTimestamp / Duration"] + WKT --> WWrap["包装类\nStringValue / Int32Value / ..."] + WKT --> WGen["泛型类\nAny / Value / Struct"] + WKT --> WUtil["工具类\nFieldMask / Empty"] ``` ### proto2 vs proto3 关键差异 @@ -105,17 +104,22 @@ message Offset { > [!tip] float vs double 取舍 > HTTP/2 + TLS 已经压缩了网络传输,**节省几个字节对延迟的影响微乎其微**。优先选择 `float32`,除非你的业务需要 IEEE 754 双精度精度(如金融计算)。 +> [!note] string vs bytes:不只是编码区别 +> - `string` **必须是合法 UTF-8**,不合法的字节序列在解析时会报错。适合人类可读的文本内容。 +> - `bytes` 不做编码校验,可以存任意二进制数据(图片、加密密文等),且在 Go/Java 中生成的是可变长度数组,追加元素更灵活。 +> - 如果不确定对方语言的 UTF-8 实现是否严格,用 `bytes` 更安全——接收端自行解码。 + ### Varint 编码与 zigzag 的关系 -很多人分不清 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"] + N["原始整数"] --> S{"是否负数?"} + S -- 否 --> V["varint 直接编码"] + S -- 是 --> Z["zigzag 变换\nn → (n << 1) ^ (n >> 31)"] + Z --> V + V --> E["变长字节序列\n小数字仅 1 byte"] ``` - **varint**:只处理非负数,数字越小占的字节越少。`1` 占 1 byte,`2^31` 占 5 bytes。 @@ -137,24 +141,13 @@ message TagList { 考虑一组 `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(proto2 需显式声明) | Packed(proto3 默认) | +|------|-------------------------------|------------------------| +| tag | 每个元素前都写一次 | 只在开头写一次 | +| length | 无,每个 value 独立跟随 tag | 在 tag 后附加总长度字节 | +| value | `tag + val` × N | `tag + len + val₁ + val₂ + ...` | +| `[1, 2, 3]` 示意 | `tag·1 ·val₁· tag·2 ·val₂· tag·3 ·val₃·` | `tag·len·val₁·val₂·val₃` | +| 总字节数 | 6B | 5B | 随着元素数量增长,差距越来越明显: @@ -231,14 +224,30 @@ Protobuf 提供了所有标量类型的 wrapper,Go 中一一对应: Protobuf 内置了一组通用的消息类型,称为 Well-Known Types(WKT),全部定义在 `google/protobuf/` 下。它们在不同语言中有各自的 native 映射,是实现跨语言兼容的关键。 -核心 WWT 分类如下: +核心 WKT 分类如下: ```mermaid graph LR - A["Well-Known Types"] --> B["日期/时间\nTimestamp / Duration"] - A --> C["可选包装\nWrapper Types × 7"] - A --> D["泛型/动态\nAny / Value / Struct"] - A --> E["实用工具\nFieldMask / Empty / ..."] + subgraph Time["时间类"] + T1["Timestamp"] + T2["Duration"] + end + subgraph Wrap["包装类"] + W1["StringValue"] + W2["Int32Value / BoolValue / ..."] + end + subgraph Gen["泛型类"] + G1["Any"] + G2["Value / Struct"] + end + subgraph Util["工具类"] + U1["FieldMask"] + U2["Empty"] + end + RootW["Well-Known Types"] --> Time + RootW --> Wrap + RootW --> Gen + RootW --> Util ``` ### 时间相关:Timestamp & Duration @@ -290,31 +299,102 @@ proto.ApplyFieldMask(&updatedUser, req.GetUser()) JSON 传递时也很简洁:`{ "updateMask": "display_name,email", "user": { "display_name": "新名字" } }`。 -### Any:泛型消息容器 +> [!tip] FieldMask 更深入的用法(嵌套路径、服务端校验等)参见 [[hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战]]。 -`Any` 允许你在不知道具体消息类型的情况下传递消息,常用于事件总线或插件架构: +### Any:Protobuf 的"万能盒子" + +想象你有一个快递盒(`Any`),里面可以装任何东西——只要这东西是 protobuf 消息就行。好处是你不需要提前知道盒子里是什么,只需要在拆盒子前查一张**"标签→物品类型"**对照表就行。 ```protobuf import "google/protobuf/any.proto"; message Event { - google.protobuf.Any payload = 1; // 任意 protobuf message + string event_id = 1; + google.protobuf.Any payload = 2; // 这个盒子可以装任何消息 } ``` -反序列化时需要注册 type registry: +#### 盒子里到底存了什么? + +每个 `Any` 在底层只存了两个东西: + +| 字段 | 类型 | 作用 | +|------|------|------| +| `type_url` | 字符串 | 类似文件扩展名,告诉接收方"这玩意儿是什么类型" | +| `value` | 字节序列 | 具体消息的二进制数据 | + +举个例子,如果 `payload` 里装的是 `OrderCreated` 消息,它长这样: + +- `type_url = "type.googleapis.com/mypb.OrderCreated"` — **这是什么** +- `value = [序列化后的二进制字节...]` — **具体内容** + +#### 完整使用流程(三步走) + +**第 1 步:装箱(发送端)** — 把具体的消息塞进 `Any` ```go -// 注册已知类型 -ptypes.RegisterAnyType(reflect.TypeFor[OrderCreated]()) +// 假设你已经有了 OrderCreated 对象 +order := &OrderCreated{OrderId: "123", Amount: 9900} -// 从 Any 中提取具体类型 -event := &Event{} -payload, _ := ptypes.UnmarshalAny(event.Payload) +// 用 proto.MarshalAny 自动打包成 Any(内部做了 marshal + 设置 type_url) +anyPayload, _ := proto.MarshalAny(order) + +event := &Event{ + EventId: "evt-001", + Payload: anyPayload, // ✅ 装上盒了 +} ``` -> [!danger] 谨慎使用 Any -> `Any` 绕过了静态类型检查,滥用会导致调试困难。只在**真正的扩展点**(如插件系统、事件溯源)使用,不要用它来替代正常的消息设计。 +**第 2 步:注册类型映射(反序列化端,只需一次)** — 告诉程序 `"type_url" → "什么类型"` + +```go +// 在程序启动时全局注册一次即可,之后所有地方都能用 +proto.RegisterName( + reflect.TypeFor[OrderCreated](), + "type.googleapis.com/mypb.OrderCreated", +) +``` + +这一步就像在邮局备案:"如果快递单上写的是 `type.googleapis.com/mypb.OrderCreated`,那里面就是 `OrderCreated` 这个类型的包裹。"不注册的话,收到包裹后程序不知道该怎么拆开。 + +**第 3 步:拆箱(接收端)** — 从 `Any` 中取出原始消息,有两种方式: + +```go +event := ... // 收到 Event,其中 event.Payload 是 Any 类型 + +// 写法 A:你知道里面是什么类型(推荐 ✅) +var msg OrderCreated +if err := event.Payload.UnmarshalTo(&msg); err != nil { + log.Fatal(err) +} +// 此时 msg 已经是强类型的 OrderCreated,可以直接用 msg.OrderId + +// 写法 B:你不知道里面是什么类型(适合通用框架) +payload, err := proto.UnmarshalAny(event.Payload) +if err != nil { + log.Fatal(err) +} +// payload 是 proto.Message 接口,需要类型断言 +switch m := payload.(type) { +case *OrderCreated: + fmt.Println("订单:", m.OrderId) +case *UserRegistered: + fmt.Println("新用户:", m.Username) +} +``` + +> [!tip] 两种拆箱方式怎么选? +> - **90% 的场景用 UnmarshalTo**:你已经知道负载类型,这种方式编译期就能检查类型匹配,不会漏掉 case。 +> - 只有在写事件总线、插件系统等真正"不知道负载类型"的场景才用 UnmarshalAny + switch。 + +#### 实际应用场景 + +- **事件溯源 / CQRS**:一个 `EventStream` 可以按顺序存储不同类型的领域事件(订单创建、支付成功、物流发货……)。 +- **gRPC 插件架构**:主服务定义一个接收 `Any` 的方法,插件自己注册类型映射,互不依赖对方 proto 文件。 +- **跨服务消息传递**:上游服务只管往 `Any` 里放东西,下游服务按需拆包,解耦两个服务之间的类型依赖。 + +> [!warning] Any 不是银弹 +> 它绕过了静态类型检查,滥用会让调用链变得难以追踪。记住一条原则:**能在 proto 设计阶段明确的类型关系,就不要用 Any 模糊处理**。只在真正的"扩展点"使用。 ## Map 类型细节 @@ -400,6 +480,14 @@ default: > - **协议切换**:同一个连接支持多种子协议 > - **互斥配置**:比如渲染模式只能选一种(WebGL / Canvas / SVG) +> [!warning] Oneof 的副作用:设置一个字段会清除其他字段 +> Oneof 字段是互斥的——给 `alipay_token` 赋值时,之前设好的 `wechat_pay_nonce` 会被自动清空。这在链式调用中容易造成隐蔽 bug: +> ```go +> req.WechatPayNonce = "abc" // 设为微信支付 +> req.AlipayToken = "xyz" // 微信字段被静默清除!现在只走了支付宝 +> ``` +> 建议封装 Builder 模式来避免这种陷阱。 + ## 最佳实践总结 - **优先使用 `int32`**,除非确定数据范围超过 ±21 亿才用 `int64`。 @@ -407,12 +495,15 @@ default: - **需要表达"可选"时优先考虑 wrapper types**,比 oneof 更简洁,比裸 scalar 更能区分零值和缺失。 - **timestamp 统一用 RFC3339 string**,跨语言互通性最好。 - **sint32/sint64** 仅在小范围内有正负波动的场景(如 offset、delta)中使用。 -- **oneof 用在"多选一"的互斥场景**,而不是用来模拟 optional。 +- **oneof 用在"多选一"的互斥场景**,而不是用来模拟 optional;注意设置一个字段会清空其他字段。 - **FieldMask 是实现 RESTful PATCH 语义的神器**,别自己解析 JSON 路径了。 - **慎用 Any**,只在真正的扩展点使用,避免绕过类型安全。 +- **字符串内容选 `string`,二进制选 `bytes`**——后者不校验 UTF-8,在异构语言环境中更安全。 +- **不要在 proto 中定义嵌套 map**:不支持 `map>`,需要用 message + repeated 替代。 ## 关联笔记 -- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 语法入门,建议先读本篇再来看本文 -- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容]] — 字段编号管理、向前向后兼容规则 -- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 深度使用 + Wrapper Type 实战模式 +- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 语法入门,包含 message/enum 等基础结构,建议先读本篇再来看本文 +- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容]] — 字段编号管理、向前向后兼容规则,与本篇的零值语义紧密相关 +- [[hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战]] — FieldMask 深入:嵌套路径、服务端校验与最佳实践 +- [[hhs/gRPC/1. Protobuf 基础篇/05-序列化与跨语言实战]] — wire encoding 深入 + Java/Python/Go 跨语言互调踩坑记录 diff --git a/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md b/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md index 4ac2264..13e5fbf 100644 --- a/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md +++ b/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md @@ -1,20 +1,29 @@ --- tags: [gRPC, Protobuf, field number, reserved, backward compatibility, forward compatibility, versioning] create time: 2026-05-11 16:40 +update time: 2026-05-12 14:30 --- # 字段编号与前向兼容 ## 概述 -每一个字段都有一个 tag number,这行简单的数字背后藏着 Protobuf 最核心的设计原则:**向后兼容**。理解这套机制,你就能放心地修改 protobuf schema 而不用担心打坏线上服务。 +Protobuf 的 wire format(二进制传输格式)从设计之初就围绕一个核心目标:**schema 可以演进,但线上服务不能断**。实现这个目标的关键在于一个字段的 tag number——每个字段分配的整数编号。 + +理解这套机制后,你就能看到为什么 Protobuf 能做到"删了一个字段,百万 QPS 的线上服务毫无感知"。 + +> [!question] 先思考一个问题 +> 假设你在线上跑着 `GetUser` API,客户端和服务端都稳定运行了两年。现在你需要添加 `avatar_url` 字段并删除 `phone` 字段。**你能在不重启任何服务、不升级任何客户端的前提下完成这件事吗?** +> +> 答案是:可以,但需要正确管理 field numbers。这就是这篇笔记要讲的事。 > [!note] 核心概念速记 -> - **向后兼容**(Old → New):旧版客户端跑新版服务端返回的数据 — Protobuf 保证**未知字段被安全忽略** -> - **向前兼容**(New → Old):新版客户端跑旧版服务端返回的数据 — 缺失字段取**类型默认值** +> - **向后兼容**(Old Client → New Server):旧版客户端收到新版服务端返回的数据 — Protobuf 保证**未知字段被安全忽略**(跳过字节即可) +> - **向前兼容**(New Client → Old Server):新版客户端请求旧版服务端返回的数据 — 缺失字段取**类型默认值**(string="", int32=0, bool=false, repeated=[]) +> - **wire encoding version**:目前仅有一个版本(varint + length-delimited),如果未来推出 v2 wire protocol,兼容性规则可能会变化 -> [!warning] 注意 -> 一旦字段编号被分配并部署,就**永远不能再复用**它。这是 Protobuf 的硬伤——编号就像 UUID,一旦发出去就是它的了。 +> [!warning] 铁律 +> 一旦字段编号在某个部署中使用了,就**永远不能再复用**它。这不是建议而是硬性约束——编号就像 UUID,一旦发出去就是它的了。 ## Tag Number 分配规则 @@ -43,42 +52,46 @@ message User { ### Wire Encoding 原理解析 -Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少: +Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少。每个字段的 wire 头部(wire tag)由两个部分组成: -| 编号范围 | Wire Encoding 大小 | 说明 | -|---------|-------------------|------| -| 1 ~ 15 | 1 byte | 黄金区间,预留给你的核心高频字段 | -| 16 ~ 2047 | 2 bytes | 次优先区间 | -| 2048+ | 3~5 bytes | 低频字段可放这里 | +``` +Wire Tag = (field_number << 3) | wire_type +``` + +其中低 3 位固定存 wire type,高 29 位存 field number——这就是为什么最大 field number 是 `2^29 - 1` = 536,870,911。 ```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 ✅"] + subgraph FieldNum["Field Number = 3"] + direction TB + N1["原始编号: 3\n(0b00011)"] --> SHIFT["<< 3 左移 3 位"] + SHIFT --> RESULT1["结果: 24\n(0b11000)"] + RESULT1 --> WIRE0["+ wire_type 0\n(同或操作 |)"] + WIRE0 --> FINAL1["最终 tag: 24\n十六进制: 0x18"] + FINAL1 --> BYTE1["✅ 仅占 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 ⚠️"] + subgraph FieldNum100["Field Number = 100"] + direction TB + N2["原始编号: 100\n(0b1100100)"] --> SHIFT2["<< 3 左移 3 位"] + SHIFT2 --> RESULT2["结果: 800\n(0b1100100000)"] + RESULT2 --> WIRE2["+ wire_type 0\n(同或操作 |)"] + WIRE2 --> FINAL2["最终 tag: 800\n十六进制: 0x320"] + FINAL2 --> BYTE2["⚠️ 占 2 bytes\n(0x320 > 0x7F)"] end - style F1 fill:#d4edda - style F2 fill:#fff3cd + style BYTE1 fill:#d4edda + style BYTE2 fill:#fff3cd ``` -> [!example] 公式 +> [!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, ...) +> - **field_number = 3**, wire_type = 0(varint) +> - `3 << 3 = 24 = 0x18`,0x18 | 0 = **0x18** → varint 编码只需 1 byte ✅ +> - **field_number = 100**, wire_type = 0 +> - `100 << 3 = 800 = 0x320`,0x320 | 0 = **0x320** → varint 需要 2 bytes(因为 0x320 > 0x7F)⚠️ -对于高频通信的消息体,节省 1 byte *per message* × 每秒百万调用 = 可观的带宽节省。这就是为什么建议 core fields 用 1~15。 +对于高频通信的消息体,节省 1 byte *per message* × 每秒百万调用 = 可观的带宽节省。这就是建议核心字段用 1~15 的根本原因。 ### 合理的 Field Numbering 策略 @@ -128,27 +141,56 @@ message User { int32 age = 4; // ---- 已废弃字段的编号保留 ---- - reserved "mobile"; // 之前叫 mobile 的字段已删除 + reserved "mobile"; // 之前叫 mobile 的字段已删除(同时锁定其原始编号) reserved 5, 6; // 编号 5 和 6 已释放,禁止复用 reserved 7 to 10; // 编号 7~10 连续保留 } ``` +### 两种 Reserved 语法 + +| 写法 | 效果 | 使用场景 | +|------|------|---------| +| `reserved ;` | 仅锁编号 | 你知道编号但忘了字段名 | +| `reserved "field_name";` | 同时锁定**原始编号 + 新编号** | 更推荐——如果以后有人改了字段名,这个记录仍然有效 | + +> [!important] Name reservation 的双保险 +> 如果你写 `reserved "mobile"`,Protobuf 编译器会查找 `mobile` 曾经占用过的所有编号并一并标记为 reserved。**即使后续有人把另一个字段改名为 `mobile`,编译也会报错**。所以优先使用 name reservation。 + +### 同一条语句中混合 reserve + +```protobuf +// 一行内同时保留名称和编号(语义清晰 ✅) +reserved "legacy_id", "temp_field", 20 to 25, 30; +``` + +### Reserved 的 proto2 vs proto3 差异 + +| 特性 | proto2 | proto3 | +|------|--------|--------| +| `reserved` 语法 | ❌ 不支持(proto2 中没有此关键字) | ✅ 支持 | +| 替代方案 | 手动文档规范 / linter 检查 | 编译期强制锁定 | + +> [!warning] Proto2 用户注意 +> proto2 **没有** `reserved` 关键字!如果你在做 proto2 → proto3 迁移,原来依赖文档规范的 reserved 行为在 proto3 中可以真正落到代码里了——这是一大收益。 + +> [!question] 思考题 +> 如果一个字段被删除了,但**没有**做 reserved,随后同事添加了 `string new_feature = 5;`,此时旧客户端读到 `new_feature` 的值时会发生什么?它会当成哪个字段的值? + ### 删除字段的正确姿势 三步走,确保平滑过渡: ```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["后续正式移除"] + S1["📝 步骤 1: reserved 占位"] --> S2["string old_field = 5;\n→\nreserved 5;"] + S2 --> S3["📢 步骤 2: 协调消费方迁移\n(发版窗口内切换代码)"] + S3 --> S4["⏳ 时间窗口: 3~6 个月\n等待旧客户端全部下线"] + S4 --> S5["✅ 步骤 3: 最终移除\n有人复用 → compile error"] - style S2 fill:#fff3cd - style S4 fill:#fff3cd - style Safe fill:#d4edda + style S3 fill:#fff3cd + style S4 fill:#e0e7ff + style S5 fill:#d4edda ``` > [!question] 为什么不能只删字段不 reserved? @@ -156,18 +198,61 @@ flowchart TD ## 兼容性矩阵(重点章节) -Protobuf 的设计确保了大部分 schema 变更不会破坏现有二进制协议: +Protobuf 的 wire format 设计确保了大部分 schema 变更不会破坏现有二进制协议。核心原则是:**以 field number 为寻址键,而非字段名或类型**。 -| 操作 | 向后兼容? | 向前兼容? | 说明 | -|------|-----------|-----------|------| -| 新增字段 | ✅ | ✅ | 老客户端忽略未知编号;新客户端用默认值 | -| 删除字段 | ✅ | ✅ | 老客户端读取已有数据;新客户端用默认值 | -| 修改字段类型 | ❌ | ❌ | 新旧对同一编号解读不同 | -| 修改字段编号 | ❌ | ❌ | 同编号对应不同语义 | -| 修改枚举值名称 | ✅ | ✅ | 枚举值名不影响 wire format(传输的是数值) | -| 新增枚举值 | ✅ | ⚠️ | 旧客户端收到未知枚举值回退为 0(首个值) | -| 删除枚举值 | ❌ | ⚠️ | 旧客户端收到未知枚举值回退为 0 | -| 单个 repeated 改为 non-repeated | ⚠️ | ❌ | 有数据的单元素列表可互转,多元素场景不兼容 | +### 一句话理解兼容性 + +```mermaid +flowchart LR + subgraph Read["老客户端读新版响应"] + direction TB + R1["未知 field_number\n→ 跳过其字节序列"] --> R2["✅ 向后兼容"] + end + + subgraph WriteNew["新版客户端读旧版响应"] + direction TB + W1["缺失的 field_number\n→ 取类型零值"] --> W2["✅ 向前兼容"] + end + + style R2 fill:#d4edda + style W2 fill:#d4edda +``` + +**为什么能做到?** Protobuf 的二进制编码中只有 `field_number + wire_type + value_bytes`,没有字段名字段类型。解析器按 number 找对应位置,遇到不认识的直接跳过——就像翻书时跳过不认识的页码。 + +### 完整兼容性表 + +| 操作 | 向后兼容? | 向前兼容? | Wire 层面原因 | +|------|-----------|-----------|--------------| +| **新增字段** | ✅ | ✅ | 老客户端跳过未知 number;新客户端读到零值 | +| **删除字段** | ✅ | ✅ | 老客户端忽略已无数据的旧 number;新客户端读不到也拿零值 | +| **修改字段类型** | ❌ | ❌ | 新旧端对同一 number 的解码规则不同(如 varint vs length-delimited) | +| **修改字段编号** | ❌ | ❌ | 同 number 对应了不同语义,两边都"以为"自己读对了 | +| **修改枚举值名称** | ✅ | ✅ | wire 上传输的是 enum 的**整数**,与名称无关 | +| **新增枚举值** | ✅ | ⚠️ | 旧端收到未知 enum value 回退为 0(proto3);proto2 会报错 | +| **删除枚举值** | ❌ | ⚠️ | 同上——旧端可能收到已被删除的 enum 数值 | +| **repeated → non-repeated** | ⚠️ | ❌ | 多元素列表无法映射到单值;proto3 默认 packed 导致编码格式不同 | +| **non-repeated → repeated** | ❌ | ❌ | 老代码无法处理多个同编号值的结构变化 | +| **添加 optional** | ⚠️ | ⚠️ | proto3 加了 `optional` 后改变了 wire encoding(从省略变为 presence bit) | + +### 类型变更的陷阱示例 + +```protobuf +// v1 - 线上运行正常 +message Config { + string timeout_ms = 1; // string 类型,wire: len-delimited +} + +// v2 - 有人图省事把类型改了 +message Config { + int32 timeout_ms = 1; // ← ❌ 同一编号改成了 varint! +} +``` + +**后果**:旧客户端把 `timeout_ms` 当字符串解析,但收到的实际是 varint 编码的整数——decode 时会报 "wrong wire type" 错误或者直接崩溃。 + +> [!tip] 如果必须改类型怎么办? +> 使用前面讲的「分步迁移方案」:保留原字段 + reserved,用新编号新类型定义新字段,等旧字段全部淘汰后再移除。 ### 实战:安全地扩展消息 @@ -203,78 +288,45 @@ message GetUserResponse { ```protobuf // v2 - ✅ 安全演进 message GetUserResponse { - string id = 1; - string name = 2; + string id = 1; + string name = 2; reserved 3; // 原 email 编号锁定,防止后人误用 - string avatar_url = 4; // 新字段分配新编号 - User_Role role = 5; + 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 +gantt + title Email 字段平滑迁移时间线 + dateFormat YYYY-MM + axisFormat %y-%m + section Phase 1 (v2) 双发版: 加字段不删字段 + avatar_url,role :2026-01, 6M + email :active, 2026-01, 6M - subgraph B["方案 B: 原地破坏 ❌ 高风险"] - B1["user/v1/user.proto\n直接改"] --> B2["已部署端受影响"] - end + section Phase 2 (v3) 标记 email 为 reserved + reserved 3 :2026-07, 1d + email (保留但标记废弃) :2026-07, 3M - style A fill:#d4edda - style B fill:#f8d7da - style A4 fill:#28a745,color:#fff - style B2 fill:#dc3545,color:#fff + section Phase 3 (v4) 正式移除 email + 旧客户端 < 1% :2026-10, 1d + 移除 email 声明 :2026-10, 1d ``` -| 维度 | 方案 A(v2 独立文件) | 方案 B(原地修改) | -|------|---------------------|-------------------| -| 风险等级 | 低 | **极高** | -| 线上影响 | Gateway 透明转换 | 所有端同时断裂 | -| 回滚成本 | 切回 v1 即可 | 几乎无法回滚 | -| 适用场景 | 所有已发布服务 | 仅限内部未发布 proto | +具体步骤: + +| 阶段 | Proto 变更 | 代码层配合 | 等待期 | +|------|-----------|-----------|--------| +| **v2** | 新增 `avatar_url=4`, `role=5`;`email` 保持不变 | 服务端同时返回 `email` + `avatar_url` | 观察 3~6 个月 | +| **v3** | 删除 `string email = 3;`(保留 `reserved 3;` 防止复用) | 客户端已切换读 `avatar_url`,不再需要 email | 确认旧客户端占比 < 1% | +| **v4** | `reserved 3;` 永久存在 | 清理 email 相关残留逻辑 | — | > [!tip] 灰度策略:双字段过渡法 > 如果需要在同一消息中过渡一个新字段到旧字段,分三阶段进行: @@ -293,10 +345,41 @@ flowchart LR ## 最佳实践 - **为每个 microservice 预留独立 namespace**:`package service_name.version`。 -- **不要重复使用 field numbers**:即使在同一个文件中删除了字段也要 reserved。 -- **核心高频字段编号保持在 1~15**:节省 wire 编码开销。 -- **重大变更走 v2 而不是改现有文件**:降低线上风险。 -- **在 CI 中加入 proto linter**(如 buf lint):自动化检查编号冲突和命名规范。 +- **不要重复使用 field numbers**:即使在同一个文件中删除了字段也要 reserved。优先考虑 name reservation(`reserved "field_name"`)而非仅数字——它能在改名后仍然生效。 +- **核心高频字段编号保持在 1~15**:节省 wire 编码开销,一个字节差 × 百万 QPS 就是巨大收益。 +- **重大变更走 v2 而不是改现有文件**:降低线上风险,旧客户端可以继续用 v1 直到自然淘汰。 +- **在 CI 中加入 proto linter**(如 `buf lint`):自动化检查编号冲突和命名规范。 +- **proto3 中避免随意加 `optional`**:加上 `optional` 会改变 wire encoding 行为(从"省略零值"变为"有 presence bit"),可能破坏向前/向后兼容性。需要使用可选语义时优先选用 [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解#Wrapper Types 包装类型|wrapper types]]。 +- **oneof 字段的编号分配要注意边界**:oneof 成员和其他普通字段**共享同一编号空间**,不要因为它们在一个 oneof block 里就单独编号。 + +```protobuf +// ❌ 错误:oneof 成员编号与普通字段冲突 +message Query { + string id = 1; // 普通字段占用了 1 + oneof filter { + int32 age = 1; // ← 编译报错:number 1 already used by id + string tag = 2; // ← 编译报错:number 2 already used by email (if exists) + } +} + +// ✅ 正确:全局唯一编号规划 +message Query { + string id = 1; + int32 age = 10; // 普通字段留 1~9, oneof 从 10 开始 + string tag = 11; +} +``` + +## 本节小结 + +| 主题 | 一句话记住 | +|------|-----------| +| Field Number | 它是二进制协议的唯一标识,不能改名、不能复用 | +| Wire Encoding | 编号越小越省字节,核心字段放 1~15 | +| Reserved | 删字段必 reserved,优先用 name reservation 做双保险 | +| 向后兼容 | 未知编号 → 跳过;新增字段 → 老端无感 | +| 向前兼容 | 缺失字段 → 零值;删除字段 → 新端读零值 | +| Schema 演进 | 分步迁移 + double-write + 灰度下线,别暴力改造 | ## 关联笔记 diff --git a/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md b/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md new file mode 100644 index 0000000..43ede62 --- /dev/null +++ b/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md @@ -0,0 +1,146 @@ +--- +tags: [gRPC, Protobuf, WKT, FieldMask, PartialUpdate] +create time: 2026-05-12 10:30 +--- + +# FieldMask 实战 + +## 概述 + +FieldMask 是 protobuf WKT 中最实用的工具类类型之一,用于实现 **精准的部分更新(Partial Update)**。它让你只需声明「要改哪些字段」和「改成什么」,而不必全量覆盖整条记录。 + +> [!question] 为什么需要 FieldMask? +> REST API 中有 PUT(全量替换)和 PATCH(部分更新)两种语义。PUT 的问题是:客户端必须回传完整对象,服务端才能知道改了哪里——如果遗漏一个字段就会被静默覆盖。如何用 proto 优雅地表达 PATCH 语义?答案就是 FieldMask。 + +## Proto 定义 + +```protobuf +import "google/protobuf/field_mask.proto"; + +message UserPatchRequest { + google.protobuf.FieldMask update_mask = 1; // 告诉服务端要改哪些字段 + User user = 2; // 只填新值 +} +``` + +两个核心约定: + +| 字段 | 含义 | JSON 表现形式 | +|------|------|--------------| +| `update_mask` | 逗号分隔的字段名列表 | `"display_name,email"`(注意是字符串不是数组) | +| `user` | 只提供被修改字段的值 | `{ "display_name": "新名字" }` | + +## 服务端实现 + +Go 中最关键的一行代码: + +```go +// ✅ 一行搞定增量更新 +proto.ApplyFieldMask(&existingUser, req.GetUser()) +``` + +这行代码等价于手写逐个赋值,但 **只有 mask 中指定的字段会被改写**,其余保持原值: + +```go +// ApplyFieldMask 效果等同于此: +existingUser.DisplayName = req.User.DisplayName // ✅ 改了 +existingUser.Email = req.User.Email // ✅ 改了 +existingUser.Phone = existingUser.Phone // ❌ 不变(mask 没提) +existingUser.Avatar = existingUser.Avatar // ❌ 不变 +``` + +### 实际服务中的完整用法 + +```go +func (s *UserService) PatchUser(ctx context.Context, req *pb.UserPatchRequest) (*pb.User, error) { + // 1. 从数据库查出旧数据 + existingUser := s.loadFromDB(req.GetUserId()) + + // 2. 用 FieldMask 做增量合并 + if err := proto.ApplyFieldMask(existingUser, req.GetUser()); err != nil { + return nil, fmt.Errorf("invalid field mask: %w", err) + } + + // 3. 保存回数据库 + s.saveToDB(existingUser) + return existingUser, nil +} +``` + +## 嵌套字段路径 + +FieldMask 支持用点号访问嵌套字段,例如: + +```json +{ + "updateMask": "profile.display_name,contact.email", + "user": { + "profile": { "display_name": "新昵称" }, + "contact": { "email": "new@example.com" } + } +} +``` + +这在用户偏好设置、配置管理等有深层结构的场景中非常有用。 + +> [!note] 点号路径要求目标字段也必须存在 +> `ApplyFieldMask` 在遇到不存在的嵌套路径时会返回错误。调用方需确保结构完整,或使用 null-safe 路径语法(需自行处理)。 + +## 客户端最佳实践 + +### Go 构造示例 + +```go +req := &pb.UserPatchRequest{ + UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"display_name", "email"}}, + User: &pb.User{ + DisplayName: "新名字", + Email: "new@example.com", + }, +} +``` + +### TypeScript 构造示例 + +```typescript +const req = { + updateMask: "display_name,email", + user: { display_name: "新名字" }, +}; +``` + +> [!tip] 前后端命名约定不同 +> - Proto JSON 映射中,`update_mask`(snake_case)会自动转为 `updateMask`(camelCase) +> - 前端无需关心 proto 中的原始字段名,直接按 API 文档约定的驼峰名传即可 + +## 常见陷阱 + +| 坑 | 说明 | 规避方法 | +|----|------|---------| +| 空 mask | `update_mask` 为空数组时不会报错,但也不会更新任何字段 | 在服务层校验 `len(mask.Paths) > 0` | +| 大小写敏感 | 字段名严格匹配 camelCase(JSON 映射后的格式),不支持 snake_case | 前端统一使用 proto 定义的 CamelCase | +| 字段不存在 | mask 中提到不存在的字段会抛出 `InvalidArgument` 错误 | 服务端捕获并返回清晰错误信息 | +| Oneof 冲突 | 对 oneof 组中的一个字段应用 mask 时,其他 oneof 字段会被清除 | 业务逻辑中提前规避互斥冲突 | + +## 与其他方案的对比 + +```mermaid +graph LR + A["PATCH 需求"] --> B{"选哪种方案?"} + B -->|"笨办法"| C["PUT + 全量回传"] + B -->|"手动解析"| D["if-check 逐个赋值"] + B -->|✅推荐| E["FieldMask + ApplyFieldMask"] + + C -.->|"带宽浪费\n误覆盖风险"| F["⚠️ 不推荐"] + D -.->|"维护成本高\n字段增多就爆炸"| F + E -.->|"一行代码\n自动路径匹配\n嵌套支持"| G["✅ 安全且简洁"] +``` + +- **PUT 全量回传**:简单粗暴,但浪费带宽且容易踩坑(漏传字段被静默覆盖)。 +- **手写 if-check**:每个字段加一行判断,字段一多就成了维护噩梦。 +- **FieldMask**:一行代码搞定,自动处理字段匹配和嵌套路径。 + +## 关联笔记 + +- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解]] — WKT 类型的概览入口,FieldMask 是其中的一种 +- [[hhs/gRPC/2. gRPC 实战指南/01-RPC 设计与 RESTful 对应]] — RPC 与 RESTful API 的语义对照,PATCH 场景在此展开