vault backup: 2026-05-13 11:17:21
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
---
|
||||
tags: [gRPC, RPC, Streaming, Go, Microservice]
|
||||
tags: [gRPC, RPC, Streaming, Go, Microservice, API Design]
|
||||
create time: 2026-05-11 16:40
|
||||
update time: 2026-05-13 00:00
|
||||
---
|
||||
|
||||
# RPC 调用模式总览
|
||||
@@ -16,17 +17,18 @@ gRPC 提供四种 RPC 调用模式,从最简单的请求-响应到完全的双
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Unary] -->|"一问一答"| B[最简单]
|
||||
C[Server Stream] -->|"一问多答"| D[广播式]
|
||||
E[Client Stream] -->|"多问一答"| F[收集式]
|
||||
G[BiDi Stream] -->|"多问多答"| H[全双工]
|
||||
U["Unary\n一问一答"] --> S1["简单 · 阻塞 · 一次往返"]
|
||||
SS["Server Stream\n一问多答"] --> S2["广播式 · 服务端推送"]
|
||||
CS["Client Stream\n多问一答"] --> S3["收集式 · 分批上传"]
|
||||
BD["BiDi Stream\n多问多答"] --> S4["全双工 · 独立收发"]
|
||||
|
||||
style A fill:#00B6BC,color:#fff
|
||||
style G fill:#EE5A24,color:#fff
|
||||
style U fill:#00B6BC,color:#fff
|
||||
style SS fill:#00D866,color:#fff
|
||||
style CS fill:#4FC3F7,color:#fff
|
||||
style BD fill:#EE5A24,color:#fff
|
||||
```
|
||||
|
||||
> [!example] 各模式数据流向速览
|
||||
> 左列为 Client,右列为 Server,箭头方向表示数据流动方向。
|
||||
## RPC 数据流示意图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -34,13 +36,13 @@ sequenceDiagram
|
||||
participant S as Server
|
||||
|
||||
rect rgba(0, 182, 188, 0.1)
|
||||
Note over C,S: Unary — 阻塞式一次往返
|
||||
Note over C,S: Unary — 一次请求,一次响应
|
||||
C->>S: request
|
||||
S-->>C: response
|
||||
end
|
||||
|
||||
rect rgba(0, 216, 102, 0.1)
|
||||
Note over C,S: Server Stream — 请求后连续响应
|
||||
Note over C,S: Server Stream — 一次请求,多次响应
|
||||
C->>S: request
|
||||
S-->>C: response 1
|
||||
S-->>C: response 2
|
||||
@@ -48,7 +50,7 @@ sequenceDiagram
|
||||
end
|
||||
|
||||
rect rgba(79, 195, 247, 0.1)
|
||||
Note over C,S: Client Stream — 连续发送后一次性响应
|
||||
Note over C,S: Client Stream — 多次请求,一次响应
|
||||
C->>S: chunk 1
|
||||
C->>S: chunk 2
|
||||
C->>S: ... n (done)
|
||||
@@ -142,27 +144,37 @@ func (s *Server) Subscribe(req *pb.SubscribeRequest, stream pb.UserService_Subsc
|
||||
> 每个文件只需 import 实际用到的即可,不必照抄。
|
||||
|
||||
```go
|
||||
// Client side - 遍历接收事件(生产环境建议用 recover + defer 做错误恢复)
|
||||
func main() {
|
||||
client := pb.NewUserServiceClient(conn)
|
||||
stream, err := client.Subscribe(context.Background(), &pb.SubscribeRequest{Topic: "orders"})
|
||||
// Client side - 遍历接收事件(生产环境建议加 context timeout 和 recover)
|
||||
func subscribeEvents(client pb.UserServiceClient, topic string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.Subscribe(ctx, &pb.SubscribeRequest{Topic: topic})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err // dial failed — 上层 main 可以做 Fatal
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
event, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break // server finished sending
|
||||
}
|
||||
if err != nil {
|
||||
return err // ⚠️ 这里用 return 而非 log.Fatal,服务端的 handler 不能 kill 进程
|
||||
log.Printf("stream error: %v", err) // ⚠️ 这里用 return/log,不能用 Fatal
|
||||
return err
|
||||
}
|
||||
fmt.Printf("received: %s\n", event.Data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] 注意:Client 仍然可以通过 context cancel 随时中断流。这是调试流式问题时最容易忽略的一点——不是 Server 主动关了连接,而是 Client 放弃了。
|
||||
> [!tip] Context Cancel 与 Recv 的关系
|
||||
> Client 仍可通过 cancel 随时中断流——服务端 `Recv()` 将返回一个 context canceled 错误。这也是调试流式问题时最容易忽略的一点:**不是 Server 主动关了连接,而是 Client 放弃了**。
|
||||
|
||||
## Client Streaming RPC(客户端流)
|
||||
|
||||
@@ -178,19 +190,31 @@ rpc Upload(stream FileChunk) returns (UploadResult);
|
||||
```
|
||||
|
||||
```go
|
||||
// Client side - 流式发送数据分片
|
||||
// Client side - 流式发送数据分片(注意 defer CleanupSend 做资源清理)
|
||||
func uploadFile(client pb.FileServiceClient, chunks [][]byte) error {
|
||||
stream, err := client.Upload(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.Upload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stream.CloseSend() // panic 时确保 send 方向关闭
|
||||
}
|
||||
}()
|
||||
for _, chunk := range chunks {
|
||||
if err := stream.Send(&pb.FileChunk{Data: chunk}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err := stream.CloseAndRecv() // 结束发送,获取最终结果
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("upload complete, size: %d\n", result.Size)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Server side - 逐块接收后聚合(用 return 而非 Fatal,让 gRPC 框架处理错误上报)
|
||||
@@ -206,13 +230,34 @@ func (s *Server) Upload(stream pb.FileService_UploadServer) error {
|
||||
}
|
||||
buffer.Write(chunk.Data)
|
||||
}
|
||||
_, err := stream.SendAndReceive(&pb.UploadResult{Size: int32(buffer.Len())})
|
||||
result, err := stream.SendAndReceive(&pb.UploadResult{Size: int32(buffer.Len())})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip] CloseAndRecv vs CloseSend + Recv
|
||||
> `CloseAndRecv()` 是 Client Stream 中的便捷方法——它同时完成"关闭发送方向"和"读取响应"两步。等价于先 `stream.CloseSend()` 再 `stream.Recv()`。在 Client Stream 中两者效果相同,但 `CloseAndRecv` 更简洁、出错概率更低。
|
||||
|
||||
**优势:**内存友好——不需要一次性 load 全部数据到内存中,每个 chunk 独立收发。
|
||||
|
||||
## 错误处理模式总结
|
||||
|
||||
四种模式共用同一套错误处理原则:
|
||||
|
||||
| 场景 | Unary | Server Stream | Client Stream | BiDi Stream |
|
||||
|------|:-----:|:-------------:|:-------------:|:-----------:|
|
||||
| **Stream 创建失败(Client)** | — | `log.Fatal` ✅ | `log.Fatal` ✅ | `log.Fatal` ✅ |
|
||||
| **Recv 返回 io.EOF** | — | `break` ✅ | `break` ✅ | `close(done)` ✅ |
|
||||
| **Recv 返回其他 error** | `return/log` ✅ | `return/log` ✅ | `return/log` ✅ | 分别处理两端 ✅ |
|
||||
| **Send 返回 error** | — | `return` ✅ | `return` ✅ | `return` ✅ |
|
||||
| **Context canceled** | RPC 自动取消 | 同 error ✅ | 同 error ✅ | `select <-ctx.Done()` ✅ |
|
||||
|
||||
> [!important] 黄金法则
|
||||
> 1. **只有连接建立阶段的 dial/send 错误才能用 `log.Fatal`**——handler 内部永远用 return
|
||||
> 2. **io.EOF 不是错误**——它表示对方完成了发送方向,是正常退出信号
|
||||
> 3. **每次 stream recv/send 都要检查 error**,哪怕代码看起来"不可能失败"
|
||||
> 4. **context timeout 对所有模式生效**——Streaming 不会因为"流式"就自动获得更长超时
|
||||
|
||||
## Bidirectional Streaming RPC(双向流)
|
||||
|
||||
Client 和 Server 可以同时独立地发送消息,是全双工通信。
|
||||
@@ -227,8 +272,9 @@ rpc Chat(stream ChatMessage) returns (stream ChatMessage);
|
||||
```
|
||||
|
||||
```go
|
||||
// Server side - 转发逻辑,两端各自独立循环
|
||||
// Server side - 转发逻辑,两端各自独立循环(增加 context cancel 处理)
|
||||
func (s *Server) Chat(stream pb.ChatService_ChatServer) error {
|
||||
ctx := stream.Context()
|
||||
done := make(chan struct{})
|
||||
|
||||
// goroutine 1: 读取客户端消息
|
||||
@@ -240,6 +286,7 @@ func (s *Server) Chat(stream pb.ChatService_ChatServer) error {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("recv error: %v", err)
|
||||
return
|
||||
}
|
||||
// 广播给其他 connected clients...
|
||||
@@ -252,32 +299,41 @@ func (s *Server) Chat(stream pb.ChatService_ChatServer) error {
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err() // client disconnected → clean exit
|
||||
case <-done:
|
||||
return nil
|
||||
case t := <-ticker.C:
|
||||
stream.Send(&pb.ChatMessage{Text: fmt.Sprintf("heartbeat: %s", t)})
|
||||
if sendErr := stream.Send(&pb.ChatMessage{Text: fmt.Sprintf("heartbeat: %s", t)}); sendErr != nil {
|
||||
log.Printf("send heartbeat error: %v", sendErr)
|
||||
return sendErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**关键点:**
|
||||
- 两端的 Send 和 Recv 是独立的——一端 Recv 完不影响另一端继续 Send
|
||||
- 必须用两个 goroutine 分别处理 recv 和 send 循环
|
||||
- `io.EOF` 只表示对方的关闭,不代表己方也要停止
|
||||
- 两端的 Send 和 Recv 是独立的——一端 Recv 完(EOF)不影响另一端继续 Send
|
||||
- 必须用两个 goroutine 分别处理 recv 和 send 循环——单线程无法同时读写
|
||||
- `io.EOF` 只表示对方的关闭,不代表己方也要停止发送
|
||||
- **Context Cancel 优先级高于 EOF**:Client 断开连接时,`stream.Context().Done()` 先触发。生产代码中永远要检查它
|
||||
- 建议在 handler 中使用 `defer stream.SendAndClose(...)` 或 defer 清理逻辑确保资源释放
|
||||
|
||||
### 背压与心跳(生产级要点)
|
||||
|
||||
BiDi Stream 在长连接场景下,有两个必须考虑的问题:
|
||||
BiDi Stream 在长连接场景下,有三个必须考虑的问题:context cancel、io.EOF、背压和心跳保活。
|
||||
|
||||
```go
|
||||
// 背压控制:如果 Send 堆积过多,应该限流或暂停
|
||||
// 背压控制:如果 Send 堆积过多,buffer 满时自动阻塞发送端
|
||||
func (s *Server) handleStream(stream pb.ChatService_ChatServer) error {
|
||||
sendCh := make(chan *pb.ChatMessage, 100) // buffer size = 100
|
||||
go func() {
|
||||
for msg := range sendCh {
|
||||
// Send 是阻塞的——buffer 满时自动背压
|
||||
stream.Send(msg)
|
||||
// Send 是阻塞的——buffer 满时自动触发背压
|
||||
if err := stream.Send(msg); err != nil {
|
||||
return // connection lost, goroutine exits cleanly
|
||||
}
|
||||
}
|
||||
}()
|
||||
// ...recv loop 往 sendCh 里塞消息即可
|
||||
@@ -285,7 +341,7 @@ func (s *Server) handleStream(stream pb.ChatService_ChatServer) error {
|
||||
```
|
||||
|
||||
> [!tip] 背压原理
|
||||
> gRPC 的 `Send()` 是**有缓冲阻塞**的。当 internal buffer 写满时,发送端会自动 pause——这就是 HTTP/2 Flow Control 提供的天然背压机制,不需要手动实现。但你应该设置合理的 buffer size,过大浪费内存,过小影响吞吐。
|
||||
> gRPC 的 `Send()` 是**有缓冲阻塞**的。当 internal buffer 写满时,发送端会自动 pause——这就是 HTTP/2 Flow Control 提供的天然背压机制,不需要手动实现。但你应该设置合理的 buffer size,过大浪费内存,过小影响吞吐。建议从 100 起步,根据实际监控调整。
|
||||
|
||||
> [!note] Keepalive 配置示例
|
||||
> ```go
|
||||
@@ -293,35 +349,41 @@ func (s *Server) handleStream(stream pb.ChatService_ChatServer) error {
|
||||
> grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
||||
> Time: 10 * time.Second, // ping interval
|
||||
> Timeout: 20 * time.Second, // wait for ping ack
|
||||
> PermitWithoutStream: true, // 即使无活跃 RPC 也发 ping
|
||||
> PermitWithoutStream: true, // 即使无活跃 RPC 也发 ping
|
||||
> }),
|
||||
> )
|
||||
> ```
|
||||
> 这对穿越 Nginx / AWS ALB 等负载均衡器至关重要——它们通常会对空闲连接执行 tcp idle timeout 断开。
|
||||
> 这对穿越 Nginx / AWS ALB 等负载均衡器至关重要——它们通常会对空闲连接执行 TCP idle timeout 断开。服务端也需要配置类似的 keepalive,否则 Server→Client 方向的心跳缺失会导致 Client 误判连接死亡。
|
||||
|
||||
> [!warning] 复杂度警告
|
||||
> BiDi Streaming 是最强大但也最容易出错的模式。你必须同时处理:context cancel、io.EOF、网络异常、心跳保活、背压(backpressure)。生产环境中除非必要,否则优先考虑其他三种模式。
|
||||
|
||||
> [!question] 为什么需要 PermitWithoutStream?
|
||||
> 因为某些场景中连接处于"空闲状态"——没有正在进行的 RPC 调用——此时 LB 会因为检测到 TCP 层无任何流量而主动断开连接。设置 `PermitWithoutStream: true` 确保即使没有活跃流,gRPC 仍会持续发送 ping 包维持连接。
|
||||
|
||||
## 模式选型决策指南
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start{是否需要<br/>实时交互?}
|
||||
Start -->|否| Simple{单次<br/>请求?}
|
||||
Start -->|是| BiDi{高频<br/>交互?}
|
||||
Start["是否需要\n实时交互?"] -->|否| Simple["单次请求?"]
|
||||
Start -->|是| BiDi["高频交互?"]
|
||||
|
||||
Simple -->|是| U[Unary RPC<br/>最简单]
|
||||
Simple -->|否| SS[Server Stream<br/>一次请求多次返回]
|
||||
Simple -->|是| U["Unary RPC\n最简单 ⭐"]
|
||||
Simple -->|否| SS["Server Stream\n一次请求多次返回"]
|
||||
|
||||
BiDi -->|是| BD[Bidirectional Stream<br/>全双工通信]
|
||||
BiDi -->|否| CS{数据量<br/>超大?}
|
||||
CS -->|是| CB[Client Stream<br/>分批上传]
|
||||
BiDi -->|是| BD["Bidirectional Stream\n全双工通信 🔥"]
|
||||
BiDi -->|否| CS["数据量超大?"]
|
||||
|
||||
CS -->|是| CB["Client Stream\n分批上传"]
|
||||
CS -->|否| SS
|
||||
|
||||
style U fill:#00D866,color:#fff
|
||||
style BD fill:#FF6B35,color:#fff
|
||||
```
|
||||
|
||||
> [!tip] 选型原则
|
||||
> **默认选 Unary**。只有在三种情况下考虑其他模式:(1) 需要实时推送——用 Server Stream;(2) 数据量大且需流式发送——用 Client Stream;(3) 双向高频交互——用 BiDi Stream。永远不要为了炫技而选择更复杂的模式。
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 维度 | Unary | Server Stream | Client Stream | BiDi Stream |
|
||||
|
||||
@@ -414,6 +414,45 @@ go generate ./...
|
||||
> [!tip] 为什么推荐 go generate?
|
||||
> 相比手写 protoc 命令,`go generate` 让代码生成变成 Go 工作流的一部分,不再需要额外记忆复杂的命令行参数。结合 Makefile 或 Task 更稳定。参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]。
|
||||
|
||||
## 实践要点:自动生成 vs 手写
|
||||
|
||||
### 自动生成(由 protoc 产出,不要手动修改)
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `xxx_pb.go` | Message struct 定义 + getter 方法(如 `CreateUserRequest{}`、`GetName()`) |
|
||||
| `xxx_grpc.pb.go` | Client Stub interface + `NewUserServiceClient()` |
|
||||
| `xxx_grpc.pb.go` | Server Interface(如 `UserServiceServer{ CreateUser(...) }`) |
|
||||
| `xxx_grpc.pb.go` | Register 函数 + ServiceDesc 元数据 |
|
||||
| `xxx_grpc.pb.go` | `UnimplementedUserServiceServer` 零值安全基类 |
|
||||
|
||||
这些文件**每次 `.proto` 变更后重新生成即可覆盖**。如果发现有 bug,修复源头 `.proto` 后重新生成,而不是直接改生成的文件。
|
||||
|
||||
### 开发者手写的部分
|
||||
|
||||
```go
|
||||
// ① 服务端:实现生成的 Server Interface
|
||||
type server struct {
|
||||
v1.UnimplementedUserServiceServer // ← 嵌入自动生成的基类
|
||||
store *UserStore // ← 自己的依赖注入
|
||||
}
|
||||
func (s *server) CreateUser(ctx, req *v1.CreateUserRequest) (*v1.CreateUserResponse, error) {
|
||||
// ← 填充业务逻辑(数据库操作、验证、权限等)
|
||||
}
|
||||
|
||||
// ② 客户端:使用生成的 Client Stub 发起调用
|
||||
client := v1.NewUserServiceClient(conn) // ← 用生成的函数创建
|
||||
resp, _ := client.CreateUser(ctx, &v1.CreateUserRequest{Name: "Alice"})
|
||||
|
||||
// ③ Server 启动注册
|
||||
v1.RegisterUserServiceServer(s, &myServer{})
|
||||
|
||||
// ④ 中间件、拦截器、错误映射、健康检查等——全部自行实现
|
||||
```
|
||||
|
||||
> [!important] 核心原则
|
||||
> **所有业务代码都应假设生成的 `.pb.go` / `_grpc.pb.go` 随时会被重新生成覆盖。** 不自行定义与 proto 同名的 struct;类型统一从生成的包 import;不修改任何生成的文件。
|
||||
|
||||
## 常见错误排查
|
||||
|
||||
| 错误信息 | 原因 | 解决方法 |
|
||||
|
||||
@@ -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 知识库全景索引
|
||||
Reference in New Issue
Block a user