11 KiB
tags, create time
| tags | 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 — 全量替换
// PUT /users/:id → rpc UpdateUser
message UpdateUserRequest {
string id = 1; // 必须传 ID
string display_name = 2; // 所有字段都必须填(哪怕没改)
string email = 3;
string phone = 4;
string avatar = 5;
}
语义:"这就是新的完整用户数据,把所有字段都覆盖掉。"
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 — 部分更新
// 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 实战]]
语义:"这些字段改成下面的值,其余保持原样。"
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 的安全保障来自两个层面
- 业务层:前端不需要回传完整对象,减少遗漏风险
- proto 层:
ApplyFieldMask只改写update_mask白名单中的字段,其余字段自动保持原值对比 PUT 要求客户端"每次都得传完整数据",PATCH 大幅降低了调用方的心智负担。
REST → gRPC 的语义转换图
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 路由注解:
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 实战流程
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 新旧值: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 不仅支持扁平字段,还支持点号分隔的嵌套路径:
{
"id": "123",
"update_mask": "profile.display_name,contact.primary_email",
"fields": {
"profile.display_name": "新昵称",
"contact.primary_email": "new@example.com"
}
}
这在使用场景中有深层结构的业务实体时非常实用:
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错误。如果不对这个错误做处理,客户端会收到一个模糊的内部错误,很难定位问题。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 选型决策树
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 知识库全景索引