This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md
T
2026-05-12 15:26:17 +08:00

147 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 场景在此展开