--- tags: [gRPC, RESTful, RPC, PATCH, PUT, FieldMask] create time: 2026-05-13 10:30 --- # RPC 设计与 RESTful 对应 ## 概述 gRPC 的 Unary RPC 模式天然可以映射到 REST 的 CRUD 操作。理解这种对应关系,是设计对外 API、搭建 API Gateway、以及处理 Partial Update(PATCH)场景的前提。 > [!question] gRPC 本身没有 REST 概念——为什么要把二者对应起来? > gRPC 是一种二进制 RPC 框架,不区分 GET/POST/PUT/PATCH。但当你需要把 gRPC 服务暴露为 REST API(通过 gRPC-Gateway、Envoy 等),或者与已有 REST 生态对接时,就必须明确每个 RPC 方法对应哪种 HTTP 语义。**尤其是 PATCH 的部分更新**——这几乎是唯一让 REST 和 gRPC 产生歧义的地方。 ## 基础 CRUD 映射 一个标准的 `UserService` 在 gRPC 和 REST 之间的对应关系: | REST 端点 | HTTP 方法 | gRPC RPC 方法 | 语义 | |-----------|----------|---------------|------| | `GET /users/:id` | GET | `rpc GetUser(GetUserRequest) returns (GetUserResponse)` | 读取单条 | | `GET /users?offset=&limit=` | GET | `rpc ListUsers(ListUsersRequest) returns (stream User)` | 列表查询 | | `POST /users` | POST | `rpc CreateUser(CreateUserRequest) returns (User)` | 创建 | | `PUT /users/:id` | PUT | `rpc UpdateUser(UpdateUserRequest) returns (User)` | **全量替换** | | `PATCH /users/:id` | PATCH | `rpc PatchUser(UserPatch) returns (User)` | **部分更新** | | `DELETE /users/:id` | DELETE | `rpc DeleteUser(DeleteUserRequest) returns (Empty)` | 删除 | > [!tip] 为什么 ListUsers 用 Stream 而不是返回完整列表? > HTTP GET `/users` 返回 JSON 数组看起来很简单,但在 gRPC 中如果一次性 load 百万条记录会阻塞整个连接。Server Streaming 让客户端可以按需消费——第一页到了就可以开始渲染,不必等全部查完。这也是为什么 gRPC 中"列表查询"默认用流式而非一次性返回。 ## PUT vs PATCH:核心差异 这是 REST 和 gRPC 对接中最关键的分水岭。 ### PUT — 全量替换 ```protobuf // PUT /users/:id → rpc UpdateUser message UpdateUserRequest { string id = 1; // 必须传 ID string display_name = 2; // 所有字段都必须填(哪怕没改) string email = 3; string phone = 4; string avatar = 5; } ``` **语义:**"这就是新的完整用户数据,把所有字段都覆盖掉。" ```go func (s *UserService) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.User, error) { user := s.loadFromDB(req.Id) user.DisplayName = req.DisplayName // 全覆盖 user.Email = req.Email user.Phone = req.Phone user.Avatar = req.Avatar s.saveToDB(user) return user, nil } ``` **风险:**如果客户端漏传了一个字段(比如忘了传 `avatar`),服务端会把该字段**静默覆盖为空值**。 ### PATCH — 部分更新 ```protobuf // PATCH /users/:id → rpc PatchUser message UserPatch { string id = 1; google.protobuf.FieldMask update_mask = 2; // 白名单:告诉服务端只改哪些字段 map fields = 3; // 只填需要变更的字段值 } // 深层结构使用点号路径: "profile.display_name,bio.bio" // 完整示例见 [[../../1. Protobuf 基础篇/04-FieldMask 实战]] ``` **语义:**"这些字段改成下面的值,其余保持原样。" ```go func (s *UserService) PatchUser(ctx context.Context, req *pb.UserPatch) (*pb.User, error) { user := s.loadFromDB(req.Id) // 查旧数据 if err := proto.ApplyFieldMask(user, req.Fields); err != nil { return nil, fmt.Errorf("invalid mask: %w", err) } // 只改 mask 白名单里的字段 s.saveToDB(user) return user, nil } ``` > [!warning] PATCH 的安全保障来自两个层面 > > 1. **业务层**:前端不需要回传完整对象,减少遗漏风险 > 2. **proto 层**:`ApplyFieldMask` 只改写 `update_mask` 白名单中的字段,其余字段自动保持原值 > > 对比 PUT 要求客户端"每次都得传完整数据",PATCH 大幅降低了调用方的心智负担。 ## REST → gRPC 的语义转换图 ```mermaid flowchart TD A["REST 请求"] --> B{"HTTP 方法?"} B -->|"GET"| C["Unary: GetUser(id)"] B -->|"POST"| D["Unary: CreateUser(data)"] B -->|"PUT"| E["Unary: UpdateUser(full data)"] B -->|"PATCH"| F["Unary: PatchUser(mask + partial fields)"] B -->|"DELETE"| G["Unary: DeleteUser(id)"] B -->|"GET + pagination"| H["ServerStream: ListUsers()"] C -.->|"简单读取"| C1["✅ 直接映射"] D -.->|"无 ID 自动生成"| D1["✅ 直接映射"] E -.->|"全量覆盖\n缺字段 = 静默置空"| E1["⚠️ 需校验完整性"] F -.->|"增量合并\n只需声明变化"| F1["✅ 推荐"] G -.->|"物理删除或逻辑删除"| G1["⚠️ 建议做逻辑删除"] style F fill:#00D866,color:#fff style E fill:#FF9F43,color:#000 ``` ## API Gateway 路由配置示例 当使用 gRPC-Gateway 将 gRPC 服务转为 REST 时,需要在 `.proto` 文件中声明 HTTP 路由注解: ```protobuf import "google/api/annotations.proto"; service UserService { rpc GetUser(GetUserRequest) returns (GetUserResponse) { option (google.api.http) = { get: "/v1/users/{id}" }; } rpc UpdateUser(UpdateUserRequest) returns (User) { option (google.api.http) = { put: "/v1/users/{id}" body: "*" // PUT 体就是整个 message }; } rpc PatchUser(UserPatch) returns (User) { option (google.api.http) = { patch: "/v1/users/{id}" body: "fields" // PATCH 体只是 fields map,mask 来自同名 HTTP header }; } } ``` **关键区别:** | 方法 | `body` 字段的值 | 含义 | |------|----------------|------| | `PUT` | `"*"` | 请求体是整个 `UpdateUserRequest`,所有字段从 HTTP body 填入 | | `PATCH` | `"fields"` | 只有 `fields` map 从 body 填入(`{"display_name":"新名"}`),`update_mask` 来自同名 HTTP header | 这解释了为什么 PATCH 比 PUT 多一层复杂度——body 里只有一部分数据,另一半数据(mask)需要通过单独的路径提取。 > [!note] mask 的传递方式 > > gRPC-Gateway 会自动将请求中名为 `update_mask`(或驼峰 `updateMask`)的字段映射为同名 HTTP header: > > - **JSON 方式**:`{ "updateMask": "display_name,email", "fields": { "display_name": "新名" } }` > - **Header 方式**:`Patch-Update-Mask: display_name,email` + Body: `{ "display_name": "新名" }` > > 两种方式的底层效果完全一致——gRPC-Gateway 负责把它们组装成完整的 protobuf message。 ## PATCH 实战流程 ```mermaid sequenceDiagram participant FE as Frontend participant GW as API Gateway participant Svc as gRPC Service Note over FE,Svc: PUT 场景:表单包含所有字段 FE->>GW: PUT /v1/users/123 { "display_name":"A", "email":"a@x.com", ...全量 } GW->>Svc: UpdateUserRequest{ display_name:A, email:a@x.com, ... } Svc-->>GW: User{ ... } GW-->>FE: 200 OK Note over FE,Svc: PATCH 场景:用户只改了昵称 FE->>GW: PATCH /v1/users/123 { "fields":{ "display_name":"B" }, "updateMask":"display_name" } GW->>Svc: UserPatch{ update_mask:[display_name], fields:{ display_name:B } } Svc->>Svc: ApplyFieldMask(existingUser, { display_name:B }) Note right of Svc: 只改 display_name,email/phone/avatar 不变 Svc-->>GW: User{ display_name:B, ... } GW-->>FE: 200 OK ``` > [!tip] 前端如何自动生成 updateMask? > 最简单的做法是维护一份表单字段清单,在 `onSubmit` 时 diff 新旧值: > > ```typescript > const changedFields = Object.keys(changes).filter(k => prev[k] !== next[k]) > const updateMask = changedFields.join(',') > // => "display_name,email" > ``` > > 更稳健的做法是让 UI 组件在 `onChange` 时主动打脏标记——用户没碰过的字段永远不在 mask 里。 ## 何时选 PUT,何时选 PATCH? | 判断维度 | PUT | PATCH | |---------|-----|-------| | 表单是否包含全部可编辑字段? | ✅ 是,一次性填完 | ❌ 否,分步填写或多页面 | | 是否需要精确知道改了哪几个字段? | ❌ 不必要 | ✅ 需要(审计日志、乐观锁等) | | 客户端是否可靠地能拿到完整旧数据? | ✅ 能 | ❌ 不能或不想 | | 对"漏传即静默覆盖"的风险容忍度 | 高 | 低 | > [!answer]+ 经验法则 > **内部微服务间优先用 PUT**——你完全可控,不存在前端泄露问题,全量来回反而简化了对齐成本。**对外 API 推荐同时支持 PUT 和 PATCH**——不同客户端有不同需求,强制统一一种方案会逼走一部分用户。 ### PATCH 进阶:嵌套字段与错误处理 FieldMask 不仅支持扁平字段,还支持点号分隔的嵌套路径: ```json { "id": "123", "update_mask": "profile.display_name,contact.primary_email", "fields": { "profile.display_name": "新昵称", "contact.primary_email": "new@example.com" } } ``` 这在使用场景中有深层结构的业务实体时非常实用: ```protobuf message User { string display_name = 1; string email = 2; UserProfile profile = 4; // 嵌套 message ContactInfo contact = 5; } message UserProfile { string bio = 1; string avatar_url = 2; } message ContactInfo { string primary_email = 1; repeated string phones = 2; } ``` | 字段类型 | update_mask 写法 | 说明 | |----------|------------------|------| | 顶层字段 | `"display_name"` | 直接匹配 | | 嵌套字段 | `"profile.bio"` | 逐段解析,每段都需存在 | | 重复字段 | `"contact.phones"` | 更新整个 repeated 字段 | > [!warning] 服务端一定要做错误处理 > > `ApplyFieldMask` 在遇到不存在的字段路径时会返回 `InvalidArgument` 错误。如果不对这个错误做处理,客户端会收到一个模糊的内部错误,很难定位问题。 > > ```go > if err := proto.ApplyFieldMask(user, req.Fields); err != nil { > return nil, status.Errorf(codes.InvalidArgument, > "invalid field mask: %s (valid fields: display_name,email,profile.bio,...)", err) > } > ``` > > **最佳实践**:在服务启动时用反射遍历 target message 的所有字段,预生成合法字段列表——这样报错时可以给出精确提示。 ### PUT vs PATCH 选型决策树 ```mermaid flowchart TD A["需要更新用户数据"] --> B{"完整表单提交
所有字段都有值?"} B -->|"✅ 是"| C{"是否需要
知道改了哪些字段?"} C -->|"❌ 不需要"| D["🟢 选 PUT
简单可靠"] C -->|"✅ 需要"| E["🔵 选 PATCH\n带审计日志"] B -->|"❌ 否
分步填/多页面/增量改"| F["🟢 必须选 PATCH"] D -.->|"内部服务间首选"| D1["请求体 = 完整对象\n无需额外参数"] E -.->|"合规/金融场景"| E1["每个变更有明确记录\n便于审计追踪"] F -.->|"对外公开 API"| F1["前端 diff 自动生成 mask"] style D fill:#6BCB77,color:#fff style F fill:#6BCB77,color:#fff style E fill:#4D96FF,color:#fff style D1 fill:#E8E8E8 style E1 fill:#E8E8E8 style F1 fill:#E8E8E8 ``` ## 关联笔记 - [[../../1. Protobuf 基础篇/04-FieldMask 实战]] — PATCH 场景的 FieldMask 详细实现 - [[../../README]] — gRPC 知识库全景索引