301 lines
11 KiB
Markdown
301 lines
11 KiB
Markdown
---
|
||
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 知识库全景索引
|