272 lines
11 KiB
Markdown
272 lines
11 KiB
Markdown
|
|
---
|
|||
|
|
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 User {
|
|||
|
|
string display_name = 1;
|
|||
|
|
string email = 2;
|
|||
|
|
string phone = 3;
|
|||
|
|
string avatar = 4;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
message UserPatchRequest {
|
|||
|
|
google.protobuf.FieldMask update_mask = 1;
|
|||
|
|
User user = 2;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**关键:`update_mask` 中的字段名来自 `User` 的定义。**
|
|||
|
|
它本身不包含字段信息,只引用被包裹的 `user` message 中已存在的字段名。
|
|||
|
|
例如 `"display_name,email"` 意味着用 `req.user.display_name` 和 `req.user.email` 的值去覆盖现有记录。
|
|||
|
|
如果 mask 中提到的字段在 `User` 里不存在,`ApplyFieldMask` 会返回 `InvalidArgument` 错误。
|
|||
|
|
|
|||
|
|
> [!warning] Proto 层无约束 —— 全靠服务端约定
|
|||
|
|
> 从 proto 定义来看,`google.protobuf.FieldMask` 的内部结构只是:
|
|||
|
|
>
|
|||
|
|
> ```protobuf
|
|||
|
|
> message FieldMask {
|
|||
|
|
> repeated string paths = 1;
|
|||
|
|
> }
|
|||
|
|
> ```
|
|||
|
|
>
|
|||
|
|
> 它是一个**通用的字符串列表**,与任何具体的 target message 都没有绑定关系。
|
|||
|
|
> 这解释了为什么 `update_mask` 字段上不会标注「只能填 display_name 或 email」。
|
|||
|
|
>
|
|||
|
|
> **字段名的关联是在服务端代码中建立的:**
|
|||
|
|
>
|
|||
|
|
> ```go
|
|||
|
|
> proto.ApplyFieldMask(&existingUser, req.GetUser())
|
|||
|
|
> // ↑ ↑
|
|||
|
|
> // target 决定哪些字段可被更新
|
|||
|
|
> ```
|
|||
|
|
>
|
|||
|
|
> - `target`(第一个参数)是 `User` → 所以 mask 里的值只能是 `display_name`、`email`、`phone`、`avatar`
|
|||
|
|
> - `source`(第二个参数)提供新值 → 只有被 mask 指定的字段才会取 `source` 中的值
|
|||
|
|
> - 如果客户端传了 mask 中没有的字段(如 `wechat_id`),且该字段在 `User` 中不存在,`ApplyFieldMask` 会报错
|
|||
|
|
|
|||
|
|
两个核心约定:
|
|||
|
|
|
|||
|
|
| 字段 | 含义 | 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 路径语法(需自行处理)。
|
|||
|
|
|
|||
|
|
## 底层机制:字符串到字段的映射过程
|
|||
|
|
|
|||
|
|
`ApplyFieldMask` 的核心链路可以分解为三步:
|
|||
|
|
|
|||
|
|
### 第 1 步:把字符串拆成字段路径
|
|||
|
|
|
|||
|
|
`update_mask` 的值 `"display_name,email"` 在传输时已经被 protobuf JSON 反序列化器转成了 Go 的 `[]string{"display_name", "email"}`。protobuf 的 FieldMask 类型定义只是:
|
|||
|
|
|
|||
|
|
```protobuf
|
|||
|
|
message FieldMask {
|
|||
|
|
repeated string paths = 1;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
所以到了 `ApplyFieldMask` 调用点,它就是一个普通的字符串切片——**里面只有名字,没有任何元数据**。
|
|||
|
|
|
|||
|
|
### 第 2 步:通过反射查找字段描述符
|
|||
|
|
|
|||
|
|
Go protobuf 的反射 API 提供了 `protoreflect.Fields`,内部用一个 map 存储所有字段:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
fd := md.Fields().ByName(protoreflect.Name(field))
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
以 `field = "display_name"` 为例:
|
|||
|
|
|
|||
|
|
| 查找方式 | 源码 | 匹配结果 |
|
|||
|
|
|---------|------|---------|
|
|||
|
|
| `ByName` | `md.Fields().ByName("display_name")` | ✅ 找到 `User.display_name` |
|
|||
|
|
| `ByJSONName` | `md.Fields().ByJSONName("displayName")` | ✅ 找 JSON 别名 |
|
|||
|
|
| `ByNumber` | `md.Fields().ByNumber(1)` | ✅ 按字段编号 |
|
|||
|
|
|
|||
|
|
`ApplyFieldMask` 使用 `ByName`——**匹配的是 proto 文件中声明的原始字段名**(即 `display_name`),不是 JSON camelCase 名。如果你的 proto 写成 `displayName`(驼峰),那 mask 里就必须传 `"displayName"`。
|
|||
|
|
|
|||
|
|
### 第 3 步:反射赋值
|
|||
|
|
|
|||
|
|
找到 `FieldDescriptor` 后,protobuf 用反射完成值传递:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// ApplyFieldMask 内部的等价逻辑(伪代码):
|
|||
|
|
fd := dst.Descriptor().Fields().ByName("display_name")
|
|||
|
|
dst.Set(fd, src.Get(fd))
|
|||
|
|
// ↑ 等价于手写:existingUser.DisplayName = req.User.DisplayName
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
如果是嵌套路径 `"profile.display_name"`,则先解析出 `profile` 的 descriptor,再通过 `fd.Message()` 进入嵌套 message,对第二段 `"display_name"` 重复上述流程。
|
|||
|
|
|
|||
|
|
> [!note] 运行时校验
|
|||
|
|
> 由于整个过程是「字符串 → 反射查找 → 赋值」,如果 mask 中包含了一个根本不在 User message 里的字段名(如 `"wechat_id"`),`ByName` 会返回 nil,此时 `ApplyFieldMask` 立即返回错误。**proto 编译器不会检查这种跨消息引用的合法性**,校验全部依赖运行时的反射查找。
|
|||
|
|
|
|||
|
|
### 谁来决定 `update_mask` 的值?
|
|||
|
|
|
|||
|
|
`"display_name,email"` 这个值 **不是由任何代码生成的**——它是调用方(client)自己决定的。
|
|||
|
|
|
|||
|
|
整个链路中没有任何机制在编译期或运行期约束 "只能选这几个字段名":
|
|||
|
|
|
|||
|
|
| 环节 | 做什么 | 有没有强制约束? |
|
|||
|
|
|------|--------|-----------------|
|
|||
|
|
| **客户端** | UI 表单检测到用户改了名字和邮箱 → 拼出 `update_mask = "display_name,email"` | ❌ 纯业务逻辑 |
|
|||
|
|
| **Proto 定义** | 声明 `FieldMask update_mask = 1` | ❌ 只是一个开放字符串列表 |
|
|||
|
|
| **网络传输** | 把字符串发给服务端 | ❌ 不做任何校验 |
|
|||
|
|
| **服务端** | 收到值后,用反射逐个查找是否在 `User` 中存在 | ✅ 只拒绝非法字段名 |
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant Client as 前端 / CLI
|
|||
|
|
participant Network as gRPC Client
|
|||
|
|
participant Server as Server
|
|||
|
|
|
|||
|
|
Client->>Network: 用户改了 display_name 和 email
|
|||
|
|
Note over Client,Network: ① 开发者自己决定要改哪些字段
|
|||
|
|
activate Network
|
|||
|
|
Network->>Server: serialize({ update_mask: "display_name,email", user: {...} })
|
|||
|
|
deactivate Network
|
|||
|
|
activate Server
|
|||
|
|
Server->>Server: ApplyFieldMask(target, source)
|
|||
|
|
Note right of Server: 反射校验:display_name? yes<br/>email? yes → 赋值 ✓
|
|||
|
|
Server-->>Client: { ... updated User ... }
|
|||
|
|
deactivate Server
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
这意味著:
|
|||
|
|
|
|||
|
|
1. **协议本身不限制可选值**——客户端理论上可以传任何字符串,包括 `"hello_world"`、`"random_value"`
|
|||
|
|
2. **只有服务端在做兜底**——你传了个 `"wechat_id"` 会报错说这个字段不存在
|
|||
|
|
3. **客户端的合理做法**是在 UI 层面维护一份可用的字段白名单,根据哪些字段被修改来自动生成 mask
|
|||
|
|
|
|||
|
|
所以 `"display_name,email"` 的本质是:**开发人员在客户端知道用户改了这两个字段,于是手动把它们填进了 mask 里**。没有框架在背后自动生成它。
|
|||
|
|
|
|||
|
|
## 客户端最佳实践
|
|||
|
|
|
|||
|
|
### 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` |
|
|||
|
|
| 大小写敏感 | `ByName` 匹配的是 proto 文件中声明的原始字段名。proto 写成 `display_name` 则 mask 必须传 `"display_name"`;写成 `displayName` 则传 `"displayName"` | 保持 proto 定义与 mask 中的命名一致 |
|
|||
|
|
| 字段不存在 | 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 是其中的一种
|
|||
|
|
- [[../../2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应]] — RPC 与 RESTful API 的语义对照,PATCH 场景在此展开
|