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/2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应.md
T
2026-05-13 11:17:21 +08:00

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