vault backup: 2026-05-13 11:17:21

This commit is contained in:
hhs
2026-05-13 11:17:21 +08:00
parent e8ee0c675c
commit 93daec5d10
6 changed files with 597 additions and 55 deletions
@@ -0,0 +1,300 @@
---
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<string, string> 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{"完整表单提交<br/>所有字段都有值?"}
B -->|"✅ 是"| C{"是否需要<br/>知道改了哪些字段?"}
C -->|"❌ 不需要"| D["🟢 选 PUT<br/>简单可靠"]
C -->|"✅ 需要"| E["🔵 选 PATCH\n带审计日志"]
B -->|"❌ 否<br/>分步填/多页面/增量改"| 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 知识库全景索引