vault backup: 2026-05-12 15:26:17
This commit is contained in:
@@ -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 场景在此展开
|
||||
Reference in New Issue
Block a user