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. 服务端实现