From 93daec5d10109b92121fa5e5af5dff0d24752000 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Wed, 13 May 2026 11:17:21 +0800 Subject: [PATCH] vault backup: 2026-05-13 11:17:21 --- .../04-FieldMask 实战/FieldMask 实战.md | 133 +++++++- .../1. Protobuf 基础篇/04-Oneof 与包装类型.md | 31 +- .../2. gRPC 核心篇/05-RPC 调用模式总览.md | 148 ++++++--- .../06-Service 定义与代码生成.md | 39 +++ .../07-RPC 设计与 RESTful 对应.md | 300 ++++++++++++++++++ hhs/gRPC/README.md | 1 + 6 files changed, 597 insertions(+), 55 deletions(-) create mode 100644 hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应.md diff --git a/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md b/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md index 43ede62..ff4f18e 100644 --- a/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md +++ b/hhs/gRPC/1. Protobuf 基础篇/04-FieldMask 实战/FieldMask 实战.md @@ -17,12 +17,48 @@ FieldMask 是 protobuf WKT 中最实用的工具类类型之一,用于实现 * ```protobuf import "google/protobuf/field_mask.proto"; +message User { + string display_name = 1; + string email = 2; + string phone = 3; + string avatar = 4; +} + message UserPatchRequest { - google.protobuf.FieldMask update_mask = 1; // 告诉服务端要改哪些字段 - User user = 2; // 只填新值 + google.protobuf.FieldMask update_mask = 1; + User user = 2; } ``` +**关键:`update_mask` 中的字段名来自 `User` 的定义。** +它本身不包含字段信息,只引用被包裹的 `user` message 中已存在的字段名。 +例如 `"display_name,email"` 意味着用 `req.user.display_name` 和 `req.user.email` 的值去覆盖现有记录。 +如果 mask 中提到的字段在 `User` 里不存在,`ApplyFieldMask` 会返回 `InvalidArgument` 错误。 + +> [!warning] Proto 层无约束 —— 全靠服务端约定 +> 从 proto 定义来看,`google.protobuf.FieldMask` 的内部结构只是: +> +> ```protobuf +> message FieldMask { +> repeated string paths = 1; +> } +> ``` +> +> 它是一个**通用的字符串列表**,与任何具体的 target message 都没有绑定关系。 +> 这解释了为什么 `update_mask` 字段上不会标注「只能填 display_name 或 email」。 +> +> **字段名的关联是在服务端代码中建立的:** +> +> ```go +> proto.ApplyFieldMask(&existingUser, req.GetUser()) +> // ↑ ↑ +> // target 决定哪些字段可被更新 +> ``` +> +> - `target`(第一个参数)是 `User` → 所以 mask 里的值只能是 `display_name`、`email`、`phone`、`avatar` +> - `source`(第二个参数)提供新值 → 只有被 mask 指定的字段才会取 `source` 中的值 +> - 如果客户端传了 mask 中没有的字段(如 `wechat_id`),且该字段在 `User` 中不存在,`ApplyFieldMask` 会报错 + 两个核心约定: | 字段 | 含义 | JSON 表现形式 | @@ -86,6 +122,95 @@ FieldMask 支持用点号访问嵌套字段,例如: > [!note] 点号路径要求目标字段也必须存在 > `ApplyFieldMask` 在遇到不存在的嵌套路径时会返回错误。调用方需确保结构完整,或使用 null-safe 路径语法(需自行处理)。 +## 底层机制:字符串到字段的映射过程 + +`ApplyFieldMask` 的核心链路可以分解为三步: + +### 第 1 步:把字符串拆成字段路径 + +`update_mask` 的值 `"display_name,email"` 在传输时已经被 protobuf JSON 反序列化器转成了 Go 的 `[]string{"display_name", "email"}`。protobuf 的 FieldMask 类型定义只是: + +```protobuf +message FieldMask { + repeated string paths = 1; +} +``` + +所以到了 `ApplyFieldMask` 调用点,它就是一个普通的字符串切片——**里面只有名字,没有任何元数据**。 + +### 第 2 步:通过反射查找字段描述符 + +Go protobuf 的反射 API 提供了 `protoreflect.Fields`,内部用一个 map 存储所有字段: + +```go +fd := md.Fields().ByName(protoreflect.Name(field)) +``` + +以 `field = "display_name"` 为例: + +| 查找方式 | 源码 | 匹配结果 | +|---------|------|---------| +| `ByName` | `md.Fields().ByName("display_name")` | ✅ 找到 `User.display_name` | +| `ByJSONName` | `md.Fields().ByJSONName("displayName")` | ✅ 找 JSON 别名 | +| `ByNumber` | `md.Fields().ByNumber(1)` | ✅ 按字段编号 | + +`ApplyFieldMask` 使用 `ByName`——**匹配的是 proto 文件中声明的原始字段名**(即 `display_name`),不是 JSON camelCase 名。如果你的 proto 写成 `displayName`(驼峰),那 mask 里就必须传 `"displayName"`。 + +### 第 3 步:反射赋值 + +找到 `FieldDescriptor` 后,protobuf 用反射完成值传递: + +```go +// ApplyFieldMask 内部的等价逻辑(伪代码): +fd := dst.Descriptor().Fields().ByName("display_name") +dst.Set(fd, src.Get(fd)) +// ↑ 等价于手写:existingUser.DisplayName = req.User.DisplayName +``` + +如果是嵌套路径 `"profile.display_name"`,则先解析出 `profile` 的 descriptor,再通过 `fd.Message()` 进入嵌套 message,对第二段 `"display_name"` 重复上述流程。 + +> [!note] 运行时校验 +> 由于整个过程是「字符串 → 反射查找 → 赋值」,如果 mask 中包含了一个根本不在 User message 里的字段名(如 `"wechat_id"`),`ByName` 会返回 nil,此时 `ApplyFieldMask` 立即返回错误。**proto 编译器不会检查这种跨消息引用的合法性**,校验全部依赖运行时的反射查找。 + +### 谁来决定 `update_mask` 的值? + +`"display_name,email"` 这个值 **不是由任何代码生成的**——它是调用方(client)自己决定的。 + +整个链路中没有任何机制在编译期或运行期约束 "只能选这几个字段名": + +| 环节 | 做什么 | 有没有强制约束? | +|------|--------|-----------------| +| **客户端** | UI 表单检测到用户改了名字和邮箱 → 拼出 `update_mask = "display_name,email"` | ❌ 纯业务逻辑 | +| **Proto 定义** | 声明 `FieldMask update_mask = 1` | ❌ 只是一个开放字符串列表 | +| **网络传输** | 把字符串发给服务端 | ❌ 不做任何校验 | +| **服务端** | 收到值后,用反射逐个查找是否在 `User` 中存在 | ✅ 只拒绝非法字段名 | + +```mermaid +sequenceDiagram + participant Client as 前端 / CLI + participant Network as gRPC Client + participant Server as Server + + Client->>Network: 用户改了 display_name 和 email + Note over Client,Network: ① 开发者自己决定要改哪些字段 + activate Network + Network->>Server: serialize({ update_mask: "display_name,email", user: {...} }) + deactivate Network + activate Server + Server->>Server: ApplyFieldMask(target, source) + Note right of Server: 反射校验:display_name? yes
email? yes → 赋值 ✓ + Server-->>Client: { ... updated User ... } + deactivate Server +``` + +这意味著: + +1. **协议本身不限制可选值**——客户端理论上可以传任何字符串,包括 `"hello_world"`、`"random_value"` +2. **只有服务端在做兜底**——你传了个 `"wechat_id"` 会报错说这个字段不存在 +3. **客户端的合理做法**是在 UI 层面维护一份可用的字段白名单,根据哪些字段被修改来自动生成 mask + +所以 `"display_name,email"` 的本质是:**开发人员在客户端知道用户改了这两个字段,于是手动把它们填进了 mask 里**。没有框架在背后自动生成它。 + ## 客户端最佳实践 ### Go 构造示例 @@ -118,7 +243,7 @@ const req = { | 坑 | 说明 | 规避方法 | |----|------|---------| | 空 mask | `update_mask` 为空数组时不会报错,但也不会更新任何字段 | 在服务层校验 `len(mask.Paths) > 0` | -| 大小写敏感 | 字段名严格匹配 camelCase(JSON 映射后的格式),不支持 snake_case | 前端统一使用 proto 定义的 CamelCase | +| 大小写敏感 | `ByName` 匹配的是 proto 文件中声明的原始字段名。proto 写成 `display_name` 则 mask 必须传 `"display_name"`;写成 `displayName` 则传 `"displayName"` | 保持 proto 定义与 mask 中的命名一致 | | 字段不存在 | mask 中提到不存在的字段会抛出 `InvalidArgument` 错误 | 服务端捕获并返回清晰错误信息 | | Oneof 冲突 | 对 oneof 组中的一个字段应用 mask 时,其他 oneof 字段会被清除 | 业务逻辑中提前规避互斥冲突 | @@ -143,4 +268,4 @@ graph LR ## 关联笔记 - [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解]] — WKT 类型的概览入口,FieldMask 是其中的一种 -- [[hhs/gRPC/2. gRPC 实战指南/01-RPC 设计与 RESTful 对应]] — RPC 与 RESTful API 的语义对照,PATCH 场景在此展开 +- [[../../2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应]] — RPC 与 RESTful API 的语义对照,PATCH 场景在此展开 diff --git a/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md b/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md index d6fb708..5f55edf 100644 --- a/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md +++ b/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md @@ -316,6 +316,21 @@ if err != nil { ... } // native.Interface() → map[string]any ``` +### Any 与 Value 对比 + +| 维度 | `Any` | `Value` | +|------|-------|---------| +| **包裹对象** | 其他 protobuf 消息 | 任意 JSON 值(struct / list / string / number / bool / null) | +| **类型信息** | 有 `@type`,运行时可校验 | 零类型信息 | +| **反序列化** | `UnmarshalTo(&target)` — 强类型目标结构体 | 手动 `GetXXX()` — 裸 `interface{}` | +| **灵活性** | ⚠️ 需在服务端注册类型 descriptor | ✅ 传什么 JSON 都行 | +| **类比** | Go 的 `any`(interface{}),但带 type 标签 | 数据库里的 JSONB 字段 | +| **典型场景** | 事件总线、插件架构、Generic Response Wrapper | 配置中心、审计日志、自由表单 | + +### 一句话决策 + +如果你知道消息类型且想享受编译期生成的类型定义,用 `Any`;如果你连结构都不确定(比如纯 JSON 自由格式),用 `Value`。 + > [!question] 思考题 > `Any` 和 `Value` 都能包裹动态内容,该用哪个?记住一个原则:**如果你知道消息类型且想享受编译期检查,用 Any;如果你连结构都不确定(比如纯 JSON),用 Value。** @@ -422,14 +437,14 @@ effectiveMask, _ := fieldmaskpb.New(changedFields...) ## 对比总结表格 -| 特性 | Oneof | Wrapper | Any | Value | -|------|-------|---------|-----|-------| -| 类型安全 | ✅ compile-time | ✅ compile-time | ⚠️ runtime | ❌ 运行时 | -| 单个可选 | ✅ 可用 | ✅(更简洁) | N/A | N/A | -| 多值互斥 | ✅ 核心用途 | ❌ | N/A | N/A | -| 动态类型 | ❌ | ❌ | ✅ | ✅ | -| JSON 互转 | ⚠️ 需额外处理 | ✅ | ✅ | ✅ | -| wire overhead | 低 | 低 | 中(需存 type_url) | 低 | +| 特性 | Oneof | Wrapper | Any | Value | +| ------------- | -------------- | -------------- | -------------- | ----- | +| 类型安全 | ✅ compile-time | ✅ compile-time | ⚠️ runtime | ❌ 运行时 | +| 单个可选 | ✅ 可用 | ✅(更简洁) | N/A | N/A | +| 多值互斥 | ✅ 核心用途 | ❌ | N/A | N/A | +| 动态类型 | ❌ | ❌ | ✅ | ✅ | +| JSON 互转 | ⚠️ 需额外处理 | ✅ | ✅ | ✅ | +| wire overhead | 低 | 低 | 中(需存 type_url) | 低 | ## 关联笔记 diff --git a/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md b/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md index 9ac5df7..534fa53 100644 --- a/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md +++ b/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md @@ -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{是否需要
实时交互?} - Start -->|否| Simple{单次
请求?} - Start -->|是| BiDi{高频
交互?} + Start["是否需要\n实时交互?"] -->|否| Simple["单次请求?"] + Start -->|是| BiDi["高频交互?"] - Simple -->|是| U[Unary RPC
最简单] - Simple -->|否| SS[Server Stream
一次请求多次返回] + Simple -->|是| U["Unary RPC\n最简单 ⭐"] + Simple -->|否| SS["Server Stream\n一次请求多次返回"] - BiDi -->|是| BD[Bidirectional Stream
全双工通信] - BiDi -->|否| CS{数据量
超大?} - CS -->|是| CB[Client Stream
分批上传] + 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 | diff --git a/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md b/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md index 3eac891..c395d8e 100644 --- a/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md +++ b/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md @@ -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;不修改任何生成的文件。 + ## 常见错误排查 | 错误信息 | 原因 | 解决方法 | diff --git a/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应.md b/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应.md new file mode 100644 index 0000000..b255309 --- /dev/null +++ b/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应.md @@ -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 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{"完整表单提交
所有字段都有值?"} + + B -->|"✅ 是"| C{"是否需要
知道改了哪些字段?"} + C -->|"❌ 不需要"| D["🟢 选 PUT
简单可靠"] + C -->|"✅ 需要"| E["🔵 选 PATCH\n带审计日志"] + + B -->|"❌ 否
分步填/多页面/增量改"| 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 知识库全景索引 diff --git a/hhs/gRPC/README.md b/hhs/gRPC/README.md index e29c497..e80e7e0 100644 --- a/hhs/gRPC/README.md +++ b/hhs/gRPC/README.md @@ -26,6 +26,7 @@ gRPC 是 Google 开源的高性能 RPC 框架,基于 HTTP/2 和 Protobuf 序 - **[05-RPC 调用模式总览](./2. gRPC 核心篇/05-RPC 调用模式总览.md)** — Unary、Server Streaming、Client Streaming、Bidirectional Streaming 四种模式对比 - **[06-Service 定义与代码生成](./2. gRPC 核心篇/06-Service 定义与代码生成.md)** — `.proto` Service/Dialect 语法、protoc 插件体系、Go Stub 生成机制 - **[07-HTTP2 传输原理](./2. gRPC 核心篇/07-HTTP2 传输原理.md)** — HTTP/2 Frame、Stream Multiplexing、Header Compression(HPACK)、Flow Control +- **↳ [07-RPC 设计与 RESTful 对应](./2. gRPC 核心篇/07-HTTP2 传输原理/07-RPC 设计与 RESTful 对应.md)** — PUT/PATCH/POST 语义映射、Partial Update 的 PATCH + FieldMask 方案 ### 3. 服务端实现