diff --git a/hhs/finalhomework/hhs执行计划.md b/hhs/finalhomework/hhs执行计划.md
new file mode 100644
index 0000000..18dc464
--- /dev/null
+++ b/hhs/finalhomework/hhs执行计划.md
@@ -0,0 +1,592 @@
+---
+tags: [全栈开发, 大作业, 执行计划, gRPC, Gin, OSS, Eino, React]
+create time: 2026-05-09 14:30
+---
+
+# 大作业·执行计划 — 双端 gRPC HR 招聘系统
+
+## 概述
+
+本文档基于《大作业项目要求》《思维导图》《工程结构设计》三份参考资料,梳理出分阶段、可落地的开发执行计划。整体采用 **六阶段推进** 策略:环境准备 → Proto 先行 → 后端双服务 → **前端一日冲刺** → 联调测试 → 视频录制与提交,每个阶段明确任务清单、前置依赖和验收标准,确保按期完成高质量交付。
+
+> [!tip] 如何使用本计划
+> 建议按阶段的顺序推进,不要跳跃。Proto 定义是前后端的"契约",必须先于业务代码完成。每个阶段完成后对照验收清单自检,避免返工。
+
+## 一、整体时间规划与打勾进度
+
+将整个开发周期拆分为 6 个阶段,各阶段之间存在明确的先后依赖关系。下方是总进度看板,每完成一个阶段就打勾:
+
+```mermaid
+gantt
+ title 大作业开发总时间线
+ dateFormat YYYY-MM-DD
+ axisFormat %m-%d
+ todayMarker stroke-width:3px,stroke:#fb8c00,opacity:0.6
+
+ section 阶段一 · 环境就绪 (M0)
+ Go / Node / MySQL / OSS :done, p1, 2026-05-10, 1d
+ .gitignore + .env.example :done, p1b, after p1, 30m
+
+ section 阶段二 · Proto 先行 (M1)
+ 7 个接口定义文件 :done, p2, 2026-05-11, 1d
+ protoc 生成 Go 代码 :done, p2b, after p2, 30m
+
+ section 阶段三 · 后端双服务 (M2)
+ 3A · MySQL 建表 : p3a, 2026-05-12, 1d
+ 3B · Logic 核心服务 (12项) :milestone, m3b, 2026-05-14, 0d
+ 3C · Web 网关服务 : p3c, after p3a, 2d
+
+ section 阶段四 · 前端一日冲刺 (M3)
+ Foundation 共享基建 : p4a, 2026-05-15, 3h
+ 候选人用户端 (6页) : p4u, after p4a, 4h
+ HR 管理端 (7页) : p4h, after p4a, 5h
+ Build 验证 + Commit :milestone,m4z, 2026-05-15, 0d
+
+ section 阶段五 · 联调自测 (M4)
+ 全链路跑通 : p5a, 2026-05-16, 1d
+ 专项测试 + Bug 修复 : p5b, 2026-05-17, 1d
+
+ section 阶段六 · 录制提交 (M5)
+ 文档完善 : p6a, 2026-05-18, 2d
+ 视频录制 + KDoc 提交 :milestone,m6end, 2026-05-18, 0d
+
+ %% 里程碑锚点
+ milestone M0_环境就绪 :milestone, mk1, 2026-05-10, 0d
+ milestone M1_Protobuf冻结 :milestone, mk2, 2026-05-11, 0d
+ milestone M2_后端可运行 :milestone, mk3, 2026-05-14, 0d
+ milestone M3_双端完成 :milestone, mk4, 2026-05-15, 0d
+ milestone M4_联调通过 :milestone, mk5, 2026-05-17, 0d
+ milestone M5_正式提交 :milestone, mk6, 2026-05-18, 0d
+```
+
+
+
+| 阶段 | 日期 | 核心交付物 | 里程碑 |
+| --------------- | ------------- | ------------------------------ | -------- |
+| ~~阶段一:环境与基础建设~~ | ~~05-10~~ | Go / Node / MySQL / OSS 就绪 | ✅ **M0** |
+| 阶段二:Proto 接口定义 | ~~05-11~~ | 7 个 `.proto` 文件 + 生成代码 | ⬜ **M1** |
+| 阶段三:后端双服务 | 05-12 ~ 05-14 | Logic gRPC + Web Gin 均可独立运行 | ⬜ **M2** |
+| 阶段四:前端一日冲刺 | 05-15 | hr-frontend + user-frontend 启动 | ⬜ **M3** |
+| 阶段五:前后端联调 | 05-16 ~ 05-17 | 全链路测试通过 | ⬜ **M4** |
+| 阶段六:视频录制与提交 | 05-18 | 视频 + 文档全部提交 | ⬜ **M5** |
+
+> [!tip] 使用说明
+> - 🟢 **绿色条** = 已完成阶段 · 🟡 **黄色条** = 当前活跃期 · 🔴 **菱形标记** = 里程碑检查点
+> - 橙色竖线为今日日期标注,直观对比当前所处阶段
+> - 甘特图下方表格同步映射 M0~M5 里程碑编号,与第九节里程碑总表一一对应
+
+> [!question] 如果某个阶段超时了怎么办?
+> 优先级原则:核心功能(gRPC 分层调用、OSS 签名上传、Eino 对话)> 次要功能(分页搜索、数据可视化)。宁可缩减 UI 装饰细节,也要保住验收红线。
+
+## 二、阶段一:环境与基础建设
+
+> [!note] 进度追踪
+> - [x] T1.1 Go 1.21+ 安装并验证
+> - [x] T1.2 Node.js 18+ / npm 安装并验证
+> - [x] T1.3 MySQL 8.0+ 安装 + 数据库实例创建
+> - [x] T1.4 OSS 平台注册 + 私有 Bucket 创建
+> - [x] T1.5 Git 仓库初始化 + 目录结构建立
+> - [ ] T1.6 `.gitignore` + `.env.example` 初始化
+
+
+📋 展开查看详细任务表
+
+### 2.1 详细任务清单
+
+| 编号 | 任务 | 耗时估计 | 说明 |
+|------|------|----------|------|
+| T1.1 | 安装并验证 Go 1.21+ | 30min | `go version`,配置 GOPATH 和 GOMODCACHE |
+| T1.2 | 安装并验证 Node.js 18+ / npm | 30min | 后续用于两个前端项目 |
+| T1.3 | 安装并验证 MySQL 8.0+ | 30min | 创建数据库实例,记录连接信息 |
+| T1.4 | 注册 OSS 平台,创建私有 Bucket | 30min | 关闭匿名访问,关闭公开读权限 |
+| T1.5 | 创建 Git 仓库,建立最终目录结构 | 30min | 参照 `final_homework/` 规范 |
+| T1.6 | 初始化 .gitignore 和 .env.example | 30min | 敏感文件排除:.env、go.sum(可选)、node_modules |
+
+
+
+### 2.2 前置条件
+
+- 无,这是第一个阶段。
+
+### 2.3 验收标准
+
+- [ ] `go version`、`node -v`、`mysql --version` 均正常输出
+- [ ] OSS Bucket 已创建且为私有(匿名访问已关闭)
+- [ ] 本地目录结构已按 `工程结构.md` 的顶层树创建出来
+- [ ] `.gitignore` 正确排除了敏感配置文件
+
+```bash
+# 预期目录骨架
+final_homework/
+├── hr-frontend/
+├── user-frontend/
+├── web-gin-service/
+├── logic-grpc-service/
+└── api/
+```
+
+> [!warning] 常见陷阱
+> - OSS 密钥务必写入 .env 或独立配置文件,**不要硬编码到业务代码中**。真实业务场景中 key 是绝对禁止提交到仓库的。
+> - 新建 Git 仓库后立刻添加 .gitignore,避免误提交 .env 文件。
+
+## 三、阶段二:Proto 接口定义(契约先行)
+
+> [!note] 进度追踪
+> - [ ] T2.1 `common.proto` — 通用响应码、分页参数
+> - [ ] T2.2 `auth.proto` — Login/RPC、Register/RPC、VerifyToken/RPC
+> - [ ] T2.3 `job.proto` — CreateJob/RPC、UpdateJob/RPC、ListJobs/RPC、DeleteJob/RPC
+> - [ ] T2.4 `application.proto` — SubmitApplication/RPC、ListApplications/RPC
+> - [ ] T2.5 `profile.proto` — GetProfile/RPC、UpdateProfile/RPC
+> - [ ] T2.6 `resume.proto` — GenOssSign/RPC、SaveResume/RPC
+> - [ ] T2.7 `chat.proto` — SendChat/RPC、GetHistory/RPC
+> - [ ] T2.8 protoc 生成 Go 代码
+
+
+📋 展开查看详细任务表 + 前置条件
+
+### 3.1 详细任务清单
+
+| 编号 | 任务 | 前置依赖 | 耗时估计 |
+|------|------|----------|----------|
+| T2.1 | 定义 `common.proto`(通用响应码、分页参数) | — | 30min |
+| T2.2 | 定义 `auth.proto`(Login/RPC、Register/RPC、VerifyToken/RPC) | T2.1 | 30min |
+| T2.3 | 定义 `job.proto`(CreateJob/RPC、UpdateJob/RPC、ListJobs/RPC、DeleteJob/RPC) | T2.1 | 30min |
+| T2.4 | 定义 `application.proto`(SubmitApplication/RPC、ListApplications/RPC) | T2.1 | 30min |
+| T2.5 | 定义 `profile.proto`(GetProfile/RPC、UpdateProfile/RPC) | T2.1 | 30min |
+| T2.6 | 定义 `resume.proto`(GenOssSign/RPC、SaveResume/RPC) | T2.1 | 30min |
+| T2.7 | 定义 `chat.proto`(SendChat/RPC、GetHistory/RPC) | T2.1 | 30min |
+| T2.8 | protoc 生成 Go 代码 | T2.1 ~ T2.7 | 30min |
+
+
+
+### 3.2 前置条件
+
+- 阶段一已完成(T1.1 ~ T1.6)
+- protoc 编译器及 go plugin 插件已安装
+
+### 3.3 验收标准
+
+- [ ] `protoc --go_out=... --go-grpc_out=...` 在 `api/proto/v1/` 下编译通过
+- [ ] 生成的 Go 代码存在于两个服务的 `proto/gen/v1/` 目录
+- [ ] 所有 RPC 方法签名覆盖项目需求文档中的全部接口
+
+> [!tip] Proto 设计最佳实践
+> - 每个 Service 对应一个 proto 文件,职责单一
+> - Request 和 Response 消息统一命名规范:`XxxRequest`、`XxxResponse`
+> - `common.proto` 中的 `CommonResponse{code, msg, data}` 作为所有响应的包装壳
+> - 字段使用 tag 编号,从 1 开始递增,预留扩展空间
+
+## 四、阶段三:后端双服务开发
+
+### 3.1 子阶段 3A:MySQL 数据库建表
+
+> [!note] 进度追踪
+> - [ ] T3A.1 根据 ER 图设计 SQL DDL
+> - [ ] T3A.2 执行建表 + 插入测试种子数据
+
+建表清单(对照 `db.md` 细化):
+
+| 表名 | 用途 | 关键字段 |
+|------|------|----------|
+| `users` | 用户表 | id, username, password_hash, role, created_at |
+| `jobs` | 岗位表 | id, creator_id, title, description, status, created_at |
+| `applications` | 投递记录表 | id, job_id, candidate_id, applied_at |
+| `profiles` | 候选人档案表 | id, user_id, name, phone, education, school, experience, skills |
+| `resumes` | 简历信息表 | id, profile_id, file_key, oss_url, created_at |
+| `chat_records` | AI对话历史表 | id, hr_id, question, ai_reply, created_at |
+
+> [!note] 思考题
+> 为什么 `creator_id` 放在 jobs 表中而不是单独的 job_ownership 关联表?因为本系统架构轻量化,HR 仅管理本人发布的岗位,单字段外键足以表达这种一对一的归属关系。
+
+### 3.2 子阶段 3B:Logic 核心业务服务
+
+> [!note] 进度追踪
+> - [ ] T3B.1 gRPC Server 框架 + config 模块
+> - [ ] T3B.2 AuthService(注册/登录/JWT签发)
+> - [ ] T3B.3 JobService(岗位 CRUD + 创建者权限校验)
+> - [ ] T3B.4 ApplicationService(投递逻辑 + 校验拦截)
+> - [ ] T3B.5 ProfileService(候选人档案增改查)
+> - [ ] T3B.6 ResumeService + OSS signer(签名 URL 生成 + 文件头校验)
+> - [ ] T3B.7 ChatService(意图解析 → SQL查询 → Prompt拼接 → Eino推送 → 持久化)
+> - [ ] T3B.8 repository 层数据访问封装(6张表)
+> - [ ] T3B.9 converter 层 proto ↔ model 转换
+> - [ ] T3B.10 JWT 工具模块(签发 + 验证 + 角色提取)
+> - [ ] T3B.11 AI 模块封装(Eino Chat Engine + prompt 模板 + 意图解析器)
+> - [ ] T3B.12 Logic 服务独立启动测试
+
+
+📋 展开查看 Logic 详细任务表
+
+| 编号 | 任务 | 前置依赖 | 耗时估计 |
+|------|------|----------|----------|
+| T3B.1 | 搭建 gRPC Server 框架 + config 模块 | T3A.2 | 1h |
+| T3B.2 | 实现 AuthService(注册/登录/JWT签发) | T3B.1 | 2h |
+| T3B.3 | 实现 JobService(岗位 CRUD + 创建者权限校验) | T3B.1 | 2h |
+| T3B.4 | 实现 ApplicationService(投递逻辑 + 校验拦截) | T3B.1, T3B.3 | 2h |
+| T3B.5 | 实现 ProfileService(候选人档案增改查) | T3B.1 | 1.5h |
+| T3B.6 | 实现 ResumeService + OSS signer(签名 URL 生成 + 文件头校验) | T3B.1 | 2h |
+| T3B.7 | 实现 ChatService(意图解析 → SQL查询 → Prompt拼接 → Eino推送 → 持久化) | T3B.1, T3A.2 | 2.5h |
+| T3B.8 | repository 层数据访问封装(6张表) | T3B.1 | 2h |
+| T3B.9 | converter 层 proto ↔ model 转换 | T2.8, T3B.8 | 1h |
+| T3B.10 | JWT 工具模块(签发 + 验证 + 角色提取) | T3B.1 | 1h |
+| T3B.11 | AI 模块封装(Eino Chat Engine + prompt 模板 + 意图解析器) | T3B.1 | 2h |
+| T3B.12 | Logic 服务独立启动测试 | T3B.2 ~ T3B.11 | 1h |
+
+
+
+#### Logic 核心流程自查
+
+- [ ] 用户注册时密码 bcrypt 加密存储
+- [ ] 登录成功签发 JWT(包含 userId + role)
+- [ ] 岗位编辑/下架时校验当前操作用户 == creator_id
+- [ ] 候选人投递时拦截:未完善资料或无简历 → 返回错误码
+- [ ] OSS 签名 URL 严格后缀白名单 (.pdf/.doc/.docx) + 文件头 Magic Number 校验
+- [ ] 简历不经过服务端缓存,客户端直传 OSS
+- [ ] AI 对话每条问答自动写入 chat_records 表
+
+> [!danger] 验收红线
+> Logic 服务内所有业务逻辑只能通过 gRPC Server 暴露,Web 服务不得通过同工程内部函数直连调用。如果同一个 Go module 里直接 import service 包 = 架构违规。
+
+### 3.3 子阶段 3C:Web 网关服务
+
+> [!note] 进度追踪
+> - [ ] T3C.1 Gin 项目框架 + router 注册
+> - [ ] T3C.2 CORS 中间件(全局跨域处理)
+> - [ ] T3C.3 JWT 鉴权中间件(读取 Token + 角色校验)
+> - [ ] T3C.4 参数合法性校验中间件
+> - [ ] T3C.5 gRPC Client 连接池 + codec JSON 转换
+> - [ ] T3C.6 Auth Handler
+> - [ ] T3C.7 Job Handler
+> - [ ] T3C.8 Application Handler
+> - [ ] T3C.9 Profile Handler
+> - [ ] T3C.10 Resume Handler
+> - [ ] T3C.11 Chat Handler
+> - [ ] T3C.12 Web 服务独立启动测试
+
+
+📋 展开查看 Web 网关详细任务表
+
+| 编号 | 任务 | 前置依赖 | 耗时估计 |
+|------|------|----------|----------|
+| T3C.1 | 搭建 Gin 项目框架 + router 注册 | T3B.12 | 1h |
+| T3C.2 | CORS 中间件(全局跨域处理) | T3C.1 | 30min |
+| T3C.3 | JWT 鉴权中间件(读取 Token + 角色校验) | T3B.10 | 1h |
+| T3C.4 | 参数合法性校验中间件 | T3C.1 | 1h |
+| T3C.5 | gRPC Client 连接池 + codec JSON 转换 | T3B.12 | 1.5h |
+| T3C.6 | Auth Handler(POST /api/auth/login, register) | T3C.5, T3B.2 | 1h |
+| T3C.7 | Job Handler(CRUD 路由映射到 gRPC) | T3C.5, T3B.3 | 1.5h |
+| T3C.8 | Application Handler | T3C.5, T3B.4 | 1h |
+| T3C.9 | Profile Handler | T3C.5, T3B.5 | 1h |
+| T3C.10 | Resume Handler(获取签名 URL 返回给前端) | T3C.5, T3B.6 | 1.5h |
+| T3C.11 | Chat Handler(AI 对话转发) | T3C.5, T3B.7 | 1.5h |
+| T3C.12 | Web 服务独立启动,用 curl/postman 测试 | T3C.6 ~ T3C.11 | 1h |
+
+
+
+#### Web 端架构自查
+
+- [ ] Handler 中没有任何核心业务逻辑——只有参数解析 + gRPC 调用转发
+- [ ] JWT 中间件在路由注册前挂载,未携带 Token 的请求一律返回 401
+- [ ] 所有 gRPC 调用都有 context deadline(防止阻塞)
+- [ ] 错误码统一通过 CommonResponse.code 传递
+
+## 五、阶段四:前端一日冲刺
+
+> [!tip] 技术选型确认
+> 前端统一使用 **React + Vite + TypeScript + Axios**,UI 组件库推荐 **Ant Design**(开箱即用、减少造轮子时间)。前端只负责页面渲染和接口请求,所有业务逻辑在后端处理。
+
+### 4.1 核心策略:共享基础层 + 下午独立填页
+
+两个前端共用了同一套后端 API 契约(`api/proto/v1/`),因此**不需要各自从零搭架子**。关键压缩思路:
+
+```mermaid
+graph LR
+ subgraph Foundation["Foundation(共享基础层)"]
+ A1["hr-frontend 脚手架初始化"] --> A2["user-frontend 脚手架初始化"]
+ A2 --> A3["双方同步搭建: Router/Axios/AuthContext/布局组件"]
+ A3 --> A4["Ant Design 主题配置"]
+ end
+
+ subgraph Candidate["候选人用户端(独立完成)"]
+ B1["公开岗位列表"] --> B2["登录注册"] --> B3["档案表单"] --> B4["OSS直传"] --> B5["投递记录"]
+ end
+
+ subgraph HR["HR 管理端(独立完成)"]
+ C1["登录注册"] --> C2["岗位CRUD"] --> C3["候选人列表"] --> C4["AI对话窗口"]
+ end
+
+ A4 -.-> B1
+ A4 -.-> C1
+```
+
+| 模块 | 策略 | 复用收益 |
+|------|------|----------|
+| Foundation | 两个项目同时跑:Vite 初始化 + Router + Axios client + AuthContext + 布局组件 | 每个文件只写一次,两份代码各自 `cp -r` 起步,省去重复劳动 |
+| 候选人 / HR | 各自独立填充页面——候选人端 6 页 / HR 端 7 页,互不干扰 | 后端 API 已完成,前端只对接已有接口 |
+
+### 4.2 Foundation 搭建
+
+> [!note] 进度追踪
+> - [ ] T4.AM.1 hr-frontend 脚手架初始化 (`create-vite`)
+> - [ ] T4.AM.1b user-frontend 脚手架初始化 (`create-vite`)
+> - [ ] T4.AM.2 安装依赖 (antd, icons, react-router-dom)
+> - [ ] T4.AM.3 路由配置 + Layout 组件(hr: Sidebar/Header, user: Navbar/Footer)
+> - [ ] T4.AM.4 Axios client 封装(baseURL / interceptor / token 注入)
+> - [ ] T4.AM.5 AuthContext(useContext 管理 JWT,isAuthenticated / role 状态)
+> - [ ] T4.AM.6 Ant Design 全局主题 & 全局 CSS
+
+
+📋 展开查看详细任务表
+
+| 编号 | 任务 | 耗时 | 说明 |
+|------|------|------|------|
+| T4.AM.1 | `npx create-vite hr-frontend --template react-ts` | 5min | 同步创建两个项目 |
+| T4.AM.1b | `npx create-vite user-frontend --template react-ts` | 5min | — |
+| T4.AM.2 | 安装依赖 (`antd`, `@ant-design/icons`, `react-router-dom`) | 10min | 两端同步操作 |
+| T4.AM.3 | 路由配置 + Layout 组件(hr: Sidebar/Header, user: Navbar/Footer) | 45min | 各自按风格定制 |
+| T4.AM.4 | Axios client 封装(baseURL / interceptor / token 注入) | 30min | 模板几乎相同,改 baseURL 即可 |
+| T4.AM.5 | AuthContext(useContext 管理 JWT,isAuthenticated / role 状态) | 30min | 两端复制 + 角色名差异 |
+| T4.AM.6 | Ant Design 全局主题 & 全局 CSS | 20min | hr 用深色主题, user 用浅色主题 |
+
+
+
+#### AM 成果验证
+
+- [ ] 两个 `npm run dev` 均能启动,显示空白布局框架
+- [ ] 路由切换正常,Axios 请求可发送到 `http://localhost:8080/api`
+
+> [!note] 思考题
+> 为什么 AuthContext 只需要改"角色名"就可以复用?因为前后端的鉴权模型是一致的——都靠 JWT 中的 `role` 字段区分 HR/候选人。这个设计体现了"接口契约驱动开发"的思想:proto 定义了统一的用户身份模型,前端只需消费它。
+
+### 4.3 候选人用户端
+
+> [!note] 进度追踪
+> - [ ] T4.U.1 HomePage — 公开岗位列表(免登录浏览)
+> - [ ] T4.U.2 LoginPage + RegisterPage — 表单 + 提交到 `/api/auth/login`
+> - [ ] T4.U.3 JobDetailPage — 岗位详情 + 条件渲染投递按钮
+> - [ ] T4.U.4 SetupProfilePage — 结构化档案表单
+> - [ ] T4.U.5 ResumeUploadPage — OSS 签名 URL 直传组件
+> - [ ] T4.U.6 ApplicationPage + WarningModal — 投递记录 + 拦截弹窗
+
+
+📋 展开查看详细任务表
+
+| 编号 | 任务 | 前置依赖 | 耗时 | 页面数 |
+|------|------|----------|------|--------|
+| T4.U.1 | HomePage:调用 `/api/jobs` 展示岗位卡片列表(免登录) | T4.AM | 40min | 1 |
+| T4.U.2 | LoginPage + RegisterPage:表单 + 提交到 `/api/auth/login` | T4.AM | 40min | 2 |
+| T4.U.3 | JobDetailPage:岗位详情 + 条件渲染投递按钮(已登录 && 有档案 && 有简历) | T4.U.1 | 40min | 1 |
+| T4.U.4 | SetupProfilePage:Ant Design Form 表格单(姓名/电话/学历/院校/经历/技能) | T4.AM | 30min | 1 |
+| T4.U.5 | ResumeUploadPage:选择文件 → 调 `/api/resume/upload` 拿签名 URL → 客户端 PUT 到 OSS | T4.AM, T3C.10 | 50min | 1 |
+| T4.U.6 | ApplicationPage + WarningModal:投递记录 + 拦截弹窗 | T4.AM | 30min | 1+1 |
+
+
+
+#### 候选人端关键交互逻辑
+
+```mermaid
+flowchart TD
+ A["游客打开 /user/"] --> B["查看岗位列表"]
+ B --> C["点击投递按钮"]
+ C --> D{"是否已登录?"}
+ D -->|"否"| E["跳转 /user/login"]
+ D -->|"是"| F{"是否完善档案?"}
+ F -->|"否"| G["提示跳转到 /user/profile/setup"]
+ F -->|"是"| H{"是否有合规简历?"}
+ H -->|"否"| I["提示 /user/resume/upload
PDF/DOC/DOCX 格式校验"]
+ H -->|"是"| J["发送 POST /api/applications"]
+ J --> K["投递成功 ✅"]
+```
+
+### 4.4 HR 管理端
+
+> [!note] 进度追踪
+> - [ ] T4.H.1 LoginPage + RegisterPage(HR 账号体系)
+> - [ ] T4.H.2 Dashboard/HomePage — 工作台概览
+> - [ ] T4.H.3 JobListPage — 本人岗位列表(分页 + 搜索)
+> - [ ] T4.H.4 JobEditPage — 新建/编辑岗位表单
+> - [ ] T4.H.5 CandidatePage — 岗位下候选人列表
+> - [ ] T4.H.6 ProfileViewPage — 候选人结构化档案只读展示
+> - [ ] T4.H.7 ChatPage — AI 智能对话窗口
+
+
+📋 展开查看详细任务表
+
+| 编号 | 任务 | 前置依赖 | 耗时 | 页面数 |
+|------|------|----------|------|--------|
+| T4.H.1 | LoginPage + RegisterPage(HR 账号体系) | T4.AM | 30min | 2 |
+| T4.H.2 | Dashboard/HomePage:工作台概览(数据卡片) | T4.AM | 30min | 1 |
+| T4.H.3 | JobListPage:本人岗位列表(Ant Design Table + 分页 + 搜索) | T4.AM | 50min | 1 |
+| T4.H.4 | JobEditPage:新建/编辑岗位表单 | T4.AM | 40min | 1 |
+| T4.H.5 | CandidatePage:岗位下候选人列表 + 跳转档案详情页 | T4.AM | 50min | 1 |
+| T4.H.6 | ProfileViewPage:候选人结构化档案只读展示 | T4.H.5 | 30min | 1 |
+| T4.H.7 | ChatPage:AI 智能对话窗口(消息气泡 + 历史加载) | T4.AM, T3C.11 | 80min | 1 |
+
+
+
+#### ChatPage 核心交互时序
+
+```mermaid
+sequenceDiagram
+ participant P as ChatPage
+ participant S as ChatContext
+ participant A as Axios
+ participant W as Web-Gin
+
+ Note over P,W: 页面加载时自动拉历史
+ P->>S: useEffect 触发
+ S->>A: GET /api/chat/history
+ A->>W: 带 JWT
+ W-->>A: 历史消息数组
+ A-->>S: state.push(...records)
+ S-->>P: 渲染对话流
+
+ Note over P,W: 用户输入新提问
+ P->>A: POST /api/chat/messages {question}
+ A->>W: 经 gRPC 转发到 Logic
+ W-->>A: {reply, records[]}
+ A-->>S: 追加 AI 回复
+ S-->>P: 自动滚动到底部
+```
+
+### 4.5 收尾
+
+> [!note] 进度追踪
+> - [ ] T4.Z.1 两端 `npm run build` 验证无编译错误
+> - [ ] T4.Z.2 检查 .env 是否正确指向后端地址
+> - [ ] T4.Z.3 git add + commit 本阶段变更
+
+> [!warning] 前端权限边界
+> 前端只做视觉展示和跳转控制。**真正的权限校验必须发生在后端**。例如:即使前端显示了投递按钮,后端也应该再次校验候选人是否满足条件;不应依赖前端隐藏按钮来保护安全。验收时评委可能会故意绕过前端直接调接口测试。
+
+> [!tip] 加速技巧速查
+> - Ant Design 的 `Form` + `Input` + `Select` 可以直接粘贴文档示例修改
+> - 岗位列表复用同一个 `JobCard` 组件(候选人端和 HR 端都可以引用)
+> - Axios interceptor 写一次就能用在两个项目中
+> - 如果某个页面实在写不完,先用 `console.log` 占位,确保核心功能优先上线
+
+## 六、阶段五:前后端联调与自测
+
+> [!note] 进度追踪
+> - [ ] T5.1 全链路跑通:注册 → 登录 → 岗位发布 → 浏览
+> - [ ] T5.2 OSS 签名上传端到端测试(候选人端直传)
+> - [ ] T5.3 AI 对话完整流程测试(提问 → 数据查询 → 回答 → 历史加载)
+> - [ ] T5.4 权限隔离测试(非创建者操作他人岗位应被拒绝)
+> - [ ] T5.5 文件格式拦截测试(上传图片/TXT应为非法)
+> - [ ] T5.6 游客 vs 登录态功能边界测试
+> - [ ] T5.7 Bug 修复与体验优化
+
+### 6.1 详细任务清单
+
+| 编号 | 任务 | 前置依赖 | 耗时估计 |
+|------|------|----------|----------|
+| T5.1 | 全链路跑通:注册 → 登录 → 岗位发布 → 浏览 | T4.Z.3 | 2h |
+| T5.2 | OSS 签名上传端到端测试(候选人端直传) | T5.1 | 2h |
+| T5.3 | AI 对话完整流程测试(提问 → 数据查询 → 回答 → 历史加载) | T5.1 | 2h |
+| T5.4 | 权限隔离测试(非创建者操作他人岗位应被拒绝) | T5.1 | 1h |
+| T5.5 | 文件格式拦截测试(上传图片/TXT应为非法) | T5.2 | 1h |
+| T5.6 | 游客 vs 登录态功能边界测试 | T5.1 | 1h |
+| T5.7 | Bug 修复与体验优化 | T5.1 ~ T5.6 | 2h |
+
+### 6.2 验收 Checklist
+
+对照以下每一项逐项打勾:
+
+- [ ] gRPC 分层:Web 与 Logic 之间全部通过 gRPC 调用,无同工程内部函数调用
+- [ ] OSS 签名 URL:文件不落地本地,客户端直传 OSS
+- [ ] Eino 框架:使用了 Eino 封装的 Chat,非裸写 HTTP
+- [ ] JWT 鉴权:HR 只能管理自己发布的岗位,候选人未完善资料不可投递
+- [ ] AI 对话持久化:每条问答对入 MySQL,刷新页面自动加载历史上下文
+- [ ] 双端前端独立运行:hr-frontend 和 user-frontend 各自 `npm run dev` 均可启动
+- [ ] 四个源码目录完整、三个文档文件齐全
+
+## 七、阶段六:视频录制与提交
+
+> [!note] 进度追踪
+> - [ ] T6.1 撰写 answer.md(拓展设计方案:OpenClaw/Hermes 集成思路)
+> - [ ] T6.2 完善 README.md(启动部署指南 + 项目亮点)
+> - [ ] T6.3 完善 api.md(前后端接口说明)
+> - [ ] T6.4 完善 db.md(数据库设计文档补充)
+> - [ ] T6.5 整理代码仓库(检查 .gitignore、清理临时文件)
+> - [ ] T6.6 录制演示视频(原生实操、口述两大核心技术点)
+> - [ ] T6.7 提交至 KDoc 表单
+
+### 7.1 详细任务清单
+
+| 编号 | 任务 | 耗时估计 |
+|------|------|----------|
+| T6.1 | 撰写 answer.md(拓展设计方案:OpenClaw/Hermes 集成思路) | 2h |
+| T6.2 | 完善 README.md(启动部署指南 + 项目亮点) | 1.5h |
+| T6.3 | 完善 api.md(前后端接口说明) | 1.5h |
+| T6.4 | 完善 db.md(数据库设计文档补充) | 1h |
+| T6.5 | 整理代码仓库(检查 .gitignore、清理临时文件) | 1h |
+| T6.6 | 录制演示视频(原生实操、口述两大核心技术点) | 2h |
+| T6.7 | 提交至 KDoc 表单 | 30min |
+
+### 7.2 视频录制脚本大纲
+
+| 时间段 | 内容 | 口述要点 |
+|--------|------|----------|
+| 0:00-0:30 | 开场 + 服务启动演示 | "我现在依次启动 Logic 服务和 Web 网关服务..." |
+| 0:30-2:00 | 候选人端操作流程 | "候选人浏览岗位、注册、完善档案、上传简历、投递..." |
+| 2:00-3:00 | 口述核心技术点①:gRPC 两层架构 | "Web 网关接收 HTTP 请求后,通过 gRPC 远程调用 Logic 服务...这两个是独立进程..." |
+| 3:00-4:00 | 口述核心技术点②:OSS 签名 URL | "简历文件先获取签名URL,然后客户端直传 OSS,服务端零缓存..." |
+| 4:00-5:30 | HR 管理端操作流程 | "HR 登录、发布岗位、查看候选人、AI 对话..." |
+| 5:30-6:30 | AI 对话演示 | "输入自然语言提问,后端查询 MySQL 真实数据,通过 Eino 推送大模型..." |
+| 6:30-7:00 | 总结与结尾 | 简要说明个人开发收获和优化方向 |
+
+> [!important] 视频硬性要求
+> - **禁止剪辑拼接**:一次录完,保证画面真实
+> - **全程配语音解说**:不能无声黑屏
+> - **必录两大技术点口述**:gRPC 分层调用逻辑、OSS 签名 URL 安全上传下载
+> - **文件命名**:`姓名_学号_全栈大作业.mp4`
+
+## 八、风险识别与应对
+
+| 风险 | 影响范围 | 概率 | 应对措施 |
+|------|----------|------|----------|
+| OSS 平台注册审核慢 | 整个项目 | 中 | 提前注册,使用 MinIO 本地替代方案作为兜底 |
+| Eino 框架学习成本超预期 | AI 对话模块 | 中 | 官方文档优先,只使用基础 Chat 组件,不做复杂编排 |
+| Protobuf 字段频繁变更 | 前后端同步 | 高 | Proto 先行、冻结接口后再写业务代码;做好注释 |
+| gRPC 调试困难 | 后端通信 | 中 | 启用 grpcurl 命令行工具进行 RPC 调用调试 |
+| 前端样式适配耗时过长 | UI 呈现 | 中 | 使用现成 UI 组件库(Ant Design),不自研 CSS |
+| JWT Token 过期处理遗漏 | 用户体验 | 低 | 初期可不实现自动刷新,手动重新登录即可 |
+
+## 九、里程碑检查点
+
+> [!note] 里程碑进度追踪
+> 点击以下复选框标记里程碑完成情况:
+> - [ ] **M0** — 环境就绪
+> - [ ] **M1** — Proto 冻结
+> - [ ] **M2** — 后端可独立运行
+> - [ ] **M3** — 双端前端完成
+> - [ ] **M4** — 全链路联调通过
+> - [ ] **M5** — 视频已录制
+> - [ ] **M6** — 正式提交
+
+```mermaid
+graph LR
+ M0["环境就绪
阶段一"] --> M1["Proto 冻结
阶段二"]
+ M1 --> M2["后端可独立运行
阶段三"]
+ M2 --> M3["双端前端完成
阶段四"]
+ M3 --> M4["全链路联调通过
阶段五"]
+ M4 --> M5["视频已录制
待提交"]
+ M5 --> M6["正式提交
KDoc表单"]
+
+ classDef done fill:#c8e6c9;
+ classDef current fill:#fff9c4;
+ classDef future fill:#eceff1;
+ class M0,M1,M2,M3,M4 done;
+ class M5 current;
+ class M6 future;
+```
+
+## 关联笔记
+
+- [[大作业项目要求]] — 作业完整需求文档
+- [[思维导图]] — 双端 gRPC HR 系统的知识体系与核心考点
+- [[工程结构]] — 详细的仓库目录结构与代码组织
diff --git a/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md b/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md
new file mode 100644
index 0000000..7b0e32e
--- /dev/null
+++ b/hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md
@@ -0,0 +1,309 @@
+---
+tags: [gRPC, Protobuf, proto, message, enum, oneof, map, IDL]
+create time: 2026-05-11 16:40
+---
+
+# Protobuf 语法与消息定义
+
+## 概述
+
+gRPC 使用 Protobuf(Protocol Buffers)作为接口定义语言(IDL)。所有 gRPC 服务契约都以 `.proto` 文件编写——这是你的 API「蓝图」,任何调用方、服务端都从这里生成代码。**学会写 `.proto` 文件,你就拿到了整个 gRPC 体系的入场券。**
+
+> [!question] 为什么选 Protobuf 而不是 JSON Schema?
+> JSON Schema 描述的是数据格式,但不提供序列化协议和跨语言代码生成能力。Protobuf 则是一套完整的 IDL:它定义了数据结构、wire format、序列化规则,并且为多语言自动生成强类型 Stub。对于内部微服务通信,这意味着**契约即代码**,编译期就能发现类型不匹配。
+
+## .proto 文件骨架
+
+一个 `.proto` 文件由若干顶层声明组成。先看完整骨架:
+
+```protobuf
+syntax = "proto3"; // ① 版本声明
+
+package user.v1; // ② 包名(命名空间)
+option go_package = "github.com/example/svc/user/v1;v1"; // ③ Go 输出路径
+
+import "google/protobuf/timestamp.proto"; // ④ 引用外部 Proto
+
+message GetUserRequest { // ⑤ 消息
+ string id = 1;
+}
+
+message GetUserResponse { // ⑤ 消息
+ User user = 1;
+}
+
+message User { // ⑤ 消息
+ string id = 1;
+ string name = 2;
+ google.protobuf.Timestamp created_at = 3;
+}
+
+service UserService { // ⑥ RPC 服务
+ rpc GetUser(GetUserRequest) returns (GetUserResponse);
+ rpc ListUsers(ListUsersRequest) returns (stream User);
+}
+```
+
+> [!note] proto2 vs proto3
+> - **proto3** 是当前默认版本,移除了 `required`/`optional` 字段修饰符、枚举必须从 0 开始等限制,语法更简洁。
+> - **proto2** 仍被部分遗留系统使用,支持更完整的特性如 `required`/`optional`、manually-implemented map field 等。
+> - **新项目一律使用 `syntax = "proto3"`**。除非你在维护十年前的遗留服务,否则没有理由用 proto2。
+
+### 核心组成部分速查
+
+| 声明 | 作用 | 是否必需 |
+|------|------|----------|
+| `syntax` | 指定 Protobuf 版本 | ✅ |
+| `package` | 命名空间隔离,避免名称冲突 | ⚠️ 推荐 |
+| `option go_package` | Go 生成的包路径和导出前缀 | ✅ Go 项目必需 |
+| `import` | 引用其他 `.proto` 文件 | ❌ |
+| `message` | 定义结构化数据类型 | ❌ |
+| `enum` | 定义枚举类型 | ❌ |
+| `service` / `rpc` | 定义远程调用接口 | ❌(纯数据 Proto 不需要) |
+
+## Message 消息结构
+
+Message 是 Protobuf 中最基本的结构化类型,对应 Go 的 `struct`:
+
+```protobuf
+message LoginRequest {
+ string username = 1; // 用户名
+ string password = 2; // 密码(生产环境走 TLS 加密通道)
+ bool remember_me = 3; // 记住登录状态
+}
+
+message LoginResponse {
+ string token = 1; // JWT Token
+ int64 expire_at = 2; // 过期时间戳(Unix seconds)
+ User profile = 3; // 嵌套消息
+}
+
+message User {
+ string id = 1; // UUID 格式
+ string name = 2; // 显示名称
+ string email = 3; // 邮箱地址
+ int32 age = 4; // 年龄
+ bool active = 5; // 是否活跃
+ repeated string roles = 6; // 角色列表(repeated 详见 [02-数据类型详解](./02-数据类型详解.md))
+}
+```
+
+每个字段包含三部分:**类型 + 字段名 + tag number**。tag number 是字段在二进制 wire format 中的唯一标识——**一旦分配就不会再变**。后续讨论兼容性时会深入理解它的重要性。
+
+> [!tip] tag number 分配原则
+> 1. 从 1 开始连续编号,不要跳号
+> 2. 预留编号区间给未来可能新增的字段(如保留 1-99 给常用字段,100+ 给扩展字段)
+> 3. 已使用的编号永远不要重用或删除 —— 这会导致序列化数据解析错乱
+> 4. 具体规则参见 [03-字段编号与前向兼容](./03-字段编号与前向兼容.md)
+
+> [!question] 为什么不用 JSON 那样的"无编号"设计?
+> tag number 的核心价值在于**向后兼容**:当你新增字段时,老版本客户端遇到未知的 tag number 会直接跳过该字节块继续解析。如果没有编号,你只能换字段名 —— 但改了名就是 breaking change。Protobuf 的二进制设计让它在小体积、高性能之余,还能优雅地处理版本演进。
+
+### 字段的默认值行为
+
+proto3 中所有字段都有明确的默认值:
+
+| 类型 | 默认值 |
+|------|--------|
+| string | `""`(空串) |
+| bytes | 空字节序列 |
+| bool | `false` |
+| numeric (int32, uint64, double…) | `0` 或 `0.0` |
+| enum | 值为 `0` 的那个枚举值 |
+| message | 返回"默认实例"(Go 中为零值 struct) |
+| repeated | 空列表(Go 中为 nil slice) |
+| map | nil map(Go 中为 nil) |
+
+```go
+// Go 中读取默认值 — 无法区分"未设置"和"显式设为零值"
+var req LoginRequest
+fmt.Println(req.Username) // "" — 到底是没传还是传了 ""?
+```
+
+**这就是 proto3 最著名的陷阱:客户端读不到"未设置"和"设为零值"的区别。** 解决之道是在需要使用包装类型时用 Wrapper Types,详情见 [02-数据类型详解](./02-数据类型详解.md)。
+
+## Enum 枚举类型
+
+枚举用于定义一组命名的整数值:
+
+```protobuf
+enum Role {
+ ROLE_UNSPECIFIED = 0; // 未指定(proto3 要求第一个值为 0)
+ ROLE_ADMIN = 1; // 管理员
+ ROLE_EDITOR = 2; // 编辑者
+ ROLE_VIEWER = 3; // 只读者
+}
+
+enum Status {
+ STATUS_OFFLINE = 0; // 离线
+ STATUS_ONLINE = 1; // 在线
+ STATUS_BUSY = 2; // 忙碌
+ STATUS_AWAY = 3; // 离开
+}
+
+message User {
+ string id = 1;
+ string name = 2;
+ Role role = 3; // 引用枚举类型
+ Status status = 4; // 引用枚举类型
+}
+```
+
+> [!warning] 枚举铁律
+> 1. **第一个枚举值必须是 0**(通常以 `_UNSPECIFIED` 或 `_UNKNOWN` 结尾),proto3 强制要求
+> 2. 新增枚举值是向后兼容的,但旧版本客户端收到未知枚举值时会回退到 0(即第一个值)
+> 3. **不要删除已有枚举值的编号**,否则可能引发不可预期的兼容问题
+> 4. 枚举值可以打同一个数值做 alias,但需要在 enum 选项里声明 `allow_alias = true`
+
+## Oneof 排他选择
+
+当多个字段互斥、每次请求只能填其中一个时,使用 `oneof`:
+
+```protobuf
+message UpdateProfileRequest {
+ string id = 1;
+
+ oneof update_field {
+ string name = 2;
+ string email = 3;
+ Role role = 4;
+ }
+}
+```
+
+这样保证了 `Name`、`Email`、`Role` 三个字段在序列化时只有一个会出现,节省带宽且语义清晰。在 Go 生成的代码中,oneof 会变成一个接口类型:
+
+```go
+type UpdateProfileRequest struct {
+ Id string
+ // 只能设置其中之一
+ UpdateField isUpdateProfileRequest_UpdateField
+}
+
+switch req.UpdateField.(type) {
+case *UpdateProfileRequest_Name:
+ fmt.Println("更新了 name:", req.Name)
+case *UpdateProfileRequest_Email:
+ fmt.Println("更新了 email:", req.Email)
+}
+```
+
+> [!question] oneof vs 单独字段?什么时候该用 oneof?
+> 如果你希望业务逻辑保证「每次请求只更新一个字段」,用 oneof 可以让编译器帮你 enforcing 这个约束。但如果只是"几个可选字段可能同时出现"的场景,反而应该用单独的 field —— oneof 会增加代码复杂度(需要 switch/case 判断哪个被设置了)。**本质区别:oneof 表达的是"二选一或多选一"的互斥关系。**
+
+## Map 键值映射
+
+Protobuf 原生支持 key-value 映射,key 只能是整数或字符串类型:
+
+```protobuf
+message UserProfile {
+ string id = 1;
+
+ // 标签映射:string → string
+ map tags = 2;
+
+ // 统计映射:string → int32
+ map login_count_by_day = 3;
+}
+```
+
+在 Go 中生成的对应类型为 `map[string]string`,**注意默认为 nil(而非空 map)**。如果需要确保非 nil,可以用 `repeated` + key-value message 替代。
+
+## Reserved 保留字段
+
+当你的 proto 文件 evolve 到新版本,可能需要移除某个字段。**但不能简单地删除——因为旧版本的客户端可能还在发送带有该字段编号的数据,新服务器解析时会把它塞进下一个字段里。** `reserved` 关键字就是为此而生:
+
+```protobuf
+message User {
+ reserved 7, 11; // 保留单个编号
+ reserved 9 to 13; // 保留编号区间
+ reserved "username", "telephone"; // 保留字段名
+
+ string id = 1;
+ string name = 2;
+ string nick = 8; // 7 不能用了,这里只能用 >= 14 的编号
+}
+```
+
+> [!example] 典型场景:用户表迭代
+> v1: `message User { string username = 1; string email = 2; string phone = 3; }`
+>
+> v2: 业务发现 `username` 改名了,决定删除并保留编号:
+> ```protobuf
+> message User {
+> reserved 1; // 告诉 protoc:1 号编号作废
+> string id = 1; // 重新用编号 1 放 id
+> string email = 2;
+> string nickname = 3; // 新的昵称字段
+> }
+> ```
+>
+> 这样如果 v1 客户端发来 `username` 的数据(tag=1),protoc 会自动丢弃而不会错误地填入 `id` 字段。
+
+> [!tip] reserved 最佳实践
+> 1. 删除字段时,同时记录被删字号的**原因注释**(可以在 git commit message 里说明,也可以加一行 `// reserved: replaced by xxx at YYYY-MM-DD`)
+> 2. 不要把正在使用的编号标记为 reserved——编译不过就是最大的提示
+> 3. 具体兼容策略参见 [03-字段编号与前向兼容](./03-字段编号与前向兼容.md)
+
+## Package 与 Import
+
+Protobuf 的 `package` 机制类似于 Go 的 import path,提供命名空间隔离:
+
+```protobuf
+// file: user/v1/user.proto
+package user.v1;
+
+import "google/protobuf/timestamp.proto"; // Well-Known Type
+
+message User {
+ string id = 1;
+ string name = 2;
+ google.protobuf.Timestamp created_at = 3;
+}
+
+// file: order/v1/order.proto
+package order.v1;
+
+import "user/v1/user.proto"; // 引用 user 包的 message
+
+message Order {
+ string id = 1;
+ user.v1.User buyer = 2; // 跨包引用
+ int64 amount_cents = 3;
+}
+```
+
+> [!tip] import 路径约定
+> `import "user/v1/user.proto"` 中的路径应当与文件的实际磁盘路径一致(相对于 `protoc -I` 参数指定的目录)。保持一致性是关键。
+
+## 构建流程总览
+
+下图展示从 `.proto` 源文件到最终 Go Stub 的完整编译链:
+
+```mermaid
+flowchart TD
+ A[".proto 源文件"] --> B["protoc 编译器"]
+ B --> C["protoc-gen-go 插件"]
+ B --> D["protoc-gen-go-grpc 插件"]
+ C --> E["pb.go — 消息结构体"]
+ D --> F["_grpc.go — client/server stub"]
+ E --> G["业务层调用 Client / Server"]
+ F --> G
+
+ style A fill:#EAB308,color:#fff
+ style B fill:#3B82F6,color:#fff
+ style C fill:#4FC08D,color:#fff
+ style D fill:#4FC08D,color:#fff
+ style E fill:#A0AEC0,color:#fff
+ style F fill:#A0AEC0,color:#fff
+```
+
+> [!info] 工具链细节
+> `protoc` 负责解析 `.proto` 语法树,各类插件将其翻译成目标语言的代码。Go 生态需要两个插件协同工作:`protoc-gen-go` 生成消息结构体,`protoc-gen-go-grpc` 生成 gRPC 客户端和服务端 Stub。具体配置方法参见 [17-protoc 工具链与 Makefile](../6.%20工程实践篇/17-protoc%20工具链与%20Makefile.md)。
+
+## 关联笔记
+
+- [[hhs/gRPC/README]] — gRPC 知识库全景索引
+- [[hhs/gRPC/1. Protobuf 基础篇/02-Protobuf 数据类型详解]] — Scalar、Wrapper、Well-Known、Repeated 详细对照
+- [[hhs/gRPC/1. Protobuf 基础篇/03-Protobuf 字段编号与前向兼容]] — Field Number 分配规则、Reserved、版本演进策略
+- [[hhs/gRPC/1. Protobuf 基础篇/04-Protobuf Oneof 与包装类型]] — Oneof 高级用法、Google.Protobuf.Value、Any 泛型封装
diff --git a/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md b/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md
new file mode 100644
index 0000000..37947c5
--- /dev/null
+++ b/hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md
@@ -0,0 +1,418 @@
+---
+tags: [gRPC, Protobuf, scalar types, wrapper types, WKT, repeated, packed, oneof, map, wire encoding]
+create time: 2026-05-11 16:40
+---
+
+# 数据类型详解
+
+## 概述
+
+Protobuf 的类型系统看起来简单,但有很多容易被忽略的细节:`optional`/`required` 的区别、packed vs unpacked repeated 编码差异、以及 Well-Known Types 的威力。这篇帮你把常见坑一次性踩完。
+
+> [!question] 为什么 Protobuf 没有 required?
+> 早期的 proto3 移除了 `required`/`optional` 关键字,因为工程实践中很难真正验证——服务端删除了字段后,客户端无法区分"字段没传"和"服务端没设值"。**如果需要保证某个字段一定存在,该用什么方式替代?** 提示:见文末 `oneof` 用法。
+
+## Protobuf 类型体系一览
+
+在深入每个类型之前,先看全貌:
+
+```mermaid
+graph TD
+ A["Protobuf 类型系统"] --> B["Scalar Types\n标量类型"]
+ A --> C["Composite Types\n复合类型"]
+ A --> D["Well-Known Types\n内置类型"]
+
+ B --> B1["整数系: int32 / int64 / uint32 / uint64 / sint32 / sint64"]
+ B --> B2["浮点系: float / double"]
+ B --> B3["其他: bool / string / bytes"]
+
+ C --> C1["repeated\n(动态列表)"]
+ C --> C2["map\n(键值对)"]
+ C --> C3["message\n(自定义结构)"]
+ C --> C4["oneof\n(互斥字段)"]
+
+ D --> D1["Timestamp\n(time.Time)"]
+ D --> D2["Duration\n(time.Duration)"]
+ D --> D3["StringValue\n(*string 指针)"]
+ D --> D4["Any / Value / Struct\n(通用 JSON)"]
+ D --> D5["FieldMask\n(partial update)"]
+```
+
+### proto2 vs proto3 关键差异
+
+| 特性 | proto2 | proto3 |
+|------|--------|--------|
+| `required` / `optional` | 支持 | ❌ 移除(proto3 用默认零值语义) |
+| `enum default value` | 不允许 0 以外的默认值 | ✅ 允许任意枚举值作为默认 |
+| map | ❌ 不支持 | ✅ 原生支持 |
+| repeated packed | 需显式声明 `[packed = true]` | ✅ 数字类型默认 packed |
+| `Has()` 判断 | 自动生成 | ❌ 不再为 scalar 生成(wrapper type 替代) |
+
+> [!tip] proto3 的 optional 回来了!
+> 虽然 proto3 最初去掉了 optional,但从 **protobuf 3.12+** 开始重新引入了 `optional` 关键字,不过它仍然受 wire compatibility 限制——加上 optional 后会改变 field number 的行为,所以生产环境中更推荐用 **wrapper types**。
+
+## Scalar Types 标量类型
+
+Protobuf 提供了一套语言无关的标量类型,每种都有确定的 wire encoding。选型的核心原则是:**在保证正确性的前提下,选最小的类型**。
+
+| Protobuf 类型 | Go 生成类型 | Wire Encoding | 说明 |
+|---------------|------------|---------------|------|
+| `double` | `float64` | 8 bytes | 双精度浮点 |
+| `float` | `float32` | 4 bytes | 单精度浮点 |
+| `int32` | `int32` | varint | **最常用**,小整数高效编码 |
+| `int64` | `int64` | zigzag varint | 大整数或时间戳 |
+| `uint32` | `uint32` | varint | 无符号 32 位 |
+| `uint64` | `uint64` | varint | 无符号 64 位 |
+| `sint32` | `int32` | zigzag varint | 有符号整数,负数编码更小 |
+| `sint64` | `int64` | zigzag varint | 同上,64 位 |
+| `fixed32` | `uint32` | 4 bytes | 固定 4 字节,适合频繁序列化的场景 |
+| `fixed64` | `uint64` | 8 bytes | 固定 8 字节 |
+| `sfixed32` | `int32` | 4 bytes | 有符号固定 4 字节 |
+| `sfixed64` | `int64` | 8 bytes | 有符号固定 8 字节 |
+| `bool` | `bool` | varint (0/1) | — |
+| `string` | `string` | len-delimited | UTF-8 编码 |
+| `bytes` | `[]byte` | len-delimited | 任意二进制数据 |
+
+### 性能选型建议
+
+下面展示两个典型场景:
+
+```go
+// ❌ 不推荐:盲目使用 int64 增加序列化体积
+// 每个 int64 可能占用 10+ bytes(varint 随数值增长)
+message Request {
+ int64 user_id = 1; // 2^31 ≈ 21 亿,99% 的用户 ID 不会超过
+ int64 amount = 2; // float 存金额会丢失精度,且编码更大
+}
+
+// ✅ 推荐:按实际范围选型
+message Request {
+ int32 user_id = 1; // 大多数用户 ID < 2^31
+ int32 amount_cents = 2; // 以"分"为单位存,避免 float,更节省
+}
+
+// ⭐ 极端优化:正负波动且范围小的场景
+message Offset {
+ sint32 delta = 1; // zigzag 编码,-1 只占 1 byte(int32 需 5 byte)
+}
+```
+
+上面的代码对应三种策略:
+1. **默认选择 `int32`**:覆盖 ±21 亿的范围,对于 ID、计数等绝大多数场景足够。
+2. **金额用最小货币单位存为整数**:比如 `100` 代表 ¥1.00,避免 IEEE 754 精度损失。
+3. **`sint32` 用于小范围正负波动**:如 offset、delta,zigzag 编码让 `-1` 和 `1` 都只需 1 byte。
+
+> [!tip] float vs double 取舍
+> HTTP/2 + TLS 已经压缩了网络传输,**节省几个字节对延迟的影响微乎其微**。优先选择 `float32`,除非你的业务需要 IEEE 754 双精度精度(如金融计算)。
+
+### Varint 编码与 zigzag 的关系
+
+很多人分不清 varint 和 zigzag,这里简单拆解:
+
+```mermaid
+graph LR
+ A["原始整数"] --> B{"是否为负数?"}
+ B -- 否 --> C["varint: 每 7 bits 一组, MSB 标记 continuation"]
+ B -- 是 --> D["zigzag: n → (n << 1) ^ (n >> 31)"]
+ D --> C
+ C --> E["变长字节序列: 小数字仅 1 byte"]
+```
+
+- **varint**:只处理非负数,数字越小占的字节越少。`1` 占 1 byte,`2^31` 占 5 bytes。
+- **zigzag**:将有符号整数映射为非负数,公式 `(n << 1) ^ (n >> 31)`,让 `-1` 变成 `1`,`-2` 变成 `3`,从而也能用 varint 紧凑编码。
+
+## Repeated 与 Packed
+
+`repeated` 字段表示一个动态长度的列表。在 proto3 中,numeric 类型的 repeated 默认采用 **packed encoding**(打包编码),非 numeric 类型(如 string、message)只能是 unpacked:
+
+```protobuf
+message TagList {
+ repeated string tags = 1; // string 类型无法 packed,总是 len-delimited
+ repeated int32 scores = 2; // int32 默认 packed
+ repeated float32 weights = 3; // float 默认 packed(4-byte fixed)
+}
+```
+
+### Packed Encoding 原理与对比
+
+考虑一组 `repeated int32` 字段 `[1, 2, 3]`,两种编码方式的 wire format 对比:
+
+```mermaid
+block
+ column "Unpacked (legacy)"
+ B1["tag(1B)"] B2["val 1(1B)"] B3["tag(1B)"] B4["val 2(1B)"] B5["tag(1B)"] B6["val 3(1B)"]
+ style B1 fill:#f9d
+ style B3 fill:#f9d
+ style B5 fill:#f9d
+ note1["重复写 tag\n共 6 bytes"]
+
+ column "Packed (proto3 默认)"
+ C1["tag(1B)"] C2["len(1B)"] C3["val 1(1B)"] C4["val 2(1B)"] C5["val 3(1B)"]
+ style C1 fill:#9df
+ style C2 fill:#9df
+ style C3 fill:#dfd
+ style C4 fill:#dfd
+ style C5 fill:#dfd
+ note2["只写一次 tag\n共 5 bytes"]
+```
+
+随着元素数量增长,差距越来越明显:
+
+| 元素数量 | Unpacked | Packed | 节省比例 |
+|---------|----------|--------|---------|
+| 3 | 6B | 5B | 17% |
+| 10 | 20B | 12B | 40% |
+| 100 | 200B | 109B | 46% |
+| 1000 | 2000B | 1037B | 48% |
+
+> [!note] 手动关闭 packed
+> 如果出于兼容性考虑需要关闭 packed,可以在 proto2 中使用:
+> ```protobuf
+> repeated int32 scores = 1 [packed = false]; // proto2 语法
+> ```
+> proto3 不允许此属性(必须 packed)。
+
+## Wrapper Types 包装类型
+
+Proto3 移除了 `required` 后,引入了 `google.protobuf.*_wrapper` 类型来区分「未设置」和「零值」:
+
+```protobuf
+import "google/protobuf/wrappers.proto";
+
+message UserUpdate {
+ string id = 1;
+ google.protobuf.StringValue display_name = 2; // 可选的字符串
+ google.protobuf.BoolValue is_active = 3; // 可选的布尔值
+ google.protobuf.Int32Value age = 4; // 可选的整数
+ google.protobuf.FloatValue height_cm = 5; // 可选的浮点数
+}
+```
+
+在 Go 生成的代码中,wrapper 类型生成的是**指针**:
+
+```go
+type UserUpdate struct {
+ Id string
+ DisplayName *string // nil = 未设置;"" = 明确设为空串
+ IsActive *bool // nil = 未设置;*false = 明确设为 false
+ Age *int32 // nil = 未设置;0 = 明确设为 0
+}
+```
+
+这样就能清晰表达三种状态:**没传这个字段**(nil)、**传了但值是零**(指向零值的指针)、**传了正常值**(指向非零值的指针)。
+
+### 原始类型 vs Wrapper 类型对比
+
+| 场景 | 原始类型 `string` | Wrapper `StringValue` |
+|------|-------------------|----------------------|
+| Go 零值 | `""`(与"未设置"无法区分) | `nil`(清晰表达缺失) |
+| JSON 序列化 | `"name": ""` | `"name": null` 或省略 |
+| 判断是否传值 | 需要额外逻辑 | `if v != nil` 即可 |
+| wire 大小 | 同左 | 同左(额外一层 wrapper overhead ≈ 0) |
+
+> [!warning] Wrapper 不是银弹
+> 不要把所有字段都用 wrapper。只有在 **你需要区分"未设置"和"零值"** 时才用 wrapper,否则会增加 nil-check 的心智负担。
+
+### 哪些 Wrapper 可用
+
+Protobuf 提供了所有标量类型的 wrapper,Go 中一一对应:
+
+| Wrapper Type | Go 指针类型 | 典型用途 |
+|-------------|-----------|---------|
+| `StringValue` | `*string` | 可选文本 |
+| `BoolValue` | `*bool` | 可选开关 |
+| `Int32Value` | `*int32` | 可选小整数 |
+| `Int64Value` | `*int64` | 可选大整数 / 时间戳 |
+| `FloatValue` | `*float32` | 可选浮点 |
+| `DoubleValue` | `*float64` | 可选双精度 |
+| `BytesValue` | `*[]byte` | 可选二进制数据 |
+
+## Well-Known Types
+
+Protobuf 内置了一组通用的消息类型,称为 Well-Known Types(WKT),全部定义在 `google/protobuf/` 下。它们在不同语言中有各自的 native 映射,是实现跨语言兼容的关键。
+
+核心 WWT 分类如下:
+
+```mermaid
+graph LR
+ A["Well-Known Types"] --> B["日期/时间\nTimestamp / Duration"]
+ A --> C["可选包装\nWrapper Types × 7"]
+ A --> D["泛型/动态\nAny / Value / Struct"]
+ A --> E["实用工具\nFieldMask / Empty / ..."]
+```
+
+### 时间相关:Timestamp & Duration
+
+```protobuf
+import (
+ "google/protobuf/timestamp.proto"
+ "google/protobuf/duration.proto"
+)
+
+message Task {
+ string title = 1;
+ google.protobuf.Timestamp deadline = 2; // 绝对时间点
+ google.protobuf.Duration timeout = 3; // 相对时长
+}
+```
+
+在 Go 端,这两个类型直接映射为 `time.Time` 和 `time.Duration`,无需手动转换:
+
+```go
+task := &pb.Task{
+ Title: "发布版本",
+ Deadline: timestamppb.Now(), // 自动转当前 time.Time
+ Timeout: durationpb.New(30*time.Second), // 自动转 30s
+}
+```
+
+> [!important] Timestamp 的序列化差异
+> 在 JSON 映射中,`Timestamp` 默认序列化为 `RFC3339` 格式的 string:`"2026-05-11T08:30:00Z"`。但在 binary protobuf 中,它是两个 int64:seconds + nanoseconds。**跨语言调用时需确保对方也理解这种语义**。
+
+### FieldMask:精准 Partial Update
+
+`FieldMask` 是 gRPC 生态中最被低估的 WKT 之一。配合 `google.golang.org/protobuf/proto` 提供的 `ApplyFieldMask` 函数,可以实现精准的增量更新:
+
+```protobuf
+import "google/protobuf/field_mask.proto";
+
+message UserPatchRequest {
+ google.protobuf.FieldMask update_mask = 1; // ["display_name", "email"]
+ User user = 2;
+}
+```
+
+```go
+// 服务器端:只对 mask 中指定的字段做更新
+updatedUser := &existingUser
+proto.ApplyFieldMask(&updatedUser, req.GetUser())
+```
+
+JSON 传递时也很简洁:`{ "updateMask": "display_name,email", "user": { "display_name": "新名字" } }`。
+
+### Any:泛型消息容器
+
+`Any` 允许你在不知道具体消息类型的情况下传递消息,常用于事件总线或插件架构:
+
+```protobuf
+import "google/protobuf/any.proto";
+
+message Event {
+ google.protobuf.Any payload = 1; // 任意 protobuf message
+}
+```
+
+反序列化时需要注册 type registry:
+
+```go
+// 注册已知类型
+ptypes.RegisterAnyType(reflect.TypeFor[OrderCreated]())
+
+// 从 Any 中提取具体类型
+event := &Event{}
+payload, _ := ptypes.UnmarshalAny(event.Payload)
+```
+
+> [!danger] 谨慎使用 Any
+> `Any` 绕过了静态类型检查,滥用会导致调试困难。只在**真正的扩展点**(如插件系统、事件溯源)使用,不要用它来替代正常的消息设计。
+
+## Map 类型细节
+
+Map 在 wire format 中被编码为 `repeated key_value message`,底层实现其实就是一个 repeated:
+
+```protobuf
+message UserPreferences {
+ map theme_settings = 1;
+ map role_permissions = 2;
+}
+```
+
+关键行为:
+- **迭代顺序不保证**:JSON/binary 序列化后顺序不可预测,不能依赖顺序做比较。
+- **不能有嵌套 map**:`map>` 非法。
+- **key 只能是整数或字符串**,不支持 message 类型作为 key。
+- Go 中初始值为 `nil`(而非 `make(map[string]string)`),使用前需判空或初始化。
+
+### Map vs Message + repeated
+
+当需要额外元数据时,map 就不够用了,需要改用 message + repeated:
+
+```protobuf
+// ❌ map 只能存 key-value,无法携带额外信息
+message Bad {
+ map roles = 1;
+}
+
+// ✅ 用 message 承载完整信息
+message Good {
+ message RoleMapping {
+ string role = 1;
+ string permission = 2;
+ }
+ repeated RoleMapping mappings = 1;
+}
+```
+
+## Optional 与 Oneof
+
+回到开头的问题:proto3 没有 `required`,如何保证字段一定存在?
+
+**方案一:Wrapper Type**(见上文)— 适合"可选但可零值"的场景。
+
+**方案二:Oneof** — 适合"多个字段中必须有且仅有一个"的场景:
+
+```protobuf
+message PaymentRequest {
+ string order_id = 1;
+
+ oneof payment_method {
+ string alipay_token = 2;
+ string wechat_pay_nonce = 3;
+ string bank_card_number = 4;
+ }
+}
+```
+
+在 Go 生成的代码中,oneof 会生成一个接口来标识哪个字段被设置了:
+
+```go
+// Go 端生成的 interface
+type PaymentRequest_PaymentMethod interface {
+ isPaymentRequest_PaymentMethod()
+}
+```
+
+使用时通过类型断言判断:
+
+```go
+switch req.GetPaymentMethod().(type) {
+case *PaymentRequest_AlipayToken:
+ // 走支付宝
+case *PaymentRequest_WechatPayNonce:
+ // 走微信支付
+default:
+ // 错误:payment method 未设置
+}
+```
+
+> [!example] Oneof 的实际应用场景
+> - **多态请求参数**:搜索时可以按关键词、ID 或模糊匹配,三者选一
+> - **协议切换**:同一个连接支持多种子协议
+> - **互斥配置**:比如渲染模式只能选一种(WebGL / Canvas / SVG)
+
+## 最佳实践总结
+
+- **优先使用 `int32`**,除非确定数据范围超过 ±21 亿才用 `int64`。
+- **金额相关用整型存储最小货币单位**(如 cents),永远不要用 `float` 存钱。
+- **需要表达"可选"时优先考虑 wrapper types**,比 oneof 更简洁,比裸 scalar 更能区分零值和缺失。
+- **timestamp 统一用 RFC3339 string**,跨语言互通性最好。
+- **sint32/sint64** 仅在小范围内有正负波动的场景(如 offset、delta)中使用。
+- **oneof 用在"多选一"的互斥场景**,而不是用来模拟 optional。
+- **FieldMask 是实现 RESTful PATCH 语义的神器**,别自己解析 JSON 路径了。
+- **慎用 Any**,只在真正的扩展点使用,避免绕过类型安全。
+
+## 关联笔记
+
+- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 语法入门,建议先读本篇再来看本文
+- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容]] — 字段编号管理、向前向后兼容规则
+- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 深度使用 + Wrapper Type 实战模式
diff --git a/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md b/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md
new file mode 100644
index 0000000..4ac2264
--- /dev/null
+++ b/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md
@@ -0,0 +1,306 @@
+---
+tags: [gRPC, Protobuf, field number, reserved, backward compatibility, forward compatibility, versioning]
+create time: 2026-05-11 16:40
+---
+
+# 字段编号与前向兼容
+
+## 概述
+
+每一个字段都有一个 tag number,这行简单的数字背后藏着 Protobuf 最核心的设计原则:**向后兼容**。理解这套机制,你就能放心地修改 protobuf schema 而不用担心打坏线上服务。
+
+> [!note] 核心概念速记
+> - **向后兼容**(Old → New):旧版客户端跑新版服务端返回的数据 — Protobuf 保证**未知字段被安全忽略**
+> - **向前兼容**(New → Old):新版客户端跑旧版服务端返回的数据 — 缺失字段取**类型默认值**
+
+> [!warning] 注意
+> 一旦字段编号被分配并部署,就**永远不能再复用**它。这是 Protobuf 的硬伤——编号就像 UUID,一旦发出去就是它的了。
+
+## Tag Number 分配规则
+
+每个字段的 tag number(通常称为 field number)取值范围为 **1 ~ 536,870,911**(即 `2^29 - 1`)。这个范围不是随机的:
+
+```protobuf
+message User {
+ string id = 1; // 核心标识符,高频使用 → 留给 1~15
+ string name = 2;
+ string email = 3;
+ int32 age = 4;
+ bool active = 5;
+
+ string phone = 6;
+ string address = 7;
+ Role role = 8;
+
+ // ... 中间跳过一些编号供未来添加 ...
+ // (reserved 10 to 20)
+
+ google.protobuf.Timestamp created_at = 100;
+ google.protobuf.Timestamp updated_at = 101;
+ repeated string tags = 102;
+}
+```
+
+### Wire Encoding 原理解析
+
+Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少:
+
+| 编号范围 | Wire Encoding 大小 | 说明 |
+|---------|-------------------|------|
+| 1 ~ 15 | 1 byte | 黄金区间,预留给你的核心高频字段 |
+| 16 ~ 2047 | 2 bytes | 次优先区间 |
+| 2048+ | 3~5 bytes | 低频字段可放这里 |
+
+```mermaid
+flowchart LR
+ subgraph F1["Field Number = 3"]
+ A1["field_number = 3"] --> B1["<< 3"]
+ B1 --> C1["24"]
+ C1 --> D1["| wire_type 0"]
+ D1 --> E1["tag = 24 = 0x18"]
+ E1 --> F1["1 byte ✅"]
+ end
+
+ subgraph F2["Field Number = 100"]
+ A2["field_number = 100"] --> B2["<< 3"]
+ B2 --> C2["800"]
+ C2 --> D2["| wire_type 0"]
+ D2 --> E2["tag = 800 = 0x320"]
+ E2 --> F2["2 bytes ⚠️"]
+ end
+
+ style F1 fill:#d4edda
+ style F2 fill:#fff3cd
+```
+
+> [!example] 公式
+> `tag = (field_number << 3) | wire_type`
+> - `<< 3` 等价于 `field_number × 8`,把高 5 位留给 field number
+> - 低 3 位存放 wire type(0=varint, 1=64-bit, 2=length-delimited, ...)
+
+对于高频通信的消息体,节省 1 byte *per message* × 每秒百万调用 = 可观的带宽节省。这就是为什么建议 core fields 用 1~15。
+
+### 合理的 Field Numbering 策略
+
+```protobuf
+// user/v1/user.proto
+message User {
+ // === Core fields (1-9): 核心字段,几乎每次都会序列化 ===
+ string id = 1;
+ string name = 2;
+ string email = 3;
+
+ // === Secondary fields (10-19): 常用但非必需 ===
+ string phone = 10;
+ string avatar_url = 11;
+ Role role = 12;
+ bool active = 13;
+
+ // === Tertiary fields (20-99): 偶尔使用 ===
+ string bio = 20;
+ string website = 21;
+ Location location = 22;
+
+ // === Audit & metadata (100-199): 系统字段,低频 ===
+ google.protobuf.Timestamp created_at = 100;
+ google.protobuf.Timestamp updated_at = 101;
+ string created_by = 102;
+ string updated_by = 103;
+
+ // === Feature flags / experimental (900-999): 灰度测试用 ===
+ bool new_ui_enabled = 900;
+}
+```
+
+> [!tip] 预留块的好处
+> 如果你的 User 消息已经有 13 个字段,未来需要新增 5 个字段,你只需要在 10~19 之间找空位。如果所有字段从 1 开始连续排列,每加一个都需要改后面所有的编号——而且已经部署的旧客户端会认为新编号的字段属于不同的语义。
+
+## Reserved 保留字段
+
+当你删除或重命名字段时,必须用 `reserved` 声明来防止后人误用相同的编号:
+
+```protobuf
+message User {
+ // ---- 当前活跃字段 ----
+ string id = 1;
+ string name = 2;
+ string email = 3;
+ int32 age = 4;
+
+ // ---- 已废弃字段的编号保留 ----
+ reserved "mobile"; // 之前叫 mobile 的字段已删除
+ reserved 5, 6; // 编号 5 和 6 已释放,禁止复用
+ reserved 7 to 10; // 编号 7~10 连续保留
+}
+```
+
+### 删除字段的正确姿势
+
+三步走,确保平滑过渡:
+
+```mermaid
+flowchart TD
+ S1["📝 步骤 1: 用 reserved 占位"] --> S2["string old_field = 5;\n→\nreserved 5;"]
+ S2 --> S3["📢 步骤 2: 协调消费方迁移"]
+ S3 --> S4["(版本发布窗口内切换)"]
+ S4 --> S5["✅ 步骤 3: 下次编译锁定\n有人复用 → compile error"]
+ S5 --> Safe["后续正式移除"]
+
+ style S2 fill:#fff3cd
+ style S4 fill:#fff3cd
+ style Safe fill:#d4edda
+```
+
+> [!question] 为什么不能只删字段不 reserved?
+> 如果没有 reserved,同事新建字段时使用相同编号:`string feature_flag = 5;`。旧版本客户端读到这个字节流时,会把 feature_flag 的值当成旧版 mobile 字段的值——数据语义完全错位,bug 极难排查。
+
+## 兼容性矩阵(重点章节)
+
+Protobuf 的设计确保了大部分 schema 变更不会破坏现有二进制协议:
+
+| 操作 | 向后兼容? | 向前兼容? | 说明 |
+|------|-----------|-----------|------|
+| 新增字段 | ✅ | ✅ | 老客户端忽略未知编号;新客户端用默认值 |
+| 删除字段 | ✅ | ✅ | 老客户端读取已有数据;新客户端用默认值 |
+| 修改字段类型 | ❌ | ❌ | 新旧对同一编号解读不同 |
+| 修改字段编号 | ❌ | ❌ | 同编号对应不同语义 |
+| 修改枚举值名称 | ✅ | ✅ | 枚举值名不影响 wire format(传输的是数值) |
+| 新增枚举值 | ✅ | ⚠️ | 旧客户端收到未知枚举值回退为 0(首个值) |
+| 删除枚举值 | ❌ | ⚠️ | 旧客户端收到未知枚举值回退为 0 |
+| 单个 repeated 改为 non-repeated | ⚠️ | ❌ | 有数据的单元素列表可互转,多元素场景不兼容 |
+
+### 实战:安全地扩展消息
+
+假设你有一个在线上运行的 v1 proto:
+
+```protobuf
+// v1 - 当前生产版本
+message GetUserResponse {
+ string id = 1;
+ string name = 2;
+ string email = 3;
+}
+```
+
+**需求:添加 `avatar_url` 和 `role` 两个字段,同时删除 `email`。**
+
+❌ **错误示范 — 直接复用已被 reserved 的编号:**
+
+```protobuf
+// v2 - ❌ 编译失败
+message GetUserResponse {
+ string id = 1;
+ string name = 2;
+
+ reserved 3; // email 的编号已保留
+
+ string avatar_url = 3; // ← 编译报错:field number 3 is reserved
+}
+```
+
+✅ **正确做法 — 使用新编号 + reserved 占位:**
+
+```protobuf
+// v2 - ✅ 安全演进
+message GetUserResponse {
+ string id = 1;
+ string name = 2;
+
+ reserved 3; // 原 email 编号锁定,防止后人误用
+
+ string avatar_url = 4; // 新字段分配新编号
+ User_Role role = 5;
+}
+```
+
+> [!note] 正确的分步迁移方案
+> 1. 先加字段 `avatar_url = 4`、`role = 5`,email 继续保留。
+> 2. 服务端双写:同时返回 email 和 avatar_url。
+> 3. 客户端升级,切换到使用 avatar_url。
+> 4. 确认旧客户端已淘汰后,标记 email 为 reserved,下次发版正式移除。
+
+## 版本演进策略
+
+对于大型项目,建议使用 package-level versioning 来管理 schema 演进:
+
+### 目录结构与命名约定
+
+```
+protos/
+├── user/
+│ └── v1/
+│ ├── user.proto
+│ ├── auth.proto
+│ └── error.proto
+└── order/
+ └── v1/
+ ├── order.proto
+ └── payment.proto
+```
+
+```protobuf
+// option go_package 包含版本路径
+option go_package = "github.com/example/service/user/v1;userpb";
+
+// import 路径与目录结构一致
+import "user/v1/user.proto";
+```
+
+### Major Version 迁移方案
+
+当需要做不兼容变更时(如改字段类型、重构消息结构):
+
+```mermaid
+flowchart LR
+ subgraph A["方案 A: v2 独立演进 🌟 推荐"]
+ direction TB
+ A1["user/v1/user.proto"] --> A2["新旧并存\nGateway 层做转换"]
+ A3["user/v2/user.proto"] --> A2
+ A2 --> A4["迁移完成\n停用 v1"]
+ end
+
+ subgraph B["方案 B: 原地破坏 ❌ 高风险"]
+ B1["user/v1/user.proto\n直接改"] --> B2["已部署端受影响"]
+ end
+
+ style A fill:#d4edda
+ style B fill:#f8d7da
+ style A4 fill:#28a745,color:#fff
+ style B2 fill:#dc3545,color:#fff
+```
+
+| 维度 | 方案 A(v2 独立文件) | 方案 B(原地修改) |
+|------|---------------------|-------------------|
+| 风险等级 | 低 | **极高** |
+| 线上影响 | Gateway 透明转换 | 所有端同时断裂 |
+| 回滚成本 | 切回 v1 即可 | 几乎无法回滚 |
+| 适用场景 | 所有已发布服务 | 仅限内部未发布 proto |
+
+> [!tip] 灰度策略:双字段过渡法
+> 如果需要在同一消息中过渡一个新字段到旧字段,分三阶段进行:
+> ```protobuf
+> // 阶段一: 服务端双写两个字段
+> message User {
+> string legacy_name = 1; // 旧字段,逐步弃用
+> string display_name = 2; // 新字段,逐步启用
+> }
+>
+> // 阶段二: 客户端优先读 display_name, 回退到 legacy_name
+> //
+> // 阶段三: 确认全部升级后移除 legacy_name (步骤见上方「删除字段的正确姿势」)
+> ```
+
+## 最佳实践
+
+- **为每个 microservice 预留独立 namespace**:`package service_name.version`。
+- **不要重复使用 field numbers**:即使在同一个文件中删除了字段也要 reserved。
+- **核心高频字段编号保持在 1~15**:节省 wire 编码开销。
+- **重大变更走 v2 而不是改现有文件**:降低线上风险。
+- **在 CI 中加入 proto linter**(如 buf lint):自动化检查编号冲突和命名规范。
+
+## 关联笔记
+
+- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 消息定义基础语法
+- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解]] — 字段可用的所有数据类型及默认值规则
+- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 的 field numbering 有特殊规则
+- [[hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范]] — Proto 文件的工程组织与命名规范
diff --git a/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md b/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md
new file mode 100644
index 0000000..d6fb708
--- /dev/null
+++ b/hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型.md
@@ -0,0 +1,438 @@
+---
+tags: [gRPC, Protobuf, oneof, Any, Value, FieldMask, dynamic types]
+create time: 2026-05-11 16:40
+---
+
+# Oneof 与包装类型
+
+## 概述
+
+Oneof 是 Protobuf 中最灵活的结构之一:它让你在多个互斥字段中只选一个。配合 `google.protobuf.Any` 和 `Value`,你可以写出几乎泛型的消息定义。这些工具如果用得好,能省去大量样板代码。
+
+## Oneof 基础
+
+Oneof 的核心语义:**同一时刻只有一个字段有值**:
+
+```protobuf
+message PaymentRequest {
+ string order_id = 1;
+
+ oneof payment_method {
+ Alipay alipay = 2;
+ Wechat wechat = 3;
+ ApplePay apple = 4;
+ }
+}
+
+message Alipay {
+ string return_url = 1;
+ string device_id = 2;
+}
+
+message Wechat {
+ string openid = 1;
+ string scene_info = 2;
+}
+
+message ApplePay {
+ string payment_token = 1;
+ string merchant_domain = 2;
+}
+```
+
+序列化时,只有被设置的那个 oneof 成员会出现在输出中:
+
+```
+// 如果设置的是 alipay 字段:
+[wire: field_number=2, value=]
+
+// 不会同时出现 field_number=3 或 4
+```
+
+> [!important] 重要行为
+> oneof 字段在**没有设置任何值**时不会出现在 serialized output 中。如果客户端只填了 `order_id` 而未选支付方式,服务端收到的 `payment_method` 对应的 oneof selector 为 nil。
+
+### 为什么需要 Oneof?
+
+如果没有 oneof,你会这样写:
+
+```protobuf
+// ❌ 无法阻止同时设置 alipay 和 wechat
+message PaymentRequest {
+ Alipay alipay = 2;
+ Wechat wechat = 3;
+}
+```
+
+**问题在哪?** 协议层没有任何互斥约束。客户端可能同时填入两个支付方式,服务端必须自己加额外校验。Oneof 把校验推给了 protobuf 编译器——在 wire format 层面保证同一时刻只有一个字段有数据。
+
+## Oneof 的 Go 实现细节
+
+protoc-gen-go 为一组 oneof 生成一个接口 + 一组实现结构体:
+
+```go
+// 生成的代码片段
+type isPaymentRequest_PaymentMethod interface {
+ isPaymentRequest_PaymentMethod()
+}
+
+type PaymentRequest_Alipay struct{ Alipay *Alipay }
+type PaymentRequest_Wechat struct{ Wechat *Wechat }
+type PaymentRequest_Apple struct{ Apple *ApplePay }
+
+type PaymentRequest struct {
+ OrderId string
+ PaymentMethod isPaymentRequest_PaymentMethod
+}
+```
+
+### 如何判断 set 的是哪个字段
+
+```go
+req := &pb.PaymentRequest{
+ OrderId: "ORD-123",
+ PaymentMethod: &pb.PaymentRequest_Alipay{
+ Alipay: &pb.Alipay{ReturnUrl: "https://example.com/return"},
+ },
+}
+
+switch pm := req.PaymentMethod.(type) {
+case *pb.PaymentRequest_Alipay:
+ fmt.Println("使用支付宝:", pm.Alipay.ReturnUrl)
+case *pb.PaymentRequest_Wechat:
+ fmt.Println("使用微信支付:", pm.Wechat.Openid)
+case *pb.PaymentRequest_Apple:
+ fmt.Println("使用 Apple Pay")
+default:
+ fmt.Println("未选择支付方式") // oneof 中没有设置任何值
+}
+```
+
+> [!tip] oneof 赋值规则
+> 每次给 oneof 赋值会自动清除之前的值:
+> ```go
+> req.PaymentMethod = &pb.PaymentRequest_Alipay{...}
+> // 此时其他 oneof 字段自动被设为 nil
+> ```
+
+> [!question] 思考题
+> 如果 oneof 里全是基本类型(如 `string`、`int32`),Go 生成的代码会是什么样子?和引用类型有什么差异?提示:去看生成代码中 `isPaymentRequest_PaymentMethod()` 的具体实现。
+
+## Wrapper Types 重访
+
+回到 wrapper types,这里给出决策树来帮你选择正确的工具:
+
+```mermaid
+flowchart TD
+ A["需要一个可选字段"] --> B{"是否只需要一个可选值?"}
+ B -->|是| C["使用 Wrapper Type
例: StringValue"]
+ B -->|否| D{"字段之间是否互斥?"}
+ D -->|是| E["使用 Oneof"]
+ D -->|否| F["用普通字段
默认零值即可"]
+ C --> G{"需要动态/不确定类型?"}
+ E --> G
+ F --> G
+ G -->|是| H["使用 Any 或 Value"]
+ G -->|否| I["完成 ✓"]
+
+ style C fill:#10b981,color:#fff
+ style E fill:#f59e0b,color:#fff
+ style H fill:#3b82f6,color:#fff
+```
+
+对比场景:
+
+```protobuf
+// ❌ 用 oneof 表达"单个可选字段" —— 过度复杂
+message UserUpdate {
+ oneof name_field {
+ string name = 1;
+ }
+}
+
+// ✅ 等价但更简洁的写法
+message UserUpdate {
+ google.protobuf.StringValue name = 1;
+}
+```
+
+```protobuf
+// ❌ 用多个单独字段表达"互斥字段" —— 无法 enforcing
+message Notification {
+ string email = 1; // 可能三个都有值!
+ string sms = 2;
+ string push_id = 3;
+}
+
+// ✅ 用 oneof 确保互斥
+message Notification {
+ oneof channel {
+ string email = 1;
+ string phone = 2;
+ string push_id = 3;
+ }
+}
+```
+
+## Google.Protobuf.Any
+
+`Any` 是一个万能容器,可以包裹任意类型的 protobuf 消息,常用于 plugin architecture、事件总线等场景:
+
+```protobuf
+import "google/protobuf/any.proto";
+
+// 事件总线中的通用事件消息
+message Event {
+ string event_id = 1;
+ string event_type = 2; // e.g., "UserRegistered"
+ google.protobuf.Any payload = 3; // 根据 event_type 反序列化
+ google.protobuf.Timestamp timestamp = 4;
+}
+
+// 具体的 payload 消息
+message UserRegistered {
+ string user_id = 1;
+ string username = 2;
+ string email = 3;
+}
+
+message OrderCreated {
+ string order_id = 1;
+ string user_id = 2;
+ int64 amount = 3;
+}
+```
+
+### Any 的使用模式
+
+```go
+// 构造:将具体消息包装进 Any
+reg := typeurl.NewRegistry()
+
+userRegistered := &pb.UserRegistered{
+ UserId: "USR-001", Username: "alice", Email: "alice@example.com",
+}
+anyPayload, err := anypb.New(userRegistered)
+if err != nil { ... }
+
+event := &pb.Event{
+ EventId: "EVT-001",
+ EventType: "UserRegistered",
+ Payload: anyPayload,
+ Timestamp: timestamppb.Now(),
+}
+
+// 反序列化:通过 registry 提取原始类型
+var extracted pb.UserRegistered
+if err := event.Payload.UnmarshalTo(&extracted); err != nil { ... }
+fmt.Println("新注册用户:", extracted.Username)
+```
+
+在 JSON 映射中,Any 的表现形式:
+
+```json
+{
+ "event_id": "EVT-001",
+ "event_type": "UserRegistered",
+ "payload": {
+ "@type": "type.googleapis.com/UserRegistered",
+ "user_id": "USR-001",
+ "username": "alice",
+ "email": "alice@example.com"
+ },
+ "timestamp": "2026-05-11T08:30:00Z"
+}
+```
+
+> [!tip] @type URL 的含义
+> `type.googleapis.com/` 是标准的 type URL 格式。`UnmarshalTo` 会根据这个 URL 查找对应的 descriptor,从而确定如何解码 `value` 字节流。
+
+### Any 的典型应用场景
+
+| 场景 | 描述 | 示例 |
+|------|------|------|
+| **Plugin Architecture** | 核心消息固定结构,payload 由插件注入 | gRPC Gateway 转发自定义 header |
+| **Event Bus** | 不同事件类型有不同的 payload 格式 | Kafka/RabbitMQ 事件驱动架构 |
+| **Generic Response Wrapper** | API 返回类型不确定的数据 | GraphQL-like 查询结果 |
+| **Multi-tenant Data** | 不同租户使用不同的扩展字段 | SaaS 平台的多态配置存储 |
+
+## Google.Protobuf.Value(万能类型)
+
+`Value` 可以包裹任意合法的 JSON 类型,比 Any 更宽松——不需要提前注册类型:
+
+```protobuf
+import "google/protobuf/struct.proto";
+
+message MetaStore {
+ string key = 1;
+ google.protobuf.Value value = 2; // 可以是 object / array / string / number / bool / null
+ google.protobuf.Value metadata = 3; // 另一个自由格式的存储
+}
+```
+
+适用场景:
+
+```go
+// 元数据存储:key-value,但 value 的结构完全由调用方决定
+store := &pb.MetaStore{
+ Key: "user:1001:preferences",
+ Value: &structpb.Value{
+ Kind: &structpb.Value_StructValue{
+ StructValue: &structpb.Struct{
+ Fields: map[string]*structpb.Value{
+ "theme": structpb.NewStringValue("dark"),
+ "font_size": structpb.NewNumberValue(16),
+ "notifications": structpb.NewBoolValue(true),
+ "languages": structpb.NewListValue(
+ &structpb.ListValue{Values: []*structpb.Value{
+ structpb.NewStringValue("zh-CN"),
+ structpb.NewStringValue("en"),
+ }},
+ ),
+ },
+ },
+ },
+ },
+}
+```
+
+> [!warning] 代价
+> 使用 `Value` 意味着**放弃了静态类型检查**。编译器无法验证你读取的数据格式是否正确,所有的解析逻辑都需要在运行时处理。适合做 configuration store 或 audit log,不适合业务核心链路。
+
+### 反序列化 Value
+
+从 `Value` 中提取数据需要手动解包,这也是类型不安全的主要体现:
+
+```go
+// 从 StructValue 中取数据
+preferences := store.Value.GetStructValue()
+theme := preferences.Fields["theme"].GetStringValue() // "dark"
+fontSize := preferences.Fields["font_size"].GetNumberValue() // 16
+langs := preferences.Fields["languages"].GetListValue() // []string{"zh-CN", "en"}
+
+// 也可以用 ToValue 转为原生 Go 类型
+native, err := structpb.NewValue(preferences)
+if err != nil { ... }
+// native.Interface() → map[string]any
+```
+
+> [!question] 思考题
+> `Any` 和 `Value` 都能包裹动态内容,该用哪个?记住一个原则:**如果你知道消息类型且想享受编译期检查,用 Any;如果你连结构都不确定(比如纯 JSON),用 Value。**
+
+## FieldMask
+
+`FieldMask` 用于 partial response 和 partial update,指定操作涉及的字段子集:
+
+```protobuf
+import "google/protobuf/field_mask.proto";
+
+message GetUserRequest {
+ string id = 1;
+ google.protobuf.FieldMask read_mask = 2; // 只返回指定的字段
+}
+
+message UpdateUserRequest {
+ string id = 1;
+ google.protobuf.FieldMask update_mask = 2; // 只更新指定的字段
+ User user = 3;
+}
+```
+
+典型 PATCH 接口的 usage:
+
+```go
+// 客户端请求:只更新 name 和 email
+updateReq := &pb.UpdateUserRequest{
+ Id: "USR-001",
+ UpdateMask: &fieldmaskpb.FieldMask{
+ Paths: []string{"name", "email"}, // 只修改这两个字段
+ },
+ User: &pb.User{
+ Name: "Alice Updated",
+ Email: "newalice@example.com",
+ Age: 999, // ← 会被忽略,因为不在 update_mask 中
+ },
+}
+
+// 服务端 Handler 中解析 mask
+for _, path := range updateReq.UpdateMask.Paths {
+ switch path {
+ case "name":
+ user.Name = updateReq.User.Name
+ case "email":
+ user.Email = updateReq.User.Email
+ // Age 不会被更新!
+ }
+}
+```
+
+FieldMask 还支持嵌套路径:
+
+```go
+// 更新嵌套对象的字段
+Paths: []string{"profile.display_name", "settings.theme"}
+```
+
+> [!tip] FieldMask 的安全用法
+> 永远不要直接用 client 传入的 mask 做 `reflect` 反射赋值——这会导致 security vulnerability(如覆盖 system 字段)。应当使用白名单校验:
+> ```go
+> allowed := map[string]bool{"name": true, "email": true}
+> for _, p := range mask.Paths {
+> if !allowed[p] {
+> return error.New("field not updatable")
+> }
+> }
+> ```
+
+### FieldMask 实用方法
+
+Google 提供了 [`fieldmaskpb`](https://pkg.go.dev/google.golang.org/protobuf/types/known/fieldmaskpb) 工具包,常见操作如下:
+
+```go
+// 获取嵌套字段的扁平路径
+mask := fieldmaskpb.FieldMask{Paths: []string{"profile.display_name"}}
+flat := mask.String() // "profile.display_name"
+
+// 合并两个 mask:取并集
+maskA := &fieldmaskpb.FieldMask{Paths: []string{"name", "email"}}
+maskB := &fieldmaskpb.FieldMask{Paths: []string{"avatar"}}
+merged, _ := fieldmaskpb.Merge(maskA, maskB) // ["name","email","avatar"]
+
+// 从子结构推导出父 mask:只保留 user 中实际变化的字段
+changedFields := computeChangedFields(oldUser, newUser)
+effectiveMask, _ := fieldmaskpb.New(changedFields...)
+```
+
+> [!note] JSON 中的 FieldMask 格式
+> 在 gRPC Gateway 等 HTTP→gRPC 桥接层,FieldMask 以逗号分隔的字符串传递:
+> ```
+> GET /users/USR-001?read_mask=name,email,profile.avatar
+> ```
+
+## 本节小结
+
+这一节覆盖了 Protobuf 中处理"不确定性"的四个工具:
+
+| 工具 | 解决什么问题 | 一句话总结 |
+|------|-------------|-----------|
+| **Oneof** | 互斥字段 | 编译期保证"三选一",不要自己加校验逻辑 |
+| **Wrapper Type** | 单个可选字段 | `StringValue` 比 `oneof string` 简洁十倍 |
+| **Any** | 已知但可变的消息类型 | 事件总线、插件架构的核心武器 |
+| **Value** | 完全自由的 JSON 数据 | 放弃类型安全换取灵活性,用在配置层而非业务核心 |
+
+## 对比总结表格
+
+| 特性 | Oneof | Wrapper | Any | Value |
+|------|-------|---------|-----|-------|
+| 类型安全 | ✅ compile-time | ✅ compile-time | ⚠️ runtime | ❌ 运行时 |
+| 单个可选 | ✅ 可用 | ✅(更简洁) | N/A | N/A |
+| 多值互斥 | ✅ 核心用途 | ❌ | N/A | N/A |
+| 动态类型 | ❌ | ❌ | ✅ | ✅ |
+| JSON 互转 | ⚠️ 需额外处理 | ✅ | ✅ | ✅ |
+| wire overhead | 低 | 低 | 中(需存 type_url) | 低 |
+
+## 关联笔记
+
+- [[01-Protobuf 语法与消息定义]] — Protobuf 基础语法入门
+- [[02-数据类型详解]] — 标量、枚举、map、repeated 等类型深入
+- [[03-字段编号与前向兼容]] — 字段编号管理与版本演进策略
diff --git a/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md b/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md
new file mode 100644
index 0000000..9ac5df7
--- /dev/null
+++ b/hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览.md
@@ -0,0 +1,349 @@
+---
+tags: [gRPC, RPC, Streaming, Go, Microservice]
+create time: 2026-05-11 16:40
+---
+
+# RPC 调用模式总览
+
+## 概述
+
+gRPC 提供四种 RPC 调用模式,从最简单的请求-响应到完全的双向流。选择合适的模式是设计高性能 API 的第一步。搞懂它们的区别,你就知道什么时候该用简单调用、什么时候需要双向通信。
+
+> [!question] 如果只选一种模式能走天下吗?
+> 技术上可以——Unary RPC 确实能解决几乎所有问题。但强行用 Unary 实现实时推送,意味着你要轮询(polling),这会产生大量无效请求和延迟。模式选择本质上是在"延迟 vs 资源消耗"之间做 tradeoff。
+
+## RPC 模式全景图
+
+```mermaid
+flowchart LR
+ A[Unary] -->|"一问一答"| B[最简单]
+ C[Server Stream] -->|"一问多答"| D[广播式]
+ E[Client Stream] -->|"多问一答"| F[收集式]
+ G[BiDi Stream] -->|"多问多答"| H[全双工]
+
+ style A fill:#00B6BC,color:#fff
+ style G fill:#EE5A24,color:#fff
+```
+
+> [!example] 各模式数据流向速览
+> 左列为 Client,右列为 Server,箭头方向表示数据流动方向。
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant S as Server
+
+ rect rgba(0, 182, 188, 0.1)
+ 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 — 请求后连续响应
+ C->>S: request
+ S-->>C: response 1
+ S-->>C: response 2
+ S-->>C: ... n (EOF)
+ end
+
+ rect rgba(79, 195, 247, 0.1)
+ Note over C,S: Client Stream — 连续发送后一次性响应
+ C->>S: chunk 1
+ C->>S: chunk 2
+ C->>S: ... n (done)
+ S-->>C: result
+ end
+
+ rect rgba(238, 90, 36, 0.1)
+ Note over C,S: BiDi Stream — 双向独立通信
+ C->>S: msg 1
+ S-->>C: reply 1
+ C->>S: msg 2
+ S-->>C: reply 2
+ end
+```
+
+## Unary RPC(普通调用)
+
+最经典、最常见的模式,等同于 REST 的 request-response。
+
+**特点:**
+- 一次客户端请求,一次服务端响应
+- 简单、易调试、可直接映射 HTTP GET/POST
+- 适合:CRUD 操作、短查询、标准 API 端点
+
+```go
+// proto 定义
+// rpc GetUser(GetUserRequest) returns (GetUserResponse);
+
+// Client side
+func main() {
+ conn, _ := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
+ defer conn.Close()
+ client := pb.NewUserServiceClient(conn)
+
+ resp, err := client.GetUser(context.Background(), &pb.GetUserRequest{Id: 42})
+ if err != nil {
+ log.Fatal(err) // error comes from transport or server handler
+ }
+ fmt.Println(resp.Name)
+}
+
+// Server side
+type Server struct {
+ pb.UnimplementedUserServiceServer
+}
+
+func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.GetUserResponse, error) {
+ user := fetchFromDB(req.GetId())
+ return &pb.GetUserResponse{Name: user.Name}, nil
+}
+```
+
+**思考题:**如果一个接口需要 30 秒才能返回结果,你应该用 Unary 还是其他模式?(提示:考虑超时和连接的持有时间)
+
+> [!answer]+ 参考答案
+> **可以用 Unary,但要注意三件事:**
+>
+> 1. **设置合理的 context timeout**:`context.WithTimeout`,避免无限期等待
+> 2. **HTTP/2 ping keepalive**:gRPC 默认会发送 keepalive ping 防止代理(Nginx/LB)因"连接空闲"而断开
+> 3. **是否真的需要阻塞等待**:如果是异步任务(如报表生成),更好的做法是——Unary 提交任务 + 轮询/回调通知结果,而非让一个 RPC 连接挂 30 秒。
+>
+> Streaming 并不会延长超时时间——超时由 context 控制,与使用哪种 RPC 模式无关。
+
+## Server Streaming RPC(服务端流)
+
+Client 发一个请求,Server 持续返回多个响应。
+
+**经典场景:**
+- 实时通知推送
+- 大列表分批返回
+- 日志/事件流订阅
+
+```protobuf
+rpc Subscribe(SubscribeRequest) returns (stream Event);
+```
+
+```go
+// Server side - 持续发送事件(注意不要用 log.Fatal,会杀死整个进程)
+func (s *Server) Subscribe(req *pb.SubscribeRequest, stream pb.UserService_SubscribeServer) error {
+ for _, event := range s.watchEvents(req.Topic) {
+ if err := stream.Send(&pb.Event{Data: event}); err != nil {
+ return err // channel closed or context canceled
+ }
+ }
+ return nil
+}
+```
+
+> [!note] import 提示
+> 流式示例中用到以下包:`context`, `fmt`, `io`, `log`.
+> 每个文件只需 import 实际用到的即可,不必照抄。
+
+```go
+// Client side - 遍历接收事件(生产环境建议用 recover + defer 做错误恢复)
+func main() {
+ client := pb.NewUserServiceClient(conn)
+ stream, err := client.Subscribe(context.Background(), &pb.SubscribeRequest{Topic: "orders"})
+ if err != nil {
+ log.Fatal(err)
+ }
+ for {
+ event, err := stream.Recv()
+ if err == io.EOF {
+ break // server finished sending
+ }
+ if err != nil {
+ return err // ⚠️ 这里用 return 而非 log.Fatal,服务端的 handler 不能 kill 进程
+ }
+ fmt.Printf("received: %s\n", event.Data)
+ }
+}
+```
+
+> [!tip] 注意:Client 仍然可以通过 context cancel 随时中断流。这是调试流式问题时最容易忽略的一点——不是 Server 主动关了连接,而是 Client 放弃了。
+
+## Client Streaming RPC(客户端流)
+
+Client 持续发送多个请求,Server 在所有数据发送完毕后返回一个响应。
+
+**经典场景:**
+- 大批量数据上传
+- 文件分片聚合处理
+- 批量日志采集
+
+```protobuf
+rpc Upload(stream FileChunk) returns (UploadResult);
+```
+
+```go
+// Client side - 流式发送数据分片
+func uploadFile(client pb.FileServiceClient, chunks [][]byte) error {
+ stream, err := client.Upload(context.Background())
+ if err != nil {
+ return err
+ }
+ for _, chunk := range chunks {
+ if err := stream.Send(&pb.FileChunk{Data: chunk}); err != nil {
+ return err
+ }
+ }
+ result, err := stream.CloseAndRecv() // 结束发送,获取最终结果
+ return err
+}
+
+// Server side - 逐块接收后聚合(用 return 而非 Fatal,让 gRPC 框架处理错误上报)
+func (s *Server) Upload(stream pb.FileService_UploadServer) error {
+ var buffer bytes.Buffer
+ for {
+ chunk, err := stream.Recv()
+ if err == io.EOF {
+ break // client closed send direction
+ }
+ if err != nil {
+ return err
+ }
+ buffer.Write(chunk.Data)
+ }
+ _, err := stream.SendAndReceive(&pb.UploadResult{Size: int32(buffer.Len())})
+ return err
+}
+```
+
+**优势:**内存友好——不需要一次性 load 全部数据到内存中,每个 chunk 独立收发。
+
+## Bidirectional Streaming RPC(双向流)
+
+Client 和 Server 可以同时独立地发送消息,是全双工通信。
+
+**经典场景:**
+- 聊天室
+- 实时协作编辑
+- 游戏状态同步
+
+```protobuf
+rpc Chat(stream ChatMessage) returns (stream ChatMessage);
+```
+
+```go
+// Server side - 转发逻辑,两端各自独立循环
+func (s *Server) Chat(stream pb.ChatService_ChatServer) error {
+ done := make(chan struct{})
+
+ // goroutine 1: 读取客户端消息
+ go func() {
+ for {
+ msg, err := stream.Recv()
+ if err == io.EOF {
+ close(done)
+ return
+ }
+ if err != nil {
+ return
+ }
+ // 广播给其他 connected clients...
+ s.broadcast(msg)
+ }
+ }()
+
+ // goroutine 2: 定时推送服务器消息
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-done:
+ return nil
+ case t := <-ticker.C:
+ stream.Send(&pb.ChatMessage{Text: fmt.Sprintf("heartbeat: %s", t)})
+ }
+ }
+}
+```
+
+**关键点:**
+- 两端的 Send 和 Recv 是独立的——一端 Recv 完不影响另一端继续 Send
+- 必须用两个 goroutine 分别处理 recv 和 send 循环
+- `io.EOF` 只表示对方的关闭,不代表己方也要停止
+
+### 背压与心跳(生产级要点)
+
+BiDi Stream 在长连接场景下,有两个必须考虑的问题:
+
+```go
+// 背压控制:如果 Send 堆积过多,应该限流或暂停
+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)
+ }
+ }()
+ // ...recv loop 往 sendCh 里塞消息即可
+}
+```
+
+> [!tip] 背压原理
+> gRPC 的 `Send()` 是**有缓冲阻塞**的。当 internal buffer 写满时,发送端会自动 pause——这就是 HTTP/2 Flow Control 提供的天然背压机制,不需要手动实现。但你应该设置合理的 buffer size,过大浪费内存,过小影响吞吐。
+
+> [!note] Keepalive 配置示例
+> ```go
+> conn, _ := grpc.Dial(addr,
+> grpc.WithKeepaliveParams(keepalive.ClientParameters{
+> Time: 10 * time.Second, // ping interval
+> Timeout: 20 * time.Second, // wait for ping ack
+> PermitWithoutStream: true, // 即使无活跃 RPC 也发 ping
+> }),
+> )
+> ```
+> 这对穿越 Nginx / AWS ALB 等负载均衡器至关重要——它们通常会对空闲连接执行 tcp idle timeout 断开。
+
+> [!warning] 复杂度警告
+> BiDi Streaming 是最强大但也最容易出错的模式。你必须同时处理:context cancel、io.EOF、网络异常、心跳保活、背压(backpressure)。生产环境中除非必要,否则优先考虑其他三种模式。
+
+## 模式选型决策指南
+
+```mermaid
+flowchart TD
+ Start{是否需要
实时交互?}
+ Start -->|否| Simple{单次
请求?}
+ Start -->|是| BiDi{高频
交互?}
+
+ Simple -->|是| U[Unary RPC
最简单]
+ Simple -->|否| SS[Server Stream
一次请求多次返回]
+
+ BiDi -->|是| BD[Bidirectional Stream
全双工通信]
+ BiDi -->|否| CS{数据量
超大?}
+ CS -->|是| CB[Client Stream
分批上传]
+ CS -->|否| SS
+
+ style U fill:#00D866,color:#fff
+ style BD fill:#FF6B35,color:#fff
+```
+
+## 性能对比
+
+| 维度 | Unary | Server Stream | Client Stream | BiDi Stream |
+|------|-------|---------------|---------------|-------------|
+| **RTT** | 1 | 1+N | M+1 | M+N |
+| **实现复杂度** | ⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
+| **内存占用** | 高(一次加载完整响应) | 低(逐条处理) | 低(分片发送) | 低(双向流控) |
+| **超时风险** | 高(连接全程持有一直到完整响应) | 低(请求已发出,可取消) | 中(大文件需合理 timeout) | 中(长连接需 keepalive) |
+| **典型场景** | CRUD、短查询 | 订阅推送、列表分页 | 大文件上传、批量采集 | 聊天室、实时协作、游戏同步 |
+
+## 与 HTTP 方法的映射类比
+
+| gRPC 模式 | 近似的 HTTP 模式 |
+|-----------|-----------------|
+| Unary | GET / POST |
+| Server Stream | SSE (Server-Sent Events) |
+| Client Stream | Multipart Upload |
+| BiDi Stream | WebSocket |
+
+> [!note] 类比 ≠ 等价
+> 这些只是功能层面的类比。gRPC 是二进制协议且基于 HTTP/2,行为特征和 HTTP 层语义有本质差异——比如 HTTP/2 的多路复用让 gRPC Stream 比 WebSocket 更高效。
+
+## 关联笔记
+- [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成]]
+- [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
diff --git a/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md b/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md
new file mode 100644
index 0000000..3eac891
--- /dev/null
+++ b/hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md
@@ -0,0 +1,439 @@
+---
+tags: [gRPC, Protobuf, Go, protoc, Code Generation, proto3]
+create time: 2026-05-11 16:41
+---
+
+# Service 定义与代码生成
+
+## 概述
+
+`.proto` 文件的最终目的是定义 Service —— 告诉 gRPC 有哪些远程可调用的 API。Protobuf 编译器会将你的 service 定义翻译成各个语言的 client stub 和 server interface。理解这个从文本到可执行代码的过程,是调试"gRPC 报错说找不到方法"的前提。
+
+> [!question] 为什么需要代码生成?
+> Protobuf 不是动态语言。所有类型、字段、方法都在编译期确定,这意味着你没法在运行时通过字符串来调用一个 RPC——必须用生成的 Stub。好处是强类型检查能在编码阶段就发现错误,坏处是你改了 proto 文件就得重新生成代码并编译。
+
+## Proto3 语法速览
+
+Service 由 message 组成,所以先快速过一遍 proto3 的核心语法。这是写 proto 文件时每天都要用的基础知识。
+
+### 消息(Message)与字段类型
+
+```protobuf
+syntax = "proto3";
+package user.v1;
+
+message User {
+ string name = 1; // 变长字符串,UTF-8 编码
+ int64 id = 2; // 64 位有符号整数
+ bool active = 3; // 布尔值
+ float balance = 4; // 32 位浮点数
+ bytes avatar = 5; // 原始字节(如图片数据)
+}
+```
+
+**常用标量类型一览:**
+
+| 声明类型 | 对应 Go 类型 | 说明 |
+|----------|-------------|------|
+| `double` / `float` | `float64` / `float32` | 浮点数 |
+| `int64` / `uint64` | `int64` / `uint64` | 大整数 |
+| `int32` / `uint32` | `int32` / `uint32` | 普通整数(用 varint 编码,小值更紧凑) |
+| `bool` | `bool` | 布尔 |
+| `string` | `string` | UTF-8 字符串(必须用 string,bytes 存原始二进制) |
+| `bytes` | `[]byte` | 任意字节序列 |
+
+> [!tip] 零值语义
+> proto3 没有 `optional` 标记的字段永远有零值——`string` 是 `""`,`int` 是 `0`,`bool` 是 `false`。你无法区分"字段没设置"和"字段设为零值"。如果需要检测字段是否存在,可以用 `google.protobuf.BoolValue` 包装类型,或者启用 `optional` 关键字(proto3 扩展语法)。
+
+### 枚举(Enum)
+
+```protobuf
+enum UserRole {
+ ROLE_UNKNOWN = 0; // proto3 要求第一个值是 0,且必须是唯一的 zero value
+ ROLE_ADMIN = 1;
+ ROLE_USER = 2;
+ ROLE_GUEST = 3;
+}
+
+message CreateUserRequest {
+ string name = 1;
+ UserRole role = 2; // 用枚举替代魔法数字
+}
+```
+
+> [!warning] enum 的零值陷阱
+> proto3 中如果收到未知枚举值,它会被当作零值处理而非报错。这意味着服务端可以优雅地忽略客户端传来的新版本枚举值——这是 proto 向后兼容的设计之一。
+
+### Oneof(多选一)
+
+当多个字段互斥时使用 `oneof`,它比用单独字段节省内存,因为底层只有一个字段在存储。
+
+```protobuf
+message PaymentMethod {
+ oneof method {
+ string credit_card = 1; // Visa/MC number
+ string paypal_email = 2; // PayPal 账户
+ AlipayAccount alipay = 3; // 自定义 message
+ }
+}
+```
+
+只能设置 oneof 中的**一个**字段。设置新字段会清除前一个的值。
+
+### Map Fields
+
+```protobuf
+message UserMetadata {
+ map tags = 1; // user -> tag mappings
+ map department_map = 2; // dept ID -> name
+}
+```
+
+内部实现是一个哈希表。空 map 序列化为空,不会省略。
+
+### Reserved 字段
+
+当你删除或重命名某个字段时,用 `reserved` 占位防止其他人重用同一 field number:
+
+```protobuf
+message User {
+ reserved 3, 5 to 8; // 保留 field numbers 3, 5, 6, 7, 8
+ reserved "old_name", "temp"; // 也保留字段名
+}
+```
+
+### Well-Known Types
+
+Protobuf 内置了一组通用类型,import 后可直接使用:
+
+```protobuf
+import "google/protobuf/timestamp.proto";
+import "google/protobuf/wrappers.proto";
+import "google/protobuf/struct.proto";
+
+message Event {
+ google.protobuf.Timestamp created_at = 1; // RFC 3339 时间戳
+ google.protobuf.StringValue display_name = 2; // *string,用于 detect missing
+ google.protobuf.Struct metadata = 3; // JSON-like 任意结构
+}
+```
+
+常用 well-known type 速查:
+
+| Well-Known Type | 对应 Go 类型 |
+|----------------|-------------|
+| `Timestamp` | `time.Time` |
+| `Duration` | `time.Duration` |
+| `StringValue` | `*string` |
+| `Int32Value` / `Int64Value` | `*int32` / `*int64` |
+| `BoolValue` | `*bool` |
+| `Any` | `any` / `[]byte` |
+
+## Service 定义语法
+
+Service 定义使用 `service` 关键字包裹一组 `rpc` 方法:
+
+```protobuf
+syntax = "proto3";
+package user.v1;
+option go_package = "example.com/proto/user/v1;userpb";
+
+message CreateUserRequest {
+ string name = 1;
+ string email = 2;
+}
+
+message CreateUserResponse {
+ int64 id = 1;
+}
+
+service UserService {
+ // Unary: 普通请求响应
+ rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
+
+ // Server Streaming: 一次请求,多个响应
+ rpc ListUsers(ListUsersRequest) returns (stream ListUsersResponse);
+
+ // Client Streaming: 多次请求,一个响应
+ rpc UploadAvatar(stream AvatarChunk) returns (AvatarResult);
+
+ // Bidirectional Streaming: 双方都流式
+ rpc WatchUsers(stream WatchRequest) returns (stream WatchEvent);
+}
+```
+
+**语法要点:**
+- `stream` 关键字出现在参数侧表示该方向是流式
+- `stream` 可以出现在 request 侧、response 侧,或两侧都有
+- 每个 proto 文件可以有**多个** service 定义(但最佳实践建议一个文件一个 service,保持高内聚)
+
+## Option 系统
+
+### 必填项
+
+| Option | 说明 | 示例 |
+|--------|------|------|
+| `syntax` | 语法版本,proto3 是当前标准 | `syntax = "proto3";` |
+| `package` | 命名空间,防止跨项目命名冲突 | `package user.v1;` |
+| `go_package` | Go 输出路径和包名 | `option go_package = "...";` |
+
+### go_package 详解
+
+这是 Go 开发者最容易踩坑的地方。它的格式是 `"模块路径/生成文件存放路径;包名"`。
+
+```protobuf
+// ✅ 正确:module path 是 github.com/myorg/services
+// 生成的文件放在 gen/proto/user/v1/ 目录下
+// 包名为 userpb
+option go_package = "github.com/myorg/services/gen/proto/user/v1;userpb";
+
+// ❌ 错误:go_package 中的路径与实际 import 不匹配
+// 会导致 Go 编译器报 symbol undefined
+option go_package = "user/v1;userpb";
+```
+
+> [!tip] go_package 拆解
+> ```
+> option go_package = "导入路径/子路径;包名";
+> // ↑ 生成文件相对 module root 的路径 ↑ Go package name
+> // 生成文件的 import path 将是 module root + 前半段
+> ```
+>
+> 例如:`go_package = "github.com/myorg/api/user/v1;userpb"`,如果当前 `.proto` 所在目录与 `v1` 对齐,则生成的 `_pb.go` 的 import path 为 `github.com/myorg/api/user/v1`,文件中 `package userpb`。
+
+### 其他语言路径配置
+
+```protobuf
+// 如需支持多语言输出,补全对应的 option
+option java_package = "com.example.user.v1";
+option java_multiple_files = true; // 每个 message 单独一个 Java 文件
+
+option py_generic_services = false; // Python 是否生成 service 基类
+
+option php_namespace = "Example\\User\\V1";
+```
+
+> [!note] 如果只开发 Go 服务,其他语言的 option 可以不填,减少维护负担。
+
+### 标记已废弃字段
+
+```protobuf
+message OldUserProto {
+ string old_field = 1 [deprecated = true]; // 前端可用 @deprecated 注解识别
+}
+```
+
+### optimize_for(性能调优选项)
+
+对于消息体很大的场景,可以用 `optimize_for` 控制代码生成的策略:
+
+```protobuf
+option optimize_for = SPEED; // 默认:生成的序列化/反序列化代码最快
+// option optimize_for = CODE_SIZE; // 优化生成代码大小(使用 lite runtime)
+// option optimize_for = LITE_RUNTIME; // 生成依赖 lite protobuf runtime 的代码
+```
+
+- **`SPEED`**(默认):生成完整的序列化和反序列化代码,速度最优
+- **`CODE_SIZE`**:使用反射式编解码,减小生成的代码体积,适合嵌入式环境
+- **`LITE_RUNTIME`**:类似 CODE_SIZE,但保留部分直接编解码逻辑,折中方案
+
+大多数 Web 微服务不需要改这个选项——默认的 SPEED 就是最好的选择。
+
+### Custom Options 简介
+
+通过 `extend google.protobuf.MessageOptions` 可以定义自定义 option,被 gRPC Gateway、Protoc Gen OpenAPI 等工具链借用。了解即可,涉及 proto 元数据反射,属于进阶话题。
+
+## Proto 编译流程
+
+```mermaid
+flowchart LR
+ A[".proto 源文件"] --> B["protoc 编译器"]
+ B --> C["protoc-gen-go — Go struct 定义"]
+ B --> D["protoc-gen-go-grpc — Client Stub + Server Interface"]
+ C --> E["Go 代码编译"]
+ D --> E
+ E --> F["可执行程序"]
+
+ style A fill:#FFD43B
+ style B fill:#00B6BC,color:#fff
+ style F fill:#4FC08D,color:#fff
+```
+
+核心流程就是三步:写 `.proto` → `protoc` 生成 Go 代码 → 正常 `go build`。
+
+### 典型的项目目录结构
+
+```
+proto/
+├── buf.gen.yaml # buf 代码生成配置(如果用 buf)
+├── user/
+│ └── v1/
+│ ├── user.proto # Service + Message 定义
+│ └── error.proto # 错误码定义
+├── google/ # third_party 依赖(来自 grpc-ecosystem/grpc-gateway)
+└── Makefile # 自动化生成脚本
+```
+
+> [!tip] 推荐的两种代码生成方式
+>
+> 1. **Makefile + protoc**:最传统的做法,用 shell 变量管理 `-I` 路径
+> 2. **buf**:现代 proto 编译工具,自动处理依赖管理和 plugin 版本,推荐新项目使用
+>
+> 具体用法参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]
+
+## Go Stub 解析
+
+运行 protoc 后,每个 `.proto` 文件至少生成两个文件:
+
+| 文件 | 内容 |
+|------|------|
+| `xxx_pb.go` | Message 的 struct 定义(如 `CreateUserRequest{}`) |
+| `xxx_grpc.pb.go` | Client interface + Server interface + Register 函数 |
+
+### `_grpc.pb.go` 中的关键符号
+
+```go
+// --- 客户端 ---
+type UserServiceClient interface {
+ CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error)
+ ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (UserService_ListUsersClient, error)
+ // ...
+}
+
+func NewUserServiceClient(cc grpc.ClientConnInterface) UserServiceClient
+
+// --- 服务端 ---
+type UserServiceServer interface {
+ CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error)
+ ListUsers(*ListUsersRequest, UserService_ListUsersServer) error
+ // ...
+}
+
+// 你必须嵌入这个来拿到零值安全的方法实现
+type UnimplementedUserServiceServer struct{}
+
+// --- 注册 ---
+func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer)
+
+// --- ServiceDesc ---
+var UserService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "user.v1.UserService",
+ MethodType: grpc.Unary,
+ MethodName: "CreateUser",
+ Handler: ...,
+}
+```
+
+### `_pb.go` 中的 Message
+
+```go
+type CreateUserRequest struct {
+ nameState impl.MessageState
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Email string `protobuf:"bytes,2,opt,name=email,proto3" json:"email,omitempty"`
+}
+// + getter methods: GetName(), GetEmail()
+// + JSON marshaler/unmarshaler
+```
+
+> [!tip] Getter 方法
+> Protobuf Go 生成器会为每个字段生成 `GetXxx()` getter方法。即使字段本身是 public 的(大写),你也应该优先用 getter——某些字段未来可能会改为 internal 实现,getter 能保护你的代码不受影响。
+
+### 实战:客户端调用示例
+
+```go
+// 构建客户端并发起请求
+conn, _ := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
+defer conn.Close()
+
+client := pb.NewUserServiceClient(conn)
+
+resp, err := client.CreateUser(context.Background(), &pb.CreateUserRequest{
+ Name: "Alice",
+ Email: "alice@example.com",
+})
+if err != nil {
+ log.Fatal(err)
+}
+fmt.Printf("created user with id=%d\n", resp.Id)
+```
+
+## 代码生成命令速查
+
+### protoc 基本用法
+
+```bash
+# 最简形式(generated files 放入 output dir)
+protoc \
+ --go_out=. \
+ --go-grpc_out=. \
+ *.proto
+```
+
+### source_relative 模式(推荐)
+
+生成的文件与原 `.proto` 放在同目录下,更符合 Go 习惯:
+
+```bash
+protoc \
+ --go_opt=paths=source_relative \
+ --go-grpc_opt=paths=source_relative \
+ -I . \
+ *.proto
+```
+
+### 多目录 / 带 import 的场景
+
+```bash
+protoc \
+ --go_opt=paths=source_relative \
+ --go-grpc_opt=paths=source_relative \
+ -I proto/ \
+ -I third_party/googleapis/ \
+ proto/user/v1/*.proto
+```
+
+### 集成到 go generate
+
+在项目的入口文件顶部添加注释指令:
+
+```go
+//go:generate protoc \
+// --go_opt=paths=source_relative \
+// --go-grpc_opt=paths=source_relative \
+// -I . \
+// ./proto/**/*.proto
+```
+
+然后在终端只需一行:
+
+```bash
+go generate ./...
+```
+
+> [!tip] 为什么推荐 go generate?
+> 相比手写 protoc 命令,`go generate` 让代码生成变成 Go 工作流的一部分,不再需要额外记忆复杂的命令行参数。结合 Makefile 或 Task 更稳定。参见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]。
+
+## 常见错误排查
+
+| 错误信息 | 原因 | 解决方法 |
+|----------|------|----------|
+| `symbol undefined` | `go_package` 路径不正确 | 检查 `go_package` 中的 module 路径是否匹配当前项目的 `go.mod` |
+| `service not found` | 没有运行 `protoc-gen-go-grpc` | 确认 `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest` 且 `$GOPATH/bin` 在 PATH 中 |
+| `mismatched version` | `protoc` CLI 版本与 plugin 版本不匹配 | 升级 plugin 到最新版,或降级 protoc。两者不需要严格一致,但不要跨太多代 |
+| `unknown syntax` | `.proto` 缺少 `syntax = "proto3"` | 添加该行或在文件开头加上 `syntax = "proto3";` |
+| `import not found` | `-I` 导入路径不对 | 检查 `import "..."` 声明与 `-I` 指定的 search path 是否对齐 |
+| `already defined` | 同一个 message/service 被定义了两次 | 检查是否有重复 import,或多个 proto 文件导出到了同一目标目录 |
+| `go_package mismatch` | 生成文件所在的包路径不符合 Go module 约定 | 将 `go_package` 调整为相对于 module root 的正确路径 |
+
+> [!tip] 快速诊断顺序
+> 1. `protoc --version` 看版本
+> 2. `which protoc-gen-go-grpc` 确认 plugin 可执行
+> 3. `grep go_package *.proto` 检查模块路径
+> 4. 跑 `go generate` 而非手动 protoc——Makefile 更稳定
+
+## 关联笔记
+
+- [[hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览]]
+- [[hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理]]
+- [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]]
diff --git a/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理.md b/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理.md
new file mode 100644
index 0000000..46feb4c
--- /dev/null
+++ b/hhs/gRPC/2. gRPC 核心篇/07-HTTP2 传输原理.md
@@ -0,0 +1,484 @@
+---
+tags: [gRPC, HTTP/2, Protocol, Go, Networking]
+create time: 2026-05-11 16:42
+---
+
+# HTTP/2 传输原理
+
+## 概述
+
+gRPC 跑在 HTTP/2 之上,这意味着你天然享受 HTTP/2 带来的所有性能红利:多路复用、头部压缩、服务器推送。但也带来了一些新的心智负担——原来用 TCP 协议栈就能搞定的事,现在又多了一层帧(frame)和流(stream)的概念。理解底层原理才能调试那些"偶发性超时""连接无故断开"的问题。
+
+> [!question] 为什么 gRPC 不用 HTTP/1.1?
+> HTTP/1.1 的串行阻塞模型在面对高并发微服务时效率太低——每增加一个并发都要付出一次 TCP 握手开销。而 HTTP/2 在一个 TCP 连接上就能搞定任意数量的并发请求。但代价是你得适应全新的二进制协议层。
+
+> [!tip] 先搞清楚这层关系
+> ```
+> 应用层:Protobuf 消息 → 序列化为字节流
+> gRPC 层:把字节流包装成 request/response/stream
+> HTTP/2 层:把 gRPC 数据切分成 frame, multiplex 到 stream 上
+> TCP 层:可靠的字节流传输
+> ```
+> gRPC = Protobuf wire format + HTTP/2 transport + gRPC semantics
+
+## HTTP/2 vs HTTP/1.1 对比
+
+| 特性 | HTTP/1.1 | HTTP/2 |
+|------|----------|--------|
+| 连接数 | 每主机通常 6 个 | 任意数量 |
+| 传输格式 | 纯文本 | 二进制 |
+| 多路复用 | ❌ (head-of-line blocking) | ✅ |
+| 头部压缩 | ❌ | ✅ HPACK |
+| 服务器推送 | ❌ | ✅ PushPromise |
+| 头部顺序 | 明文逐行发送 | header block fragments |
+
+**核心差异一句话:**HTTP/2 将一切变成了二进制帧(frame),用帧的组合来表达请求、响应和元数据。
+
+> [!note] 为什么二进制更好?
+> 纯文本协议(如 HTTP/1.1)需要靠 `\r\n` 分隔,解析容易出错且浪费带宽。二进制协议用 length + type 字段精确定位每个单元,解析更快也更健壮。代价是——你不能再直接用浏览器看明文了,必须用专门的抓包工具。
+
+## HTTP/2 连接建立过程
+
+### Connection Preface(连接前置声明)
+
+HTTP/2 要求在真正的数据交换之前,双方先发一段 "preface" 来确认对方支持 HTTP/2:
+
+```go
+// Client Preface(客户端必须首先发送,固定字符串)
+// PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
+// SETTINGS frame (length = 0)
+
+// Server Preface(服务端确认后回复同样长度的 SETTINGS frame)
+// SETTINGS frame (length = 0)
+```
+
+这不是什么代码层面的操作——gRPC 客户端内部自动处理。但你可以在 Wireshark 中看到这个 handshake 过程:Client 发 `PRI * HTTP/2.0`,Server 回复 `SETTINGS ACK`,然后双方才开始交换业务数据。
+
+> [!warning] 常见坑
+> 某些老旧代理(如旧版 Squid / Nginx < 1.3.10)不支持 ALPN 或不认 Preface,会导致 HTTP/2 降级为 HTTP/1.1。遇到偶发的 "connection reset" 时可以检查一下中间件版本。
+
+### TLS Handshake + ALPN
+
+如果是 HTTPS 连接(gRPC 生产环境的标配),完整的握手流程是:
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant T as TCP/TLS
+ participant S as Server
+
+ Note over C,S: Step 1 — TCP 三次握手
+ C->>T: SYN
+ T->>S: SYN-ACK
+ S->>C: ACK
+
+ Note over C,T: Step 2 — TLS 1.3 握手
+ C->>T: ClientHello (ALPN: h2)
+ T->>S: ServerHello (selected: h2)
+ S->>C: finished
+
+ Note over C,S: Step 3 — HTTP/2 Preface
+ C->>S: ClientPreface + SETTINGS
+ S->>C: SETTINGS ACK + ServerSettings
+
+ Note over C,S: Step 4 — 开始 multiplexing
+ C->>S: HEADERS + DATA (Stream 1)
+ C->>S: HEADERS + DATA (Stream 3)
+ S->>C: HEADERS + DATA (Stream 2)
+```
+
+关键点是 **ALPN(Application-Layer Protocol Negotiation)**:TLS 握手的 `ClientHello` 中会附带支持的协议列表(`h2` / `http/1.1`),服务端从中选择一个并在 `ServerHello` 中返回。如果协商失败,就不会走 HTTP/2。
+
+## HTTP/2 帧(Frame)详解
+
+gRPC 的数据以 frame 为单位在连接上传输。每种 frame 有特定的类型码和控制语义:
+
+| Frame | Type Code | 作用 | gRPC 中的场景 |
+|-------|-----------|------|---------------|
+| DATA | 0x0 | 实际载荷 | Request / Response body |
+| HEADERS | 0x1 | 头信息(含 HTTP/2 header block) | HTTP headers + gRPC metadata |
+| PRIORITY | 0x2 | 设置 stream 优先级 | gRPC 不使用 |
+| RST_STREAM | 0x3 | 异常终止 | Client/Server 主动中断流 |
+| SETTINGS | 0x4 | 协商参数 | 握手阶段交换 max_concurrent_streams 等 |
+| PUSH_PROMISE | 0x5 | 服务器推送 | gRPC 不使用 |
+| PING | 0x6 | 保活探测 | keepalive ping |
+| GOAWAY | 0x7 | 优雅关闭 | Server 准备停机 |
+| WINDOW_UPDATE | 0x8 | 流量控制 | 调整接收窗口大小 |
+| CONTINUATION | 0x9 | 续传 header block | 头部太长时的分片传输 |
+
+每个 frame 的结构固定为 9 字节头部 + 有效载荷:
+
+```
++-----------------------------------------------+
+| Length (24 bits) |
++---------------+---------------+---------------+
+| Type (8 bits) | R(1 bit) | Stream ID(31 bits) |
++---------------+---------------+---------------+
+| Payload (... bytes) |
++-----------------------------------------------+
+```
+
+- **Length**: payload 长度,最大 2^24 - 1 ≈ 16MB
+- **Type**: frame 类型(上述表格中的 Type Code)
+- **R**: reserved bit,必须为 0
+- **Stream ID**: 所属 stream 的 ID,0 表示 connection-level frame
+- **Payload**: 随类型不同含义各异
+
+```mermaid
+flowchart TB
+ subgraph Connection ["HTTP/2 Connection"]
+ direction TB
+ Streams --> S1["Stream 1
HEADERS → DATA → HEADERS → DATA"]
+ Streams --> S2["Stream 2
HEADERS → DATA"]
+ Streams --> SN["Stream N
HEADERS → DATA"]
+ end
+
+ ConnLevel -.-> Settings["SETTINGS / PING / GOAWAY
(Stream ID = 0)"]
+
+ style ConnLevel fill:#A0AEC0,color:#fff
+ style Settings fill:#ED8936,color:#fff
+ style S1 fill:#00B6BC,color:#fff
+ style S2 fill:#4FC08D,color:#fff
+ style SN fill:#FFD43B
+```
+
+**关键概念:**
+- 每个 connection 上有多个 stream,每个 stream 独立收发 data
+- 所有的 frame 都属于某个 stream(除了 connection-level 的 SETTINGS/GOAWAY/PING/WINDOW_UPDATE)
+- gRPC 只用了其中一小部分 frame 类型——它把复杂的 protocol 封装在了内部
+
+## Stream 与 Multiplexing
+
+这是 HTTP/2 最重要的特性,也是 gRPC 高性能的核心原因。
+
+**机制:**
+- 一个 TCP 连接上可以有多个 stream
+- 每个 stream 有唯一的 stream ID(奇数 = client 发起,偶数 = server 发起,ID 从 1 开始递增)
+- stream 之间互不干扰——解决了 HTTP/1.1 的队头阻塞(head-of-line blocking)
+
+### Stream ID 分配规则
+
+```mermaid
+flowchart LR
+ Client["Client"] --- TCP[("TCP Connection")]
+ TCP --- Server["Server"]
+
+ subgraph Streams ["Stream IDs"]
+ S1["1
Client→Server
Unary call"]
+ S3["3
Client→Server
Another call"]
+ S5["5
Client→Server
Streaming"]
+ S2["2
Server→Client
Response to #1"]
+ S4["4
Server→Client
Response to #3"]
+ S6["6
Server→Client
Push error"]
+ end
+
+ Client --> S1
+ Client --> S3
+ Client --> S5
+ Server --> S2
+ Server --> S4
+ Server --> S6
+
+ style S1 fill:#00B6BC,color:#fff
+ style S3 fill:#00B6BC,color:#fff
+ style S5 fill:#00B6BC,color:#fff
+ style S2 fill:#4FC08D,color:#fff
+ style S4 fill:#4FC08D,color:#fff
+ style S6 fill:#4FC08D,color:#fff
+```
+
+**要点:**
+- Stream ID 严格递增,Client 永远用奇数,Server 永远用偶数
+- 即使一个 stream 已经完成(发送了 END_STREAM flag),它的 ID 也不会回收重用
+- 理论上单条连接最多可以有 2^31 个 stream(受 Stream ID 字段限制)
+
+### 代码示例
+
+同一个 conn 上可以并发创建多个 stream,无需额外 TCP 连接:
+
+```go
+conn, _ := grpc.Dial("localhost:50051", ...) // 只有一个 TCP 连接
+client := pb.NewUserServiceClient(conn)
+
+// 这三个 call 在同一个连接的不同 stream 上并行执行
+go client.GetUser(ctx1, &pb.GetUserRequest{Id: 1}) // → Stream 1
+go client.GetUser(ctx2, &pb.GetUserRequest{Id: 2}) // → Stream 3
+go client.GetUser(ctx3, &pb.GetUserRequest{Id: 3}) // → Stream 5
+```
+
+## HPACK 头部压缩
+
+HTTP/2 使用 HPACK 算法对头部进行压缩,主要靠两个手段:
+
+1. **静态字典** — RFC 预定义了 61 个常用 header(如 `:method`, `:path`, `content-type`),直接通过索引引用
+2. **动态表** — 运行时新增的 key-value 对会被缓存,后续请求只需引用索引号
+3. **Huffman 编码** — 对无法查表的字符串做变长编码
+
+gRPC 强制要求 hpack table size >= 4096 bytes。好处是 gRPC 的请求头部通常只有几十个字节,相比 HTTP/1.1 每次都要带完整的 User-Agent/Content-Type 等大很多倍。
+
+```mermaid
+flowchart LR
+ A["原始 Header:
:method=POST
:path=/user.v1.UserService/CreateUser
content-type=application/grpc
grpc-timeout=30s"] --> B["HPACK Encoder"]
+
+ B -->|"索引引用"| D[":method → :POST (static table idx 2)"]
+ B -->|"动态表插入"| E[":path → full string
(dynamic table idx 1)"]
+ B -->|"Huffman 编码"| F["grpc-timeout=30s
(Huffman: 5 bytes)"]
+
+ D --> G["Header Block Fragment
(~15 bytes)"]
+ E --> G
+ F --> G
+
+ style A fill:#FFD43B
+ style B fill:#00B6BC,color:#fff
+ style G fill:#4FC08D,color:#fff
+```
+
+> [!example] HPACK 的实际压缩效果
+>
+> ```
+> // HTTP/1.1 头部(每次完整发送,约 200+ bytes)
+> POST /user.v1.UserService/CreateUser HTTP/1.1
+> Host: localhost:50051
+> Content-Type: application/grpc
+> Grpc-Timeout: 30s
+> User-Agent: grpc-go/1.50.0
+> Te: trailers
+>
+> // HTTP/2 + HPACK(连续多次调用,压缩后可能只剩十几字节)
+> HEADERS: {idx 2} {dynamic-table: :path=/user.v1.UserService/CreateUser} {huffman: 30s}
+> DATA:
+> ```
+>
+> 注意第二次调用时,`:method=POST` 和 `content-type=application/grpc` 都可以直接从 static table 引用——不需要再发送这些字符串。
+
+## Flow Control 流量控制
+
+HTTP/2 有两层 flow control,各自独立工作:
+
+| 层级 | 范围 | 默认窗口 | 控制方式 |
+|------|------|----------|----------|
+| Connection Level | 整个 TCP 连接 | 65535 bytes | WINDOW_UPDATE frame |
+| Stream Level | 单个 stream | 继承 connection level | WINDOW_UPDATE frame |
+
+当接收方缓冲区快满时,发送方会收到 WINDOW_UPDATE 被"堵住"——这就是 HTTP/2 的 **backpressure**。gRPC 在此基础上又加了一层自己的 flow control:
+
+| gRPC 配置项 | 说明 | 默认值 |
+|-------------|------|--------|
+| `MaxReceiveMessageSize` | 单次可接收的最大消息 | **4 MB** |
+| `MaxSendMessageSize` | 单次可发送的最大消息 | math.MaxInt32 |
+
+> [!warning] 这是一个常见的坑!
+> 当你的 protobuf message 超过 4MB 时,你会看到类似这样的错误:
+> ```
+> grpc: received message larger than max (5242880 vs 4194304) on XXX
+> ```
+> 修复方式:在 dial 或 server 选项中设置更大的 limit。
+> ```go
+> grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(10*1024*1024))
+> ```
+> 注意:这里设置的单位是 bytes。设太大也有风险——对方可能发一个巨型 payload 打爆你的内存。
+
+### 与 HTTP/2 Flow Control 的关系
+
+很多人会把两层 flow control 混淆。简单理解:
+
+- **HTTP/2 Window** 管的是「网络缓冲区」——防止发送方压垮接收方的 TCP 队列
+- **gRPC Message Size** 管的是「应用层内存」——防止 protobuf unmarshal 时 OOM
+
+两层都配好了才是真正的安全。
+
+> [!tip] 调优建议
+> - HTTP/2 Window:大多数场景不需要手动改,除非出现大量 WINDOW_UPDATE 延迟
+> - MaxReceiveMessageSize:按业务需要设定,但不要设为无上限;配合上游 LB 的 payload limit 一起考虑
+
+## 连接生命周期
+
+```mermaid
+stateDiagram-v2
+ [*] --> Idle
+ Idle --> Connecting: TCP connect
+ Connecting --> Connected: OK
+ Connecting --> ErrorFailed: Failed
+ ErrorFailed --> Closed
+
+ Connected --> TLSEncrypted: TLS + ALPN (if secure)
+ Connected --> Plaintext: Insecure mode
+
+ TLSEncrypted --> SettingsSent: Send HTTP/2 Preface + SETTINGS
+ Plaintext --> SettingsSent: Send HTTP/2 Preface + SETTINGS
+
+ SettingsSent --> Ready: Receive SETTINGS ACK
+ SettingsSent --> ErrorSettingsTimeout: Timeout
+ ErrorSettingsTimeout --> Closed
+
+ Ready --> Active: Create Streams
+ Active --> HalfClose: Stream done (END_STREAM)
+ HalfClose --> GoAwayReceived: Server sends GOAWAY
+ GoAwayReceived --> DrainPending: Wait for active streams
+ DrainPending --> Closed: All pending done
+
+ state Active {
+ StreamOpen --> Sending
+ StreamOpen --> Receiving
+ Sending --> Done
+ Receiving --> Done
+ }
+
+ note right of ErrorFailed
+ DNS 解析失败
+ TCP connect timeout
+ TLS cert invalid
+ end note
+
+ note right of ErrorSettingsTimeout
+ 对方未在规定时间内
+ 回复 SETTINGS ACK
+ end note
+```
+
+**各阶段要点:**
+
+1. **Connecting** — TCP 三次握手。如果目标地址不可达或被防火墙拦截,会在这里报 `connection refused`
+2. **TLS + ALPN** — 对于 secure 连接,先用 TLS 加密并协商出 `h2` 协议。证书过期或 host mismatch 会在这一步失败
+3. **Settings exchange** — 双方交换 window size、max concurrent streams 等参数。这是 HTTP/2 的正式握手点
+4. **Ready** — 可以开始创建 stream 了
+5. **Active** — 正常业务阶段,stream 可随时创建和销毁
+6. **GOAWAY** — 服务端通知即将关闭(如滚动重启)。已创建的 stream 还能继续完成,新 stream 不能再用这条连接
+7. **Closed** — 连接彻底关闭,下次调用时 gRPC 会自动重连(reconnect policy)
+
+> [!note] GOAWAY vs RST_STREAM 的区别
+> - **GOAWAY**: connection-level,告诉对方"这条连接以后不能新建 stream 了",属于优雅关闭
+> - **RST_STREAM**: stream-level,仅终止单个 stream,不影响同连接上的其他 stream
+
+## 对开发者的实际影响
+
+### 1. 连接数管理
+
+不需要手动连接池。gRPC 的 `grpc.ClientConn` 已经做了连接复用和自动重建:
+
+```go
+// 一个 conn 对象就够了,内部自动管理连接数和重连
+conn, _ := grpc.Dial("target", grpc.WithTransportCredentials(...))
+// 所有 client share this conn
+client1 := pb.NewService1Client(conn)
+client2 := pb.NewService2Client(conn)
+```
+
+> [!tip] 连接池误区
+> 很多从 HTTP/1.1 转过来的开发者会习惯性建连接池。但在 gRPC 里一个 `Dial` 就够——gRPC 内部维护了一个连接管理器,会根据负载情况自动增减连接。
+
+### 2. Keepalive 配置
+
+生产环境务必调优 keepalive 参数,否则可能被负载均衡器或 K8s ingress 切断连接:
+
+```go
+grpc.WithKeepaliveParams(keepalive.ClientParameters{
+ Time: 10 * time.Second,
+ Timeout: 5 * time.Second,
+ PermitWithoutStream: true, // 即使没有 active stream 也发 ping
+})
+
+grpc.KeepaliveParams(keepalive.ServerParameters{
+ Time: 10 * time.Second, // PING 间隔
+ Timeout: 20 * time.Second, // 无响应则断开
+})
+```
+
+> [!warning] PermitWithoutStream 的作用
+> 当这个值为 false 时(默认),如果当前没有任何活跃的 RPC(比如空闲等待期),gRPC 不会发 keepalive ping——此时中间设备恰好会杀掉空闲连接。生产环境建议设为 true。
+
+生产环境的推荐配置取决于你的中间件策略:
+
+| 组件 | 典型 idle timeout | 建议 keepalive Time |
+|------|-------------------|---------------------|
+| AWS ALB | 60s | 25s |
+| Nginx proxy_pass | 75s (proxy_read_timeout) | 30s |
+| K8s kube-proxy (iptables) | 根据 conntrack | 25s |
+| Cloud Load Balancer | varies | 20–30s |
+
+### 3. MaxMessageSize
+
+遇到 `"message too large"` 错误时,第一反应应该是检查这个限制,而不是怀疑代码逻辑。
+
+### 4. 调试技巧
+
+| 工具 | 用途 | 常用命令 |
+|------|------|----------|
+| `grpcurl` | CLI 工具,支持直接调用 gRPC 方法 | `grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser` |
+| `nghttp` | HTTP/2 抓包分析,能看到 frame 级别细节 | `nghttp -v http://localhost:50051` |
+| Wireshark | 原始帧级别的诊断 | filter: `tcp.port == 50051 && http2` |
+| `GRPC_VERBOSITY=DEBUG GRPC_TRACE=all` | Go 内置的 verbose trace,打印内部事件 | `export GRPC_VERBOSITY=DEBUG && export GRPC_TRACE=http2,transport,subchannel` |
+
+```bash
+# 快速测试一个 gRPC endpoint
+grpcurl -plaintext -d '{"id": 42}' localhost:50051 user.v1.UserService.GetUser
+
+# 查看服务提供的全部方法
+grpcurl -plaintext localhost:50051 list
+
+# 查看某个 service 的详细定义
+grpcurl -plaintext localhost:50051 describe user.v1.UserService
+```
+
+### 5. 常见问题排查速查表
+
+| 现象 | 可能原因 | 排查方向 |
+|------|---------|---------|
+| 偶发性 `DEADLINE_EXCEEDED` | HTTP/2 Window 满了被 backpressure 堵住 | Wireshark 看 WINDOW_UPDATE 延迟 |
+| 连接间歇性断开 | Keepalive 没开或时间太长,LB 杀了空闲连接 | 检查 `PermitWithoutStream` 和 LB timeout |
+| `"message larger than max"` | Proto message 超过 4MB 限制 | 调大 `MaxCallRecvMsgSize` |
+| `PROTOCOL_ERROR` / `INTERNAL` | 中间代理篡改了 HTTP/2 frame | 检查 Nginx / Envoy 配置,确保启用 http2 |
+| 某个 stream 卡住但不报错 | Server 端 handler 阻塞或未回写 | 用 `GRPC_TRACE=stream,transport` 定位 |
+| DNS 解析慢导致首次调用超时 | gRPC 内置 resolver 异步解析但首次调用不等 | 提前预热连接或用固定 IP |
+
+## 附录:gRPC 协议分层速览
+
+```mermaid
+block-beta
+ columns 1
+ block:App
+ columns 1
+ A1["Protobuf Message
(序列化后的 byte[])"]
+ end
+ space
+ block:GRPC
+ columns 1
+ B1["gRPC Frame
(Wire type + Length + Payload)"]
+ end
+ space
+ block:HTTP2
+ columns 1
+ C1["DATA Frame"]
+ C2["HEADERS Frame"]
+ C3["WINDOW_UPDATE Frame"]
+ end
+ space
+ block:Transport
+ columns 1
+ D1["HTTP/2 Stream
(multiplexed over one TCP)"]
+ end
+ space
+ D2["TCP Socket"]
+
+ A1 --> B1
+ B1 --> C1
+ B1 --> C2
+ B1 --> C3
+ C1 --> D1
+ C2 --> D1
+ C3 --> D1
+ D1 --> D2
+
+ style A1 fill:#FFD43B
+ style B1 fill:#00B6BC,color:#fff
+ style D1 fill:#4FC08D,color:#fff
+```
+
+## 关联笔记
+
+- [[../1. Protobuf 基础篇/01-Protobuf 语法与消息定义]]
+- [[../3. 服务端实现/08-Server 搭建与注册]]
+- [[../3. 服务端实现/09-Streaming Handler]]
+- [[../4. 客户端开发/11-Client 连接与 Dial]]
+- [[../4. 客户端开发/12-Call Options 与 Context]]
+- [[../6. 工程实践篇/20-性能优化与压测]]
diff --git a/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册.md b/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册.md
new file mode 100644
index 0000000..60969ee
--- /dev/null
+++ b/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册.md
@@ -0,0 +1,426 @@
+---
+tags: [gRPC, Go, Server, Production]
+create time: 2026-05-11 16:00
+---
+
+# Server 搭建与注册
+
+## 概述
+
+gRPC server 搭建本身很简单——`grpc.NewServer()` 加一行 `RegisterxxxServer()` 就够了。但在生产环境中你需要考虑的东西很多:TLS 配置、优雅关闭、服务发现集成、多端口暴露、reflection 开关、健康检查。我们从一个最简单的 hello world 开始,逐步构建一个 production-ready 的 server。
+
+> [!question] 为什么生产环境需要关心 graceful shutdown?
+> gRPC 基于 HTTP/2,连接是长连接。如果直接 kill 进程,所有正在处理的请求会突然断掉,client 端收到的是 TCP RST 而非一个干净的 finish。这在金融或订单系统中可能导致重复扣款、状态不一致。
+
+## 最小可运行 Server
+
+```go
+// cmd/server/main.go
+package main
+
+import (
+ "log"
+ "net"
+
+ "go.uber.org/zap"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/reflection"
+
+ pb "your/proto/gen/go"
+)
+
+func main() {
+ logged, _ := zap.NewProduction()
+ zap.ReplaceGlobals(logged)
+ defer logged.Sync()
+
+ lis, err := net.Listen("tcp", ":50051")
+ if err != nil {
+ log.Fatalf("failed to listen: %v", err)
+ }
+
+ s := grpc.NewServer()
+ pb.RegisterUserServiceServer(s, &userService{})
+
+ // Reflection for debugging — production 应关闭
+ reflection.Register(s)
+
+ zap.L().Sugar().Infow("serving", "addr", lis.Addr())
+
+ if err := s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+}
+
+// userService 实现 pb.UserServiceServer interface
+type userService struct {
+ pb.UnimplementedUserServiceServer // 嵌入 zero-stub,天然支持未来 method 新增
+}
+```
+
+核心就三步:监听端口 → 创建 server → 启动。**但** `UnimplementedUserServiceServer` 这个嵌入类型很重要——它让你无需为每个 method 写空壳,proto 文件新增 RPC method 时编译期自动报错提醒,而不是静默忽略。
+
+## Server Options(ServerOption 精选)
+
+`grpc.NewServer(...)` 接受任意数量的 `ServerOption`(函数式选项模式)。以下是高频选项速查表:
+
+| Option | 用途 | 默认值 | 建议 |
+|--------|------|--------|------|
+| `grpc.Creds(credentials)` | TLS / mTLS 认证 | 明文 | **生产必配** |
+| `grpc.MaxRecvMsgSize(n int)` | 单条接收消息上限 | 4MB | 按需调大,注意内存 |
+| `grpc.MaxSendMsgSize(n int)` | 单条发送消息上限 | 4MB | 配合 MaxRecv 对称设置 |
+| `grpc.MaxConcurrentStreams(n uint32)` | 单 stream 最大并发 HEADERS | 100 | 防 DoS,放宽到 1024+ |
+| `grpc.KeepaliveParams(kp)` | keepalive 心跳参数 | 2h idle timeout | LB 后必须调整 |
+| `grpc.ChainUnaryInterceptor(ints...)` | Unary 拦截器链 | 无 | 日志 / 鉴权 / 追踪 |
+| `grpc.StatsHandler(h stats.Handler)` | OpenTelemetry 等 stats 注入 | 无 | 链路追踪 / metrics |
+
+```go
+s := grpc.NewServer(
+ grpc.Creds(credentials.NewTLS(tlsConfig)),
+ grpc.MaxRecvMsgSize(16*1024*1024), // 16MB
+ grpc.MaxConcurrentStreams(1024), // 放宽并发流限制
+ grpc.KeepaliveParams(keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Minute,
+ KeepaliveTime: 20 * time.Second,
+ KeepaliveTimeout: 5 * time.Second,
+ MinTimeBetweenPings: 10 * time.Second,
+ PingWithoutCallsAllowed: true,
+ }),
+ grpc.ChainUnaryInterceptor(logging.Unary(), auth.Unary()),
+ // grpc.StatsHandler(otelgrpc.NewServerHandler()), // OpenTelemetry Go SDK
+)
+```
+
+> [!tip] keepalive 参数调优经验
+> 经过 LB(如 Envoy 或 Nginx)时,默认的 2 小时 idle timeout 会让 LB 提前切断连接,导致 client 侧出现 "connection reset" 错误。把 `MaxConnectionIdle` 设短到 15~20 分钟更合理——让 server 主动重建连接,确保两端对连接生命周期有一致认知。
+>
+> 直连无 LB 场景可以保持默认值,减少不必要的连接重建开销。
+
+### 拦截器链执行顺序
+
+拦截器以**尾递归**方式组合——先注册的 interceptor 包裹后注册的,形成洋葱模型:
+
+```mermaid
+flowchart LR
+ Client["Client Request"] --> Logging["Logging Interceptor
(最外层)"]
+ Logging --> Auth["Auth Interceptor
(内层)"]
+ Auth --> Recovery["Recovery Interceptor
(最内层)"]
+ Recovery --> Handler["Actual Handler"]
+
+ style Client fill:#E3F2FD
+ style Handler fill:#FFF3E0
+```
+
+```go
+// 请求到达顺序:Logging → Auth → Recovery → Handler
+// 响应返回顺序:Handler → Recovery → Auth → Logging
+grpc.ChainUnaryInterceptor(
+ logging.UnaryInterceptor(), // 第 1 层(最外)
+ auth.UnaryInterceptor(), // 第 2 层
+ recovery.UnaryInterceptor(), // 第 3 层(最内)
+)
+```
+
+**设计建议:**
+- **外层**做横切关注点:日志、追踪、metrics
+- **中层**做业务安全校验:鉴权、限流
+- **内层**做兜底逻辑:panic recover、超时控制
+
+## Service Registration
+
+### 什么是 Service Registration
+
+每次 proto 文件中的 `service` 块都会生成两个 Go 类型:
+
+1. **`Server` interface** —— 定义了你必须实现的 RPC method 集合
+2. **`RegisterServer(server, impl)` 函数** —— 将实现注册到 server 的 method dispatch table
+
+```go
+// 你定义的 .proto:
+// service UserService {
+// rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
+// rpc GetUser(GetUserRequest) returns (GetUserResponse);
+// }
+
+// 生成的接口(简化示意):
+type UserServiceServer interface {
+ CreateUser(context.Context, *pb.CreateUserRequest) (*pb.CreateUserResponse, error)
+ GetUser(context.Context, *pb.GetUserRequest) (*pb.GetUserResponse, error)
+ // UnimplementedUserServiceServer 提供 default implementation (Unimplemented)
+ mustEmbedUnimplementedUserServiceServer()
+}
+
+// 注册函数签名:
+func RegisterUserServiceServer(s *grpc.Server, srv UserServiceServer)
+```
+
+### 单 server 注册多 service
+
+不需要为每个 service 创建独立 `grpc.Server`。单个 `grpc.Server` 实例天然支持多个 service registration:
+
+```go
+s := grpc.NewServer(opts...)
+
+pb.RegisterUserServiceServer(s, NewUserService())
+pb.RegisterOrderServiceServer(s, NewOrderService())
+pb.RegisterPaymentServiceServer(s, NewPaymentService())
+
+s.Serve(lis) // 一个 listener 对外暴露三个 service
+```
+
+底层通过 method name 路由(例如 `:method/user.v1.UserService/CreateUser`),各 service 完全隔离互不干扰。
+
+### reflection:开发调试 vs 生产关闭
+
+Reflection 允许外部工具在运行时查询已注册的 service descriptor,无需 `.proto` 文件:
+
+```go
+import "google.golang.org/grpc/reflection"
+
+reflection.Register(s) // 注册 gRPC Reflection v1alpha 服务
+```
+
+**开发阶段好处:**
+- `grpcurl` 无需 `.proto` 即可列出可用 API
+- IDE 自动补全 gRPC call
+- 快速验证某个 service 是否成功注册
+
+**生产环境关闭原因:**
+- 暴露了完整的 API schema 信息(方法名、消息结构),增加攻击面
+- 额外占用内存保存所有 descriptor protobuf
+- 客户端可通过 reflection 探测内部方法名(即使是未公开的)
+
+```go
+if os.Getenv("ENV") != "production" {
+ reflection.Register(s) // 仅非生产环境开启
+}
+```
+
+## Multi-Port / Multi-Network
+
+常见需求:内网接口和外网接口分开监听,或者同时暴露 IPv4 和 IPv6:
+
+```go
+srv := grpc.NewServer(opts...)
+pb.RegisterUserServiceServer(srv, svc)
+
+lis1, _ := net.Listen("tcp", ":50051") // 内网
+lis2, _ := net.Listen("tcp", ":50052") // 外网
+
+go func() { _ = srv.Serve(lis1) }()
+go func() { _ = srv.Serve(lis2) }()
+
+<-ctx.Done()
+```
+
+> [!warning] 同一个 `grpc.Server` 不能在同一时刻被多个 goroutine 同时调用 `Serve()`(不安全)
+> 上面的写法在 gRPC-Go 中实际会导致 race condition。如果你的业务是按服务拆分到不同端口的需求,应该创建独立的 server 实例:
+
+```go
+// 正确做法:每个端口使用独立的 grpc.Server 实例
+srv1 := grpc.NewServer(opts...)
+pb.RegisterUserServiceServer(srv1, userSvc)
+
+srv2 := grpc.NewServer(opts...)
+pb.RegisterUserServiceServer(srv2, userSvc)
+pb.RegisterOrderServiceServer(srv2, orderSvc) // 外网额外暴露 OrderService
+
+go func() { _ = srv1.Serve(net.Listen("tcp", ":50051")) }() // 内网:只暴露 User
+go func() { _ = srv2.Serve(net.Listen("tcp", ":50052")) }() // 外网:暴露 User + Order
+
+<-ctx.Done()
+srv1.Stop()
+srv2.Stop()
+```
+
+这样每个 server 实例管理自己的 listener 和连接池,彼此隔离互不影响。按服务粒度差异化暴露端口是微服务架构中的常见模式——内网服务只暴露核心 RPC,网关层再聚合多服务。
+
+## Graceful Shutdown(重要!)
+
+这是最容易踩坑的部分。gRPC 提供了两种停止方法:
+
+```go
+// 优雅停止:拒绝新连接 + 等待活跃流完成
+s.GracefulStop()
+
+// 立即停止:直接断开所有连接(未完成请求会报 error)
+s.Stop()
+```
+
+典型的生产模式是结合 signal handling + context 超时控制:
+
+```go
+func run(ctx context.Context, s *grpc.Server) error {
+ go func() {
+ <-ctx.Done()
+ log.Println("shutdown signal received")
+ s.GracefulStop()
+ }()
+
+ return s.Serve(lis)
+}
+
+// 30s 超时: GracefulStop 后最多等 30s,超时则强制退出
+ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+defer cancel()
+
+if err := run(ctx, s); err != nil {
+ return err
+}
+return nil
+```
+
+**为什么需要 30s 超时?** `GracefulStop` 没有内置超时机制——如果某个 handler goroutine 因为缺少 `ctx.Done()` 检测而永远不返回,它会阻塞整个关闭流程。超时兜底确保进程最终能退出(操作系统会重新发送 SIGKILL)。
+
+GracefulStop 的执行流程如下:
+
+```mermaid
+flowchart TD
+ SIGTERM["SIGTERM Signal"] --> Handler["Signal Handler"]
+ Handler --> StopNew["停止接收新连接
新请求返回 UNAVAILABLE"]
+ StopNew --> Drain["排空活跃 Stream
等待 handler 返回"]
+ Drain --> Check{"所有流
全部完成?"}
+ Check -->|"超时"| Force["强制关闭
可能丢失未完成请求"]
+ Check -->|"是"| CleanExit["干净退出
所有回调执行完毕"]
+
+ style CleanExit fill:#00D866,color:#fff
+ style Force fill:#EE5A24,color:#fff
+```
+
+> [!danger] GracefulStop 不是万能的
+> 如果你的 handler 中没有正确检测 `ctx.Done()`,handler goroutine 永远不会结束,`GracefulStop` 最终也会卡住。所以 streaming handler 里必须遵循 ctx 驱动模式——见下一篇文章。
+
+## 完整生产级模板
+
+汇总成一个可直接复用的 bootstrap 骨架:
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "go.uber.org/zap"
+ "google.golang.org/grpc"
+ grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/keepalive"
+ "google.golang.org/grpc/reflection"
+
+ pb "your/proto/gen/go"
+)
+
+func NewGRPCServer(opts ...grpc.ServerOption) (*grpc.Server, error) {
+ // 初始化 logger(defer Sync 由调用方管理生命周期)
+ logger, _ := zap.NewProduction()
+ zap.ReplaceGlobals(logger)
+
+ creds, err := loadTLS()
+ if err != nil {
+ return nil, fmt.Errorf("tls: %w", err)
+ }
+
+ allOpts := []grpc.ServerOption{
+ grpc.Creds(creds),
+ grpc.MaxRecvMsgSize(16 * 1024 * 1024),
+ grpc.MaxConcurrentStreams(1024),
+ grpc.KeepaliveParams(keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Minute,
+ KeepaliveTime: 20 * time.Second,
+ KeepaliveTimeout: 5 * time.Second,
+ }),
+ grpc.ChainUnaryInterceptor(
+ logging.UnaryInterceptor(),
+ auth.UnaryInterceptor(),
+ ),
+ }
+ allOpts = append(allOpts, opts...)
+
+ srv := grpc.NewServer(allOpts...)
+
+ // register services
+ pb.RegisterUserServiceServer(srv, NewUserService())
+ pb.RegisterOrderServiceServer(srv, NewOrderService())
+
+ // health check
+ hs := health.NewServer()
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+ grpc_health_v1.RegisterHealthServer(srv, hs)
+
+ // reflection: dev only
+ if os.Getenv("ENV") != "production" {
+ reflection.Register(srv)
+ }
+
+ return srv, nil
+}
+
+func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ srv, err := NewGRPCServer()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ lis, err := net.Listen("tcp", ":50051")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- srv.Serve(lis)
+ }()
+
+ select {
+ case err := <-errCh:
+ return
+ case <-ctx.Done():
+ // GracefulShutdown with timeout
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ srv.GracefulStop()
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ log.Println("server stopped gracefully")
+ case <-shutdownCtx.Done():
+ log.Println("shutdown timeout, forcing stop")
+ srv.Stop()
+ }
+ }
+}
+```
+
+这个模板涵盖了:TLS、大小限制、keepalive、拦截器链、健康检查、reflection 条件开关、signal handling、graceful shutdown + 超时兜底。复制粘贴后即可投入生产使用。
+
+## 关键概念对照表
+
+| 概念 | 对应 API | 一句话总结 |
+|------|---------|-----------|
+| Server 创建 | `grpc.NewServer(...)` | 传入 ServerOption 配置行为 |
+| Service 注册 | `RegisterXxxServer(s, impl)` | 将实现绑定到 server dispatch table |
+| 零-stub 兼容 | 嵌入 `UnimplementedXxxServer` | proto 新增 method 编译期自动告警 |
+| 健康检查 | `health.NewServer()` | Kubernetes probe / SLA 监控对接 |
+| 调试反射 | `reflection.Register(s)` | 仅限开发环境 |
+| 优雅关闭 | `GracefulStop()` + 超时 | 等活跃请求完成,超时无情斩断 |
+
+## 关联笔记
+
+- [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
+- [[hhs/gRPC/3. 服务端实现/10-健康检查与反射]]
+- [[hhs/gRPC/1. 基础概念/02-gRPC 核心术语]]
diff --git a/hhs/gRPC/3. 服务端实现/09-Streaming Handler.md b/hhs/gRPC/3. 服务端实现/09-Streaming Handler.md
new file mode 100644
index 0000000..b5a4d98
--- /dev/null
+++ b/hhs/gRPC/3. 服务端实现/09-Streaming Handler.md
@@ -0,0 +1,521 @@
+---
+tags: [gRPC, Go, Streaming, RPC]
+create time: 2026-05-11 16:30
+---
+
+# Streaming Handler
+
+## 概述
+
+本文档讲解 gRPC 三种 streaming RPC handler 的编写模式——**Server Streaming**、**Client Streaming** 和 **Bidirectional Streaming**。核心主题是循环内的生命周期管理:如何正确感知 client 断开、如何在大数据量下避免 goroutine 泄漏、以及如何优雅地处理背压。
+
+> [!warning] Streaming handler 最大的陷阱:goroutine 泄漏
+> 如果你在 loop 里 spawn 了子 goroutine 但没有正确的退出机制,一旦 client 断开连接,server 端的 goroutine 会永远无法回收。**下面每一个例子都会标注如何避免这个问题。**
+
+## Server Streaming Handler
+
+Server streaming:client 发一次请求,server 持续返回多条响应。
+
+```go
+func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
+ users := getAllUsers() // 从 DB / cache 获取
+
+ for _, u := range users {
+ if err := stream.Send(&pb.ListUsersResponse{User: u}); err != nil {
+ // client 已经断开或主动取消
+ s.logger.Warn("send failed", "error", err)
+ return status.Error(codes.Internal, err.Error())
+ }
+ }
+ return nil // 正常结束:数据全部发送完毕
+}
+```
+
+### 核心要点
+
+- Server 通过 `stream.Send()` 逐条发送,每次 Send 都会编码 Protobuf 并通过 HTTP/2 frame 推送到 client
+- **`stream.Context()` 是唯一可靠的退出信号** —— 当 client cancel 或网络中断时,context 会被取消
+- 正常结束(遍历完全部数据)返回 `nil`;断连时 context 报错
+- 不要在 loop 之外做 cleanup,否则即使 send 失败也会执行不必要的清理
+
+### 大数据量场景下的超时检测
+
+上面的简单示例只适用于结果集可控的场景。如果用户量大(比如百万级),需要在循环中**主动检查 context** 提前退出:
+
+```go
+func (s *server) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
+ ctx := stream.Context()
+ results := hugeQuery(ctx) // 可能返回大量数据
+
+ for i, u := range results {
+ // 每发送 100 条检查一下 context
+ if i >= 100 && len(results) >= 1000 {
+ select {
+ case <-ctx.Done():
+ return ctx.Err() // client 已取消
+ default:
+ }
+ }
+
+ if err := stream.Send(&pb.ListUsersResponse{User: u}); err != nil {
+ return status.Error(codes.Internal, err.Error())
+ }
+ }
+ return nil
+}
+```
+
+这里有个设计取舍:**每次 Send 都检查 vs 每隔 N 次检查**。全检查保证响应快但多了 select 开销;间隔检查减少 CPU 消耗但有延迟。根据业务对"取消感知的灵敏度"来决定。
+
+### 数据流转时序
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant S as Server Handler
+ participant DB as Data Source
+
+ C->>S: Send(ListUsersRequest)
+ S->>DB: Query All Users
+ DB-->>S: []User
+
+ loop For Each User
+ alt Client Still Connected
+ S->>S: Check Context
+ S->>C: Send(User)
+ else Context Cancelled
+ S->>S: Return ctx.Err()
+ end
+ end
+
+ alt Normal Completion
+ C->>S: (no more data)
+ S-->>C: Return nil (EOF)
+ else Send Failed
+ S-->>C: Return error Status
+ end
+```
+
+## Client Streaming Handler
+
+Client streaming:client 持续发送多条请求,server 汇总后返回一次响应。
+
+```go
+func (s *server) UploadData(stream pb.UserService_UploadDataServer) (*pb.UploadResult, error) {
+ ctx := stream.Context()
+ var totalBytes int64
+ var processedItems []*Item
+
+ for {
+ chunk, err := stream.Recv()
+ if err == io.EOF {
+ break // client 结束了发送
+ }
+ if err != nil {
+ // client 主动取消或网络异常 —— 不算业务错误,直接退出
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ return nil, status.Errorf(codes.InvalidArgument, "recv failed: %v", err)
+ }
+
+ totalBytes += int64(len(chunk.Data))
+ processedItems = append(processedItems, chunk.ToItem())
+ // TODO: 批量写入 DB,不要每来一条就 flush
+ }
+
+ return &pb.UploadResult{TotalBytes: totalBytes, Count: int32(len(processedItems))}, nil
+}
+```
+
+### 核心要点
+
+- 用 `io.EOF` 判断 client 结束发送(注意:是 `io.EOF`,不是其他 error)
+- **Recv 的 error 可能来自两种情况**:client 正常关闭(`io.EOF`)或 client 取消/断连。后者不应视为业务错误,而应直接通过 `ctx.Err()` 退出
+- Recv 循环必须在 EOF 之前 `return` 或 `break`,否则会无限阻塞
+- 所有临时状态放在 loop 内,返回前一次性聚合结果
+
+### 内存安全注意事项
+
+当 client 发送的数据量不可控时,**必须限制内存使用**:
+
+```go
+const maxBufferSize = 100 * 1024 * 1024 // 100 MB limit
+
+func (s *server) UploadData(stream pb.UserService_UploadDataServer) (*pb.UploadResult, error) {
+ var totalBytes int64
+ var items []*Item // ⚠️ 生产环境应写流到磁盘而非放内存
+
+ for {
+ chunk, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, status.Errorf(codes.InvalidArgument, "recv failed: %v", err)
+ }
+
+ totalBytes += int64(len(chunk.Data))
+ if totalBytes > maxBufferSize {
+ return nil, status.Errorf(codes.ResourceExhausted,
+ "exceeds max upload size: %d bytes", maxBufferSize)
+ }
+
+ items = append(items, chunk.ToItem())
+ }
+
+ return aggregateResult(items), nil
+}
+```
+
+> [!tip] 何时该用临时文件?
+> 如果单条消息超过 10MB 或者总量难以预估,直接将 `chunk.Data` 写到一个 `os.File` 中比存在内存切片里安全得多。handler 返回前一次性处理临时文件即可。
+
+## Bidirectional Streaming Handler
+
+Bidirectional streaming 是最复杂的模式:收和发两个方向完全独立,各自有自己的生命周期。
+
+```go
+func (s *server) ChatRoom(stream pb.UserService_ChatRoomServer) error {
+ ctx := stream.Context()
+ inboundCh := make(chan *pb.ChatMessage, 100)
+ errCh := make(chan error, 1) // 错误传播通道
+
+ // ---- 接收方向(后台 goroutine)----
+ go func() {
+ defer close(inboundCh)
+ for {
+ msg, err := stream.Recv()
+ if err != nil {
+ errCh <- err // EOF / cancel / network error
+ return
+ }
+ select {
+ case inboundCh <- msg:
+ default:
+ // channel full, drop message
+ }
+ }
+ }()
+
+ // ---- 发送方向(主循环)----
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err() // client 断了
+ case err := <-errCh:
+ return err // recv 出错,主循环退出
+ case envelope := <-inboundCh:
+ envelope := transform(envelope) // 业务转换
+ if err := stream.Send(envelope); err != nil {
+ return err
+ }
+ }
+ }
+}
+```
+
+### 核心架构
+
+```mermaid
+flowchart TB
+ subgraph "Handler Goroutine"
+ S["Send Loop
for-select"]
+ end
+
+ subgraph "Recv Goroutine"
+ R["Recv Loop"] -->|"inboundCh buffered"| B["Business Logic"]
+ end
+
+ R --> stream
+ S --> stream
+ B -->|"outboundCh buffered"| S
+
+ stream["HTTP/2 Stream"]
+
+ style S fill:#4FC08D,color:#fff
+ style R fill:#00B6BC,color:#fff
+ style B fill:#FF9F43,color:#000
+```
+
+### 关键设计原则
+
+1. **Context.Done() 是唯一可靠的退出信号** — 不管哪个方向出错,最终都要通过 context 来协调
+2. **收和发是两个独立的生命周期** — recv 在一个 goroutine,send 在主循环
+3. **务必给 channel 设置 buffer**,否则 sender 堵住时 receiver 也会饿死
+
+### 背压策略选择
+
+channel full 时如何处理,决定了系统的韧性:
+
+| 策略 | 实现方式 | 适用场景 | 风险 |
+|------|---------|---------|------|
+| **Drop** | `select + default`(如上文) | 实时性优先,丢消息可接受 | 丢失关键消息 |
+| **Block** | 普通 `ch <- msg` | 吞吐量重要,允许等待 | receiver 被拖慢 |
+| **Backpressure** | `select + ctx.Done()` | 需要优雅降级 | 代码略复杂 |
+| **Reject** | 统计丢弃数,超标后返回 error | 有 SLA 要求的系统 | 直接中断连接 |
+
+**推荐**大多数场景用 Backpressure 模式——超时未消费则丢弃,连续丢弃过多时通过 errCh 通知主循环终止:
+
+```go
+func (s *server) ChatRoom(stream pb.UserService_ChatRoomServer) error {
+ ctx := stream.Context()
+ inboundCh := make(chan *pb.ChatMessage, 100)
+ errCh := make(chan error, 1)
+
+ go func() {
+ defer close(inboundCh)
+ drops := 0
+ for {
+ msg, err := stream.Recv()
+ if err != nil {
+ errCh <- err
+ return
+ }
+ select {
+ case inboundCh <- msg:
+ case <-time.After(10 * time.Millisecond):
+ drops++
+ if drops > 1000 { // 连续丢弃过多,通知主循环终止
+ errCh <- status.Errorf(codes.ResourceExhausted,
+ "too many messages dropped")
+ return
+ }
+ }
+ }
+ }()
+
+ // 主循环同上,新增对 errCh 的监听即可
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-errCh:
+ return err
+ case envelope := <-inboundCh:
+ if err := stream.Send(envelope); err != nil {
+ return err
+ }
+ }
+ }
+}
+```
+
+> [!tip] bidirectional streaming 的设计直觉
+> 想象成一个管道:一端进水(Recv)、一端出水(Send)。中间的业务逻辑可以是任意的转换、过滤、聚合。但管壁(context)不能漏。
+
+## 错误处理对照表
+
+streaming handler 中的错误来源复杂,不同错误的处理方式也不同:
+
+| 错误来源 | stream.Recv 返回值 | 处理方式 |
+|----------|-------------------|---------|
+| Client 正常关闭连接 | `io.EOF` | `break` 或 `return nil` |
+| Client 主动取消请求 | `context.Canceled`(包装为普通 error) | 记录日志,`break` |
+| Network 中断 | 非 nil error(含 canceled) | 记录日志,`break` |
+| 数据格式错误 | 具体 error | 原样返回给 client |
+| 业务校验失败 | `status.Errorf(codes.InvalidArgument, ...)` | 返回具体错误码,终止循环 |
+| 数据量超限 | `status.Errorf(codes.ResourceExhausted, ...)` | 返回错误码,终止循环 |
+
+> [!note] 关于 Recv 返回 error 的细节
+> gRPC-go 会把 `context.Canceled` 包装成普通 error 返回,不会返回 `io.EOF`。所以判断 client 断开应该同时检查两种 case。
+
+```mermaid
+flowchart TD
+ A["Recv() returns error?"] -->|"No"| B["继续处理消息"]
+ A -->|"Yes"| C{"err == io.EOF?"}
+ C -->|"Yes"| D["client 正常关闭
return nil ✅"]
+ C -->|"No"| E{"codes.FromError is OK?"}
+ E -->|"Yes"| F["canceled / network issue
return ctx.Err() ⚠️"]
+ E -->|"No"| G["真实错误
return status.Errorf ❌"]
+```
+
+## Context 超时传播
+
+Streaming handler 中,context 超时由 client 侧的 WithTimeout 控制,server 必须主动感知并退出:
+
+```go
+func (s *server) LongRunningOp(req *pb.LongRequest, stream pb.Service_LongRunningOpServer) error {
+ ctx := stream.Context()
+
+ for i := 0; i < 100; i++ {
+ // 每一步都检查 context
+ select {
+ case <-ctx.Done():
+ return ctx.Err() // client 取消了
+ default:
+ }
+
+ result := doStep(i)
+ if err := stream.Send(result); err != nil {
+ return err // send 失败也立即退出
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ return nil
+}
+```
+
+这个模式的精髓是 **"每一次 IO 操作前都检查 context"**。只要有一次遗漏,就可能变成孤儿 goroutine。
+
+## 关闭与连接检测
+
+在 streaming 场景中,理解"如何感知对端断开"比"如何发消息"更重要。
+
+### Client 关闭的信号路径
+
+```mermaid
+flowchart LR
+ A["Client 正常关闭发送端"] -->|"Recv() → io.EOF"| B["handler return nil ✅"]
+ C["Client Cancel context"] -->|"Recv() → ctx.Err()"| D["handler return ctx.Err() ⚠️"]
+ E["网络断开"] -->|"Recv() → connection error"| F["handler return error ⚠️"]
+ G["不读 response"] -->|"Send() → stream error"| H["handler return error ❌"]
+```
+
+| Client 行为 | Server 感知现象 | handler 处理 |
+|-------------|----------------|-------------|
+| 正常关闭发送端 | `Recv()` 返回 `io.EOF` | `return nil` ✅ |
+| Cancel context | `Recv()` 返回 context canceled | `return ctx.Err()` ⚠️ |
+| 网络断开 | `Recv()` 返回连接相关 error | `return err` ⚠️ |
+| 不读 response | `Send()` 返回 stream error | `return err` ❌ |
+
+**核心原则**:无论哪种情况,handler 的正确做法都是尽快 return,让 gRPC 内部清理资源。不需要手动 close channel 或做其他清理。
+
+### 为什么不存在 CloseSend 通知 channel
+
+有些开发者误以为 gRPC 提供了类似 `CloseSend() chan struct{}` 的通知通道。实际上 **gRPC-Go 并没有这样的 API**。Server 只能通过以下两种方式检测 client 停止发送:
+
+1. **`Recv() io.EOF`** —— client 正常调用 `CloseSend()` 后
+2. **`Recv() error`** —— context 取消或网络异常
+
+任何依赖"某个 channel 被关闭"来判断的模式都是不可靠的。
+
+## 资源清理模式
+
+Streaming handler 中经常需要订阅外部事件源(DB watcher、消息队列等),资源清理是关键:
+
+```go
+func (s *server) WatchEvents(req *pb.WatchRequest, stream pb.Service_WatchServer) error {
+ ctx := stream.Context()
+
+ // 订阅事件流 —— 必须传 context
+ ch, cancel := s.eventBroker.Subscribe(ctx, req.GetFilter())
+ defer cancel() // handler 返回时自动释放订阅
+
+ for {
+ select {
+ case <-ctx.Done():
+ return nil // client 断了
+ case event, ok := <-ch:
+ if !ok {
+ return nil // broker 关闭了 channel
+ }
+ if err := stream.Send(event); err != nil {
+ return err // send 失败
+ }
+ }
+ }
+}
+```
+
+要点总结:
+
+1. `Subscribe(ctx, ...)` 一定要传 context,不能用 `context.Background()`
+2. `defer cancel()` 确保 handler 退出时释放订阅
+3. select 中始终优先检测 `ctx.Done()`
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant H as Handler
+ participant EB as Event Broker
+ participant S as Stream
+
+ H->>EB: Subscribe(ctx, filter)
+ EB-->>H: ch, cancel
+
+ loop Until Disconnected
+ alt Client Still Connected
+ H->>H: select { ctx.Done(), <-ch }
+ EB->>H: event
+ H->>S: Send(event)
+ else Client Disconnected
+ H->>H: ctx.Done() triggers
+ H->>H: defer cancel()
+ H->>EB: Unsubscribe via cancel
+ H-->>C: Return nil
+ end
+ end
+```
+
+## 生产实践要点
+
+写完 handler 只是第一步,生产环境还需要考虑以下方面:
+
+### 背压与速率限制
+
+- **Server Streaming** 中如果 client 读取速度慢于 server 发送速度,gRPC 会在 TCP/HTTP2 层积压数据。建议:监控 Send 耗时,如果 P99 持续升高则降低发送频率或加入 rate limiter
+- **Client Streaming** 中可通过 `maxReceiveMessageSize` dial option 限制最大消息大小
+
+### 监控指标
+
+在 streaming handler 中埋点三个关键指标:
+
+```go
+// 1. handler 存活时长
+duration := time.Since(start)
+histogram.Record("grpc.stream.duration", duration.Milliseconds())
+
+// 2. 发送/接收消息计数
+counter.Increment("grpc.stream.messages_sent")
+
+// 3. 非正常终止次数
+counter.Increment("grpc.stream.errors", errorCode)
+```
+
+### 优雅关闭(Graceful Stop)
+
+当 server 要 shutdown 时,不能直接 kill 正在执行的 streaming handler。正确流程:
+
+1. 调用 `grpc.Server.GracefulStop()` —— 拒绝新连接
+2. 等待已有 handler 自然返回(可以设 deadline)
+3. 超时后强制 shutdown
+
+```go
+srv.GracefulStop() // 停止接收新请求
+// 或带超时的优雅关闭:
+stopCh := make(chan struct{})
+go func() {
+ srv.GracefulStop()
+ close(stopCh)
+}()
+select {
+case <-stopCh:
+ log.Println("all handlers finished")
+case <-time.After(30 * time.Second):
+ srv.Stop() // 强制杀
+ log.Println("force stopped after timeout")
+}
+```
+
+## 常见问题 Checklist
+
+写完 streaming handler 后逐项核对:
+
+- [ ] **Recv 错误分清了 `io.EOF` 和 `ctx.Err()` 吗?**(正常结束 vs client 取消的处理不同)
+- [ ] Context 取消了吗?(`ctx.Done()` 在所有关键路径被检测)
+- [ ] Send 失败正确处理了吗?(不会因为 send 错误继续无效循环)
+- [ ] goroutine 会泄漏吗?(所有 spawn 的 goroutine 都有明确退出条件,bidirectional 中 recv goroutine 的错误能传播到主循环)
+- [ ] Channel 有 buffer 吗?(bidirectional 模式下 sender/receiver 互相不饿死)
+- [ ] 内存有上限吗?(client streaming 中对累积的消息数量做了限制)
+- [ ] **消息大小有限制吗?**(gRPC 默认 4MB 限制,超出会直接报错)
+- [ ] Subscribe 传了 context 吗?(外部事件源随 handler 一起退出)
+- [ ] 大数据集做了间隔检测吗?(百万级数据不会无意义地每个元素都 select)
+- [ ] 背压策略选对了吗?(drop/block/backpressure/reject 符合业务需求)
+
+## 关联笔记
+
+- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册]]
+- [[hhs/gRPC/3. 服务端实现/10-健康检查与反射]]
+- [[hhs/gRPC/4. 客户端开发/13-Streaming Client]]
+- [[hhs/gRPC/6. 工程实践篇/20-性能优化与压测]]
diff --git a/hhs/gRPC/3. 服务端实现/10-健康检查与反射.md b/hhs/gRPC/3. 服务端实现/10-健康检查与反射.md
new file mode 100644
index 0000000..43f2e47
--- /dev/null
+++ b/hhs/gRPC/3. 服务端实现/10-健康检查与反射.md
@@ -0,0 +1,361 @@
+---
+tags: [gRPC, Go, Health Check, Debug, Reflection]
+create time: 2026-05-11 16:00
+---
+
+# 健康检查与反射
+
+## 概述
+
+生产级 gRPC 服务需要具备两大能力:**可观测性**(知道服务是否存活)和 **可调试性**(了解服务暴露了哪些 API)。gRPC 生态提供了两个独立的内置模块来解决这些问题:
+
+- **Health Check v1 API** — 标准协议层健康探针,供 K8s / LB / 运维工具查询服务状态
+- **Server Reflection** — 开发阶段的内省机制,允许客户端动态枚举所有 service、method 和 message 定义
+
+这两个功能完全独立、可分别启用。核心原则是:**生产环境必须开 Health Check、关闭 Reflection**。
+
+> [!question] 为什么不用 HTTP health endpoint?
+> 你可以同时暴露 HTTP 和 gRPC health,但用 gRPC 统一的好处是:不需要维护两套协议、负载均衡器可以直接用 gRPC probe、而且 gRPC Health Check 支持 Watch 模式实现实时监控。如果你只有 gRPC 服务而没有 HTTP sidecar,那 gRPC health 就是唯一选择。
+
+## Health Check Protocol Buffer
+
+gRPC 官方定义了标准 Health Check proto,位于 grpc-go 源码树中(Go 包路径 `google.golang.org/grpc/health/grpc_health_v1`),无需单独下载 .proto 文件:
+
+> [!tip] 其他语言如何引用?
+> Health Check proto 也开源在 [`grpc/grpc-proto`](https://github.com/grpc/grpc-proto/tree/master/grpc/health/v1) 仓库。protobuf / Java / Python 等语言的客户端可以直接克隆该仓库并使用 `protoc` 编译,或通过 maven / pip 等包管理器引入对应 stub。
+
+```protobuf
+syntax = "proto3";
+package grpc.health.v1;
+
+message HealthCheckRequest {
+ string service = 1;
+}
+
+message HealthCheckResponse {
+ enum ServingStatus {
+ UNKNOWN = 0;
+ SERVING = 1;
+ NOT_SERVING = 2;
+ SERVICE_UNKNOWN = 3;
+ UNAVAILABLE = 4;
+ }
+ ServingStatus status = 1;
+}
+
+service Health {
+ rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
+ rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
+}
+```
+
+> [!tip] ServingStatus 的含义
+> - **SERVING**: 服务正常对外提供 RPC
+> - **NOT_SERVING**: 服务拒绝新请求(正在关机、依赖断裂等)
+> - **SERVICE_UNKNOWN**: 查询的 service name 未注册
+> - **UNAVAILABLE**: 内部错误导致无法判断状态
+
+两个端点的区别:
+
+| 端点 | 类型 | 用途 |
+|------|------|------|
+| `Check` | Unary RPC | 瞬时查询,适合 liveness/readiness probe |
+| `Watch` | Server Streaming | 持续监听状态变化,适合 dashboard / alerting |
+
+## 在 Go 中实现 Health Service
+
+```go
+import (
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/health/grpc_health_v1"
+)
+
+func main() {
+ s := grpc.NewServer()
+
+ // 创建 health server 实例
+ healthServer := health.NewServer()
+
+ // 按 service name 注册健康状态
+ healthServer.SetServingStatus("user.v1.UserService", grpc_health_v1.HealthCheckResponse_SERVING)
+ healthServer.SetServingStatus("order.v1.OrderService", grpc_health_v1.HealthCheckResponse_SERVING)
+
+ // root service "" 表示整个进程级别的健康状态
+ healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+
+ // 挂载到 gRPC server —— 这一步自动启用了 Check 和 Watch
+ grpc_health_v1.RegisterHealthServer(s, healthServer)
+}
+```
+
+> [!note] 关于 `RegisterHealthServer`
+> 调用此方法后,Check 和 Watch 两个 RPC 都会自动注册到你的 server 上。**不需要手动编写任何 Watch handler** —— `health.NewServer()` 内部使用 watcher map + channel 实现了完整的 Watch 逻辑。你只需要调用 `SetServingStatus(key, status)` 来更新状态即可。
+
+关键细节:
+
+- `""` (空字符串)表示整个进程级别的健康状态,是 K8s 最常用的探测目标
+- `"user.v1.UserService"` 这种带 package 的路径可以精确到某个具体 service
+- `SetServingStatus` 可随时调用,不需要重启 server
+
+> [!warning] 常见误区:不要手写 Watch handler
+> 有些教程展示了手写的 Watch stream loop,但那是不必要的——官方库已实现完毕。除非你有极其特殊的需求(比如自定义状态变更事件源),否则直接用 `health.NewServer()` 提供的默认行为即可。
+
+## Health Check 使用场景
+
+### Kubernetes Probe
+
+```yaml
+apiVersion: v1
+kind: Pod
+spec:
+ containers:
+ - name: my-service
+ readinessProbe:
+ grpc:
+ port: 50051
+ service: "" # 对应 SetServingStatus("", SERVING)
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ grpc:
+ port: 50051
+ service: ""
+ failureThreshold: 3
+```
+
+Kubernetes 原生支持 gRPC probe,直接调用 `grpc_health_v1.Health.Check` 即可,无需额外的 HTTP endpoint。
+
+> [!tip] Readiness vs Liveness
+> - **Readiness probe**: 决定是否将流量接入 Pod。建议设置合理的 `initialDelaySeconds`,避免启动期间被误杀。
+> - **Liveness probe**: 决定是否需要重启 Pod。不要用它检测临时性故障,否则会导致不必要的重启循环。
+
+### Load Balancer 摘机判断
+
+负载均衡器(如 Envoy)支持 gRPC health checking protocol。当服务返回 `NOT_SERVING` 时,LB 会自动将该实例从后端池中摘除。
+
+```yaml
+# Envoy lb_config —— 仅示意,完整配置参考 Envoy docs
+common_lb_config:
+ health_check:
+ grpc: {} # 使用 gRPC health check protocol
+ timeout: 5s
+ interval: 10s
+ unhealthy_threshold: 3
+ healthy_threshold: 2
+```
+
+### 蓝绿部署流程
+
+```mermaid
+sequenceDiagram
+ participant K8s as "K8s Scheduler"
+ participant HP as "Health Probe"
+ participant HS as "Health Server"
+ participant SS as "Serving Status"
+ participant LB as "Load Balancer"
+
+ K8s->>HP: Every 10s Call Check("")
+ HP->>HS: rpc Check(service="")
+ HS->>SS: query status
+ SS-->>HS: SERVING
+ HS-->>HP: HealthCheckResponse{SERVING}
+ HP-->>K8s: OK
+
+ alt service shutdown begins
+ K8s->>SS: SetServingStatus("", NOT_SERVING)
+ Note over K8s,SS: draining pods or rolling update
+ HP->>HS: rpc Check(service="")
+ HS->>SS: query status
+ SS-->>HS: NOT_SERVING
+ HS-->>HP: HealthCheckResponse{NOT_SERVING}
+ HP-->>K8s: FAIL x3 -> restart/drain
+ end
+
+ style OK fill:#00D866,color:#fff
+ style FAIL fill:#EE5A24,color:#fff
+```
+
+## Server Reflection
+
+启用 reflection 后,外部工具可以通过 gRPC 协议枚举所有 service、method、message 定义。这在开发和测试阶段非常有用。
+
+```go
+// 方式 A:显式注册(推荐,可读性好)
+import "google.golang.org/grpc/reflection"
+reflection.Register(s)
+
+// 方式 B:blank import(利用 init() 自动注册)
+import _ "google.golang.org/grpc/reflection"
+```
+
+启用 reflection 后可以做这些操作:
+
+- 枚举所有已注册的 services 和 methods
+- 查看 message 的字段类型和编号
+- 构造请求进行交互式测试
+- IDE 自动补全 gRPC call
+
+> [!warning] 生产环境务必关闭 Reflection
+> **Reflection 开启后存在多重安全隐患**:
+> 1. **API 枚举**:攻击者可以获取全部 service、method 名称,了解业务逻辑结构
+> 2. **Message Schema 泄露**:暴露所有 message 的字段类型和编号,辅助构造恶意请求
+> 3. **无需认证**:Reflection RPC 本身没有鉴权机制,任何能连接 gRPC port 的请求都能使用
+>
+> 如果需要在生产环境调试,考虑使用专门的 tracing / metrics / audit logging 方案替代。
+
+## 调试命令示例
+
+安装 [grpcurl](https://github.com/fullstorydev/grpcurl) 后可直接使用:
+
+```bash
+# 列出所有已注册的服务
+grpcurl -plaintext localhost:50051 list
+
+# 输出:
+# grpc.health.v1.Health
+# user.v1.UserService
+# order.v1.OrderService
+
+# 查看某个服务的完整定义
+grpcurl -plaintext localhost:50051 describe user.v1.UserService
+
+# 查看 message 结构
+grpcurl -plaintext localhost:50051 describe user.v1.CreateUserRequest
+
+# 调用 RPC(注入 request body)
+grpcurl -plaintext \
+ -d '{"name":"test","email":"test@example.com"}' \
+ localhost:50051 user.v1.UserService/CreateUser
+
+# 调用 Health Check
+grpcurl -plaintext -d '{"service":""}' \
+ localhost:50051 grpc.health.v1.Health/Check
+
+# 输出:
+# {
+# "status": "SERVING"
+# }
+```
+
+## Production Setup
+
+生产环境的正确姿势:开启 health check,关闭 reflection。
+
+```go
+func NewProdServer() *grpc.Server {
+ s := grpc.NewServer(
+ grpc.Creds(credentials.NewTLS(tlsConfig)),
+ grpc.MaxRecvMsgSize(16*1024*1024),
+ grpc.ChainUnaryInterceptor(logging.Unary(), auth.Unary()),
+ )
+
+ // 注册业务 service
+ registerServices(s)
+
+ // Health check always on in production
+ hs := health.NewServer()
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+ grpc_health_v1.RegisterHealthServer(s, hs)
+
+ // NO reflection in production!
+ // reflection.Register(s) // <-- commented out
+
+ return s
+}
+```
+
+对比表格:
+
+| 环境 | Reflection | Health Check | Reason |
+|------|-----------|--------------|--------|
+| Local Dev | On | Optional | 方便调试 |
+| Staging | Optional | On | 接近生产行为 |
+| Production | Off | On | 安全 + 运维需求 |
+
+## Watch 端点的高级用法
+
+`Watch` 是一个 server streaming 端点,比 unary `Check` 强大得多:**客户端建立连接后,服务器会在状态变化时主动推送新值,而无需客户端反复轮询**。
+
+在 Go 中获取 Watch 流非常简单——只需调用 `grpc_health_v1.NewHealthClient` 然后执行 `Watch` RPC:
+
+```go
+// Client 端:订阅 root service 的健康状态变化
+client := grpc_health_v1.NewHealthClient(conn)
+
+ctx, cancel := context.WithCancel(context.Background())
+defer cancel()
+
+watchStream, err := client.Watch(ctx, &grpc_health_v1.HealthCheckRequest{
+ Service: "",
+})
+if err != nil {
+ log.Fatal(err)
+}
+
+for {
+ resp, err := watchStream.Recv()
+ if err != nil {
+ log.Printf("watch error: %v", err)
+ return
+ }
+ log.Printf("status changed to: %s\n", resp.Status)
+ // → SERVING → NOT_SERVING → SERVING ...
+}
+```
+
+应用场景:
+
+- **Metrics 采集器**:实时消费状态变化,更新 prometheus histogram
+- **Dashboard**:前端 WebSocket 推送背后可以用 gRPC Watch 替代
+- **Alerting System**:检测到 NOT_SERVING 时立即触发告警,比轮询更高效
+
+```mermaid
+sequenceDiagram
+ participant Obs as "Observer (Prometheus / Dashboard)"
+ participant HS as "Health Server"
+ participant Store as "Serving Status Map"
+
+ Obs->>HS: Watch(service="") → stream created
+ HS->>Store: Register watcher with channel
+ Store-->>HS: Immediate: SERVING
+ HS-->>Obs: Send(SERVING)
+
+ Note over Store: Later... admin triggers shutdown
+
+ Store->>HS: Notify all watchers: NOT_SERVING
+ HS-->>Obs: Send(NOT_SERVING)
+
+ Note over Obs: Alert fires within one notification cycle
+
+ Obs->>HS: Close stream (context cancelled)
+ HS->>Store: Unregister watcher
+```
+
+## 生产环境故障排查
+
+部署后遇到问题?以下是高频场景的速查表:
+
+> [!faq] Probe 一直失败 (NOT_SERVING),但服务实际正常运行
+> **原因**:可能是启动速度太快,probe 在初始化完成之前就发起探测。解决:增大 `initialDelaySeconds`,或在初始化完成后才调用 `SetServingStatus("", SERVING)`。
+
+> [!faq] 为什么 Check 返回 SERVING 但接口实际不可用?
+> **原因**:`SetServingStatus` 只在内存中标记状态,不会检查依赖服务(DB、Redis 等)是否可用。如果需要深度健康检查,应该在 `Check` handler 中主动校验依赖项后再返回 `NOT_SERVING`。
+
+> [!faq] 切换服务版本时,老版本还在收流量
+> **原因**:K8s 删除 Pod 需要时间。正确做法是先调 `SetServingStatus("", NOT_SERVING)` 通知 LB 摘机,等业务请求归零后,再执行 Pod 删除。这叫 **graceful drain**。
+
+```go
+// graceful drain 示例:Shutdown hook
+func GracefulStop(server *grpc.Server, healthServer *health.Server) {
+ // Step 1: 告诉 health check 我们不再 serving
+ healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
+
+ // Step 2: 停止接收新连接(但不中断已有请求)
+ server.GracefulStop()
+}
+```
+
+## 关联笔记
+
+- [[hhs/gRPC/3. 服务端实现/08-Server 搭建与注册]]
+- [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
diff --git a/hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial.md b/hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial.md
new file mode 100644
index 0000000..e616c81
--- /dev/null
+++ b/hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial.md
@@ -0,0 +1,308 @@
+---
+tags: [gRPC, Go, Dial, Connection, TransportCredentials, Keepalive, Retry]
+create time: 2026-05-11 15:30
+---
+
+# Client 连接与 Dial
+
+## 概述
+
+gRPC client 的连接建立在 `grpc.Dial` 上,但 dial 只是起点——真正的连接管理、自动重连逻辑、负载均衡和地址解析都在背后的子系统里。理解这些机制能让你避免绝大多数生产环境下的连接问题,而不是把时间浪费在反复调试"为什么偶尔超时"上。
+
+> [!question] 为什么一个 conn 就能代替连接池?
+> HTTP/1.1 时代我们需要手动维护 http.Client 的 Transport 来复用 TCP 连接。但 gRPC 的设计哲学不同——它把连接生命周期完全封装在 `grpc.ClientConn` 内部,你只需要调好 dial options 就够了。后续章节会详细拆解这个内部是怎么工作的。
+
+## Basic Dial
+
+```go
+import (
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+conn, err := grpc.Dial(
+ "localhost:50051",
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+)
+if err != nil {
+ log.Fatalf("dial failed: %v", err)
+}
+defer conn.Close() // 必须调用,释放底层 goroutine 和文件描述符
+```
+
+- **target** 支持多种 scheme:`dns:///host:port`、`unix:///path`、`ip://...`。不指定 scheme 时默认走 DNS resolver。
+- `grpc.Dial()` 是 **non-blocking** 的——它立即返回 `conn` 对象,后台异步建立连接。此时不能立即发起 RPC,否则会排队等待连接就绪。
+- **必须**在进程退出前调用 `conn.Close()`,否则 goroutine 泄漏(每个 subchannel 都会启动 keepalive timer、reconnect goroutine)。
+- ❌ gRPC v1.35+ 已废弃 `grpc.WithInsecure()`,改用 `grpc.WithTransportCredentials(insecure.NewCredentials())`。
+
+## Dial Options 分类表
+
+gRPC 通过 Option pattern(函数式选项)配置客户端行为。按功能域分为以下类别:
+
+| 类别 | Option | 用途 |
+|------|--------|------|
+| 传输安全 | `WithTransportCredentials()` | TLS / mTLS 配置 |
+| **重连策略** | `WithConnectTimeout()`, `WithBackoffConfig()` | 底层 reconnect backoff(**见下文**) |
+| 重试 | `WithDefaultServiceConfig()` | Service config JSON,含 retry policy |
+| 负载平衡 | `WithBalancerName()` ⚠️已废弃 | v1.60+ 固定使用 `pick_first`;需改 service config 中的 `loadBalancingConfig` |
+| 消息大小 | `WithMaxCallRecvMsgSize()` / `WithMaxSendMsgSize()` | 接收/发送上限(默认 recv 4MB) |
+| 保持活 | `WithKeepaliveParams()` / `WithKeepaliveAuthority()` | Keepalive 心跳 + 覆盖 authority |
+| 拦截器 | `WithChainUnaryInterceptor()` / `WithChainStreamInterceptor()` | 拦截器链 |
+| 统计/观测 | `WithStatsHandler()` | StatsHandler 接口,用于 metrics 和 tracing |
+| 元数据 | `WithUserAgent()` | 自定义 User-Agent header |
+| 通信类型 | `WithInitialWindowSize()` / `WithInitialConnWindowSize()` | 调整 gRPC 层 flow control 窗口 |
+
+> [!warning] WithBalancerName 已废弃
+> gRPC v1.60.0 起移除了旧版 balancer API(`balancer.Register`)。负载均衡策略现在统一通过 service config JSON 的 `loadBalancingConfig` 字段配置——这也会在 "Default Service Config" 部分详述。
+
+## Transport Credentials(证书配置)
+
+```go
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "google.golang.org/grpc/credentials"
+)
+
+// 生产环境:标准 TLS
+caBytes, _ := os.ReadFile("ca-cert.pem")
+caPool := x509.NewCertPool()
+caPool.AppendCertsFromPEM(caBytes)
+
+cert, _ := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
+
+creds := credentials.NewTLS(&tls.Config{
+ Certificates: []tls.Certificate{cert},
+ RootCAs: caPool,
+})
+
+conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(creds))
+
+// 本地开发:insecure(仅本地!绝不能用于生产)
+// conn, _ := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+```
+
+mTLS 与普通 TLS 的区别:普通 TLS 只需验证服务端证书;mTLS 还要求客户端出示自己的证书。如果你的服务网格或零信任架构要求双向认证,就必须用 mTLS。
+
+## Default Service Config(Retry Policy)
+
+gRPC Go 内置的自动重试能力,通过 service config JSON 开启:
+
+```go
+config := `{
+ "retryPolicy": {
+ "maxAttempts": 3,
+ "initialBackoff": "0.1s",
+ "maxBackoff": "1s",
+ "backoffMultiplier": 2,
+ "retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
+ }
+}`
+
+conn, err := grpc.Dial(addr,
+ grpc.WithDefaultServiceConfig(config),
+)
+```
+
+retry policy 的核心机制:**指数退避**。第一次失败等 100ms,第二次 200ms,第三次 400ms,直到达到 maxBackoff 上限。
+
+⚠️ 注意:service config 中同时可以设置 load balancing policy:
+```json
+{"loadBalancingPolicy":"round_robin","retryPolicy":{...}}
+```
+
+## Keepalive Parameters
+
+```go
+import "google.golang.org/grpc/keepalive"
+
+ksp := keepalive.ClientParameters{
+ Time: 10 * time.Second, // ping 间隔
+ Timeout: 5 * time.Second, // 等待 pong 的超时
+ PermitWithoutStream: true, // 空闲时也发 ping
+}
+
+conn, err := grpc.Dial(addr,
+ grpc.WithKeepaliveParams(ksp),
+)
+```
+
+为什么需要 keepalive:NAT 网关和云负载均衡器通常会在 30~60 秒无流量后主动切断 TCP 连接。如果不发送 keepalive ping,服务端认为连接已死,而客户端仍然以为活着,后续读写就会拿到 `i/o timeout` 这类难以排查的错误。
+
+```mermaid
+flowchart LR
+ subgraph "Client"
+ A["Keepalive Timer\nEvery 10s send ping"] --> B["HTTP/2 PING Frame"]
+ end
+ subgraph "Server"
+ B --> C["Receive PING"]
+ C --> D["Reply PONG"]
+ D --> E["Return to step A"]
+ end
+
+ F["NAT / LB\nNo traffic 30s close conn"] -.-> G["Connection alive"]
+
+ style A fill:#00D866,color:#fff
+ style G fill:#EE5A24,color:#fff
+```
+
+## Backoff / Reconnect Policy
+
+Keepalive 负责探测"连接是否还活着",而 backoff 策略决定断连后**等多久重连**。这两者是协作关系:
+
+```go
+// gRPC v1.60+ 推荐方式:手动指定 backoff config
+conn, err := grpc.Dial(addr,
+ grpc.WithConnectTimeout(5*time.Second),
+ grpc.WithBackoffConfig(backoff.BackoffConfig{
+ MaxDelay: 1 * time.Minute, // 最大重连延迟(指数退避上限)
+ BaseDelay: 1 * time.Second, // 初始 delay
+ Multiplier: 1.6, // 每次翻倍乘数
+ Jitter: 0.2, // 抖动范围 ±20%,防止 thundering herd
+ }),
+)
+```
+
+默认行为与可配参数的对比:
+
+| 参数 | 默认值 | 说明 |
+|------|--------|------|
+| `BaseDelay` | 1s | 初次重连等待时间 |
+| `Multiplier` | 1.6 | 指数退避系数 |
+| `Jitter` | 0.2 | 随机抖动(防雪崩) |
+| `MaxDelay` | 120s | 重连间隔上限 |
+
+当 keepalive 检测失败(ping timeout),subchannel 进入 TransientFailure 状态并触发 backoff。背压按如下序列递增:
+
+```mermaid
+flowchart LR
+ A["Disconnected"] --> B["wait 1.0s"]
+ B --> C["reconnect failure"]
+ C --> D["wait 1.6s jitter"]
+ D --> E["reconnect failure"]
+ E --> F["wait 2.56s jitter"]
+ F --> G["exponential growth"]
+ G --> H["wait up to 120s jitter"]
+ H --> I["retry until connected"]
+
+ style B fill:#FFD43B
+ style G fill:#FF9F43
+ style H fill:#EE5A24,color:#fff
+ style I fill:#00D866,color:#fff
+```
+
+核心要点:
+- **不要设得太激进**——如果服务端确实挂了,频繁重连只会放大 traffic。120s 上限给了运维人员修复窗口。
+- **thundering herd 防护**:Jitter 让每个 client 的重连时间分散开来,避免大批量客户端在同一时刻同时重连压垮刚恢复的服务端。
+- `WithConnectTimeout` 控制的是握手阶段的超时(TCP + TLS handshake),和 reconnection backoff 是两个独立的超时维度。
+
+## Address Resolution(地址解析)
+
+gRPC 通过 resolver 机制将目标字符串翻译为真实地址列表——这使得客户端不需要硬编码 IP,天然支持动态扩缩容和 Service Discovery。
+
+```mermaid
+flowchart LR
+ subgraph Target["target dns:///my-service.default.svc:50051"]
+ Resolver["Resolver\nDNS / etcd / Static"]
+ end
+
+ Resolver -->|"Address List"| Picker["Picker\nSelect subchannel for request"]
+
+ subgraph Conn["grpc.ClientConn"]
+ Picker --> SC1["SubChannel 1\n10.0.0.1:50051"]
+ Picker --> SC2["SubChannel 2\n10.0.0.2:50051"]
+ Picker --> SC3["SubChannel 3\n10.0.0.3:50051"]
+ end
+
+ style Resolver fill:#00B6BC,color:#fff
+ style Picker fill:#FFD43B
+```
+
+**核心组件:**
+
+| 角色 | 职责 |
+|------|------|
+| **Resolver** | 把 target 字符串解析为 `address.Address` 列表,并 watch 更新 |
+| **Balancer (Picker)** | 从子连接中选一个来发请求(pick_first / round_robin) |
+| **SubChannel** | 封装单个后端连接的传输层,含 keepalive、reconnect 逻辑 |
+
+**内置 Resolver 类型:**
+
+- **DNS resolver**:`dns:///my-service.default.svc.cluster.local:50051`,自动解析 A/AAAA/TXT 记录并 watch 变化。当 DNS 返回多个 A 记录时,gRPC 会创建子连接并使用 picker 做负载均衡。
+- **Static resolver**:传入裸 `IP:port`(如 `10.0.0.1:50051`),不会自动重连,适合单元测试或固定 IP 场景。
+- **Discovery 插件**:etcd、Consul、Nacos 等有第三方 resolver 实现。
+
+> [!tip] 为什么需要 TXT 记录?
+> gRPC DNS resolver 同时读取 A 记录(后端地址)和 TXT 记录(service config)。你可以在 DNS TXT 中声明 retry policy 和 load balancing config,这样服务端就能统一管理配置而不需要改客户端代码。
+
+## Client Lifecycle(连接生命周期)
+
+```mermaid
+stateDiagram-v2
+ [*] --> Idle: grpc.Dial() returns immediately
+ Idle --> Connecting: first call or reconnect
+ Connecting --> Ready: TCP + HTTP/2 handshake OK
+ Connecting --> TransientFailure: dial failed
+
+ state "Connecting" as C {
+ [*] --> TCPConnect
+ TCPConnect --> TLSHandshake: secure mode
+ TLSHandshake --> PrefaceSent
+ TCPConnect --> PrefaceSent: insecure mode
+ PrefaceSent --> Ready: SETTINGS ACK received
+ PrefaceSent --> ErrorTimeout: timeout
+ ErrorTimeout --> TransientFailure
+ }
+
+ Ready --> Busy: Create RPC Stream
+ Busy --> Ready: Stream completed
+ Busy --> Closing: context cancellation
+
+ TransientFailure --> Reconnecting: backoff expires
+ Reconnecting --> Ready: reconnect success
+ Reconnecting --> TransientFailure: reconnect fail
+
+ Closing --> [*]: conn.Close() graceful shutdown
+ TransientFailure --> [*]: fatal error (e.g., invalid addr)
+
+ note right of TransientFailure
+ DNS resolved but all
+ backends unreachable
+ end note
+```
+
+gRPC client 的生命周期分为以下状态:
+
+1. **Idle** —— `grpc.Dial()` 立即返回,后台不自动发起连接。直到第一次 RPC 调用或某个 subchannel 需要重建时才触发连接。
+2. **Connecting** —— 正在建立 TCP → TLS(如启用安全模式)→ HTTP/2 Preface 握手。这个阶段发来的 call 会被排队,由 `WaitForReady` 控制超时行为。
+3. **Ready** —— 连接就绪,可以正常收发 RPC。picker 从可用的 subchannels 中选择一个来发送请求。
+4. **TransientFailure** —— 所有后端都不可达时进入此状态。此时新 call 直接失败(除非设了 `WaitForReady`),但 gRPC 会在 backoff 到期后自动尝试重连。
+5. **Closing** —— `conn.Close()` 被调用,启动优雅关闭流程:等待在跑的 stream 完成,然后终止所有子连接。
+
+> [!question] Dial 到底是同步还是异步?
+> 答案很微妙:`grpc.Dial()` 本身是同步的——它马上返回 `*grpc.ClientConn`。但连接的**建立过程是异步的**。这意味着你在 dial 返回后立刻发第一个 call 时,如果连接还没 ready,这个 call 会阻塞在排队中等待连接就绪。你可以用 `conn.WaitForStateChange()` 或者 StatsHandler 来主动监听状态变化。
+
+## 生产最佳实践
+
+- **永远在进程启动时建立一次连接,复用 `conn` 对象**——gRPC 内部是连接池,重新 dial 会创建新的子连接并浪费资源。
+- **永远不要因某个 call 失败就重新 Dial**——gRPC 自带 transport-layer 重连逻辑(reconnect policy),比任何手动重试都可靠。
+- **跨机房部署时必须配置 keepalive**,否则几乎必然遇到半死连接(NAT/LB 已断连但客户端不知情)。
+- **使用 `grpc.StatsHandler` 监控连接状态变化**,将子连接数、transient failure 次数等指标接入 Prometheus/Grafana。
+- **生产环境禁用 insecure credentials,务必使用 TLS**——这看起来是废话,但在 CI/CD 环境中我们见过太多忘记改的测试配置流入生产。
+
+> [!tip] 预热连接(Connection Warm-up)
+> DNS resolver 解析后不会立刻建立连接,直到第一次 RPC 调用才触发握手。如果需要降低首次调用的尾延迟,可以在进程启动后立即发一个健康检查 call:
+> ```go
+> // 利用 WaitForReady 避免首次调用的排队超时
+> healthConn, _ := grpc.Dial(addr, opts...)
+> defer healthConn.Close()
+> healthClient := health.NewClient(healthConn)
+> healthClient.Check(context.Background(), &healthpb.HealthCheckRequest{Service: "my.Service"})
+> ```
+
+## 关联笔记
+
+- [[../2. gRPC 核心篇/07-HTTP2 传输原理]] — HTTP/2 frame、stream multiplexing 和 flow control 原理
+- [[12-Call Options 与 Context]] — call-level options、retry policy 匹配规则、context 取消传播
+- [[../3. 服务端实现/10-健康检查与反射]] — health check API,与 warm-up 技巧配合使用
+- [[../6. 工程实践篇/20-性能优化与压测]] — 连接池复用、compression、gRPC-bench 压测手法
diff --git a/hhs/gRPC/4. 客户端开发/12-Call Options 与 Context.md b/hhs/gRPC/4. 客户端开发/12-Call Options 与 Context.md
new file mode 100644
index 0000000..3edef82
--- /dev/null
+++ b/hhs/gRPC/4. 客户端开发/12-Call Options 与 Context.md
@@ -0,0 +1,303 @@
+---
+tags: [gRPC, Go, Context, Call Options, Metadata, Retry, Deadline]
+create time: 2026-05-11 15:32
+---
+
+# Call Options 与 Context
+
+## 概述
+
+一个 gRPC 调用不只是 method name + params——你有丰富的 options 来配置超时、认证、路由、优先级等行为。而 Context 则是贯穿所有选项的灵魂:取消信号、deadline、元数据全部通过 context 传递。
+
+> [!question] Context 应该用 Background 还是 Todo?
+> 在 RPC 场景中,永远用 `context.Background()` 或从上游继承 context。`context.TODO()` 表示"还没想好该用什么",不应该出现在生产代码中。
+
+## Context 基础
+
+```go
+ctx := context.Background()
+
+// 推荐:显式设置 deadline,让下游知道剩余时间
+ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+defer cancel() // 不调用会泄漏 timer
+```
+
+Context 的传播链是单向的——每个 `With*` 函数生成新的 context,原始 context 不会被修改:
+
+```mermaid
+flowchart LR
+ A["Background"] -->|"WithTimeout"| B["WithTimeout\n5s"]
+ B -->|"Append Metadata"| C["AppendToOutgoingContext"]
+ C --> D["ClientMethod Call"]
+
+ B -.->|"cancel / deadline"\n到达 | E["RPC 被取消"]
+ E --> F["返回 context.DeadlineExceeded"]
+
+ style B fill:#74b9ff,color:#000
+ style E fill:#fdcb6e,color:#000
+ style F fill:#d63031,color:#fff
+```
+
+如果任何一层调用了 `cancel()`,整个链条上的 Recv/Send 都会立即感知到。
+
+## Timeout / Deadline:由 Context 负责
+
+你可能在其他 gRPC 库中看到过 `timeout` 这个参数,但在 Go 中**统一通过 context 传递**:
+
+> [!success] 核心原则:超时走 Context,其余走 Call Option
+> 这不是限制,而是设计哲学——gRPC Go 把一切生命周期管理都交给 context。所有选项本质上可以分成两类:
+> - **Context 相关**:取消、超时、元数据、认证凭证
+> - **Call Option 相关**:消息大小限制、压缩算法、重试策略、用户代理
+
+```go
+// ⚠️ gRPC 没有 grpc.WithTimeout() 这个 call option!
+// 正确做法:用 context 控制超时
+ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+defer cancel()
+
+resp, err := client.GetUser(ctx, req) // context 自带超时信息
+```
+
+## Unary Call Options
+
+```go
+import "google.golang.org/grpc"
+
+resp, err := client.GetUser(ctx, &pb.GetRequest{Id: "123"},
+ grpc.WaitForReady(true), // 连接排队中时不拒绝,等待建立
+ grpc.MaxCallRecvMsgSize(10<<20), // 本 call 接收上限 10MB
+ grpc.UseCompressor(gzip.Name), // 强制 gzip 压缩请求体
+)
+```
+
+每个 call option 的参数都是 `func(*callOpts)`——这就是 Go 的函数式选项模式。gRPC 内置了约 15 个选项,常用以上几种。
+
+### 超时 vs Call Option 对比
+
+| 维度 | 通过 Context | 通过 gRPC Option |
+|------|-------------|-----------------|
+| **取消信号** | ✅ `ctx.Done()` | ❌ |
+| **超时控制** | ✅ `WithTimeout` / `WithDeadline` | ❌ |
+| **元数据传递** | ✅ `AppendToOutgoingContext` | ❌ |
+| **消息大小限制** | ❌ | ✅ `MaxCallRecvMsgSize` |
+| **压缩算法** | ❌ | ✅ `UseCompressor` |
+| **重试次数** | ❌ | ✅ `NumRetries`(需 service config) |
+
+> [!question] 为什么超时不设计成 Call Option?
+> 因为 context 不仅携带超时信息,还携带取消信号、值传递、认证信息等。如果超时散落在各个 call option 里,就无法统一管理整条调用链的生命周期。Go 的做法是:**一个 context,所有生命周期管理**。
+
+| Option | 作用 |
+|--------|------|
+| `WaitForReady` | 连接不在 Ready 状态时等待而非直接失败(默认 false) |
+| `MaxCallRecvMsgSize` | 覆盖该次调用的接收上限(默认 4MB) |
+| `MaxCallSendMsgSize` | 覆盖该次调用的发送上限(默认无限制) |
+| `UseCompressor` | 指定压缩算法(gzip / deflate),不指定则由 gRPC 自动协商 |
+| `FailOnNonTempDialError` | 非临时 dial 错误立即返回,不再重试连接 |
+| `NumRetries` | 显式指定重试次数(需配合 service config 使用) |
+| `UserAgent` | 设置本次调用的 User-Agent header,用于服务端识别客户端 |
+| `InitialCredentials` | 首次通信使用的 credentials(与 PerRPCCredentials 配合) |
+
+> [!tip] 两个常用的遗漏选项
+> - **`InitialGzip(true)`**:仅在本次调用中启用 gzip 压缩请求体(无需全局配置 compressor)。
+> - **`ReturnRawServerStats()`**:开启后获取原始服务器遥测数据(用于监控和埋点)。
+
+> [!warning] MaxCallRecvMsgSize 的层级关系
+> Dial-level 设的是全局上限,call-level 设的是本次上限。两者取最小值生效。如果服务端发送的消息超过了你客户端的限制,你会收到 `received message larger than max` 错误。
+
+## Metadata 注入与读取
+
+Metadata 是 key-value 对,用于透传 token、trace-id、region 等上下文信息:
+
+```go
+// 注入 outgoing metadata
+ctx = metadata.AppendToOutgoingContext(ctx,
+ "authorization", "Bearer "+token,
+ "x-trace-id", traceID,
+ "x-region", "cn-east",
+)
+
+// 发起 call,同时接收 Header 和 Trailer
+resp, md, err := client.SecureMethod(ctx, req,
+ grpc.Header(&headerMD), // RPC 开始时的响应头
+ grpc.Trailer(&trailerMD), // RPC 结束时的尾随元数据
+)
+if err != nil {
+ // gRPC 错误详情实际上在 trailer 中
+ if cerr, ok := status.FromError(err); ok {
+ log.Println("Code:", cerr.Code(), "Details:", trailerMD.Get("grpc-status-details"))
+ }
+}
+```
+
+### Metadata 关键注意事项
+
+> [!important] Trailing Metadata 是获取错误详情的唯一途径
+> gRPC 的错误信息(包括 protobuf Any 类型的详情)是通过 **Trailer** 传递的。如果你调用服务端接口失败,必须通过 `grpc.Trailer()` option 接收才能拿到完整错误信息。
+
+| 字段 | 方向 | 常见用途 |
+|------|------|---------|
+| `Content-Type` | Outgoing + Incoming | `application/grpc` |
+| `authorization` | Outgoing | Bearer Token / API Key |
+| `x-trace-id` | Outgoing + Incoming | 分布式链路追踪 |
+| `x-rate-limit-remaining` | Incoming (Header) | 限流计数 |
+| `grpc-status-details` | Incoming (Trailer) | 结构化错误详情 |
+
+> [!tip] Metadata 的键名规范
+> gRPC metadata 的 key 统一使用小写——因为 HTTP/2 header 本身不区分大小写,gRPC 库会自动将驼峰转换为小写存储。
+
+## Retry Policy 实战
+
+gRPC Go 内置的重试机制需要在 service config 中声明:
+
+```go
+config := `{
+ "methodConfig": [{
+ "name": [{"service": "user.v1.UserService", "method": "GetUser"}],
+ "retryPolicy": {
+ "maxAttempts": 3,
+ "initialBackoff": "0.1s",
+ "maxBackoff": "1s",
+ "backoffMultiplier": 2,
+ "retryableStatusCodes": ["UNAVAILABLE"]
+ }
+ }]
+}`
+
+conn, _ := grpc.Dial(addr, grpc.WithDefaultServiceConfig(config))
+```
+
+重试策略的匹配规则:
+1. 优先匹配 `"service":"X","method":"Y"`(最精确)
+2. 其次匹配 `"service":"X"`(服务级)
+3. 最后匹配 `"{}"` (全局兜底,即没有 name 字段时生效)
+
+⚠️ **幂等性警告**:只有 GET 类操作才应该交给自动重试。Write 操作(Create/Update/Delete)必须自行判断是否幂等,因为 retry 会导致重复写入。
+
+```mermaid
+flowchart TB
+ A["发送请求"] --> B{"收到响应"}
+ B -->|"OK 200"| C["返回结果"]
+ B -->|"UNAVAILABLE /\nDEADLINE_EXCEEDED"| D{"已达最大\n重试次数?"}
+ B -->|"其他错误码"| H["直接返回错误"]
+
+ D -->|"否"| E["等待 Backoff 时间"]
+ D -->|"是"| F["返回最终错误"]
+
+ E --> G{"Context 已取消?"}
+ G -->|"是"| I["提前退出:\ncontext.Canceled"]
+ G -->|"否"| A
+
+ style C fill:#00D866,color:#000
+ style F fill:#d63031,color:#fff
+ style I fill:#fdcb6e,color:#000
+ style H fill:#e17055,color:#fff
+```
+
+### 退避算法(Backoff)计算示例
+
+假设配置 `initialBackoff: 100ms`, `maxBackoff: 1s`, `backoffMultiplier: 2`:
+
+| 重试轮次 | 实际等待时间 |
+|---------|-------------|
+| 第 1 次 | 100ms |
+| 第 2 次 | 200ms |
+| 第 3 次 | 400ms |
+| 第 4 次及以上 | 1000ms(被 maxBackoff 截断) |
+
+> [!tip] 不要手动实现指数退避
+> 服务配置会自动处理退避计算。如果需要在代码中做类似逻辑,直接使用 context 的 WithTimeout/WithDeadline 配合循环,或者使用 `golang.org/x/time/rate` 包。
+
+## Context 取消传播
+
+当客户端 context 被取消时,整个调用链的反应如下:
+
+```mermaid
+flowchart TB
+ A["context.Background()"] -->|"WithTimeout"| B["5秒 Timeout"]
+ B --> C["AppendToOutgoingContext MD"]
+ C --> D["ClientMethod Call"]
+
+ subgraph Server["服务端"]
+ E["Handler ctx.Done()"]
+ F["清理资源 / 中止计算"]
+ end
+
+ B -.->|"cancel / deadline\ndelivered"| E
+ E --> F
+
+ D -.->|"RPC cancelled"| G["返回\ncontext.Canceled"]
+
+ style B fill:#74b9ff,color:#000
+ style F fill:#00D866,color:#000
+ style E fill:#fdcb6e,color:#000
+ style G fill:#d63031,color:#fff
+```
+
+1. 服务端 handler 的 `ctx.Done()` channel 会关闭
+2. 服务端应主动停止计算、释放资源
+3. 客户端下一次 Recv 会收到 `context.Canceled` 错误
+
+> [!warning] Cancel 是单向通知
+> Client 调用 `cancel()` 后,服务端可能仍在继续处理已接收的消息。gRPC 没有双向联动机制——如果需要强制服务端在 client 取消时也退出,应在服务端 handler 中监听 `ctx.Done()` 并主动 return。
+
+## Deadline vs Timeout
+
+| 方法 | 参数类型 | 语义 | 适用场景 |
+|------|----------|------|---------|
+| `WithTimeout` | `time.Duration` | 相对当前时间的 duration | 简单场景 |
+| `WithDeadline` | `time.Time` | 绝对时间点 | 跨调用链追踪 |
+
+推荐使用 `WithDeadline` 的原因:当你把一个 context 传给下游 RPC 时,你可以从 context 中提取 deadline,计算出剩余时间,作为下一级 timeout。这样整条链路可以共享同一个总 deadline。
+
+```go
+// 第一层
+ctx, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Second))
+defer cancel()
+
+// 传递给下游时减去缓冲时间
+if deadline, ok := ctx.Deadline(); ok {
+ remaining := time.Until(deadline) - 500*time.Millisecond
+ ctx = context.WithDeadline(ctx, time.Now().Add(remaining))
+}
+```
+
+## 流式调用的 Option 差异
+
+流式(Streaming)的 Call Options 和 Unary 基本一致,但有一个关键区别——**Option 在 Stream 创建时就确定了,不能在过程中动态修改**:
+
+```go
+stream, err := client.FullDuplex(ctx,
+ grpc.MaxCallRecvMsgSize(10<<20), // 影响整个 stream 生命周期
+ grpc.UseCompressor(gzip.Name),
+)
+if err != nil {
+ return err
+}
+defer func() { _ = stream.CloseSend() }() // 关闭 send half
+
+// 后续收发共享同一套配置
+for {
+ req := <-reqChan
+ if err := stream.Send(req); err != nil { break }
+ resp, err := stream.Recv() // 超出 MaxCallRecvMsgSize 会失败
+ if err != nil { break }
+ _ = resp
+}
+```
+
+### 流式调用的特殊注意事项
+
+| 关注点 | Unary | Server Stream | Client Stream | Full Duplex |
+|--------|-------|---------------|---------------|-------------|
+| **超时控制** | context | context | context | context |
+| **消息大小** | 单次限制 | 每帧独立限制 | 每帧独立限制 | 每帧独立限制 |
+| **Cancel 时机** | 返回后立即 | Recv 循环结束后 | Send 循环结束后 | 全部完成后 |
+| **必须关闭** | ❌ | `CloseAndRecv()` | `SendMsg` 后自动关半双工 | `CloseAndSend()` |
+
+> [!tip] 流式调用的 Cancel 泄漏问题
+> 如果服务端仍在发送数据而客户端 context 已取消,Recv goroutine 可能不会被回收。确保每个 Recv goroutine 都监听 `ctx.Done()` 并在收到取消信号后退出。
+
+## 关联笔记
+
+- [[11-Client 连接与 Dial]] — Dial-level 配置是 Call Options 的前置基础
+- [[13-Streaming Client]] — Stream 调用同样依赖 Context 控制生命周期
diff --git a/hhs/gRPC/4. 客户端开发/13-Streaming Client.md b/hhs/gRPC/4. 客户端开发/13-Streaming Client.md
new file mode 100644
index 0000000..26a8cf7
--- /dev/null
+++ b/hhs/gRPC/4. 客户端开发/13-Streaming Client.md
@@ -0,0 +1,251 @@
+---
+tags: [gRPC, Go, Streaming, ServerStreamingClient, ClientStreamingClient, BidiStreaming, Recv, Send, ContextCancellation, GracefulShutdown]
+create time: 2026-05-11 15:34
+---
+
+# Streaming Client
+
+## 概述
+
+客户端处理 stream 的方式跟 unary 截然不同。你不能简单地 `resp, err := client.Method()`,而是需要处理一个连续的收发消息循环。这里我们逐个拆解每种 stream 模式下客户端的正确写法。
+
+> [!tip] Stream 不是魔法
+> 本质上一根 HTTP/2 stream 就是一个 duplex channel。gRPC 只是把 Send 和 Recv 的时序通过代码组织起来。理解了这一点,就能理解所有 stream 变体的模式。
+
+## Server Streaming Client
+
+服务端返回多条消息,客户端逐条接收:
+
+```go
+stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{})
+if err != nil {
+ return err // call 启动失败
+}
+
+for {
+ resp, err := stream.Recv()
+ if err == io.EOF {
+ break // 正常结束
+ }
+ if err != nil {
+ return err // 真实错误
+ }
+ fmt.Println(resp.User)
+}
+```
+
+要点:
+1. **调用时返回 stream object**,而非单个 response
+2. **Recv loop 直到收到 `io.EOF` 才算正常结束**,EOF 是服务端主动关闭发送端的信号
+3. **全程只有两个 error 关注点**:initial error(call 启动时)和 final error(EOF 前),中间 Recv 成功不需要检查 err
+
+这种模式适合列表类接口——数据量可能很大但服务端控制发送节奏,客户端不需要主动推数据。
+
+## Client Streaming Client
+
+客户端连续发送多条消息,服务端最后返回一条聚合结果:
+
+```go
+stream, err := client.UploadData(ctx)
+if err != nil {
+ return err
+}
+
+chunks := splitIntoChunks(data)
+for _, chunk := range chunks {
+ if err := stream.Send(&pb.Chunk{Data: chunk}); err != nil {
+ return err // 某条发送失败
+ }
+}
+
+result, err := stream.CloseAndRecv() // 关闭发送端并获取最终响应
+if err != nil {
+ return err
+}
+fmt.Printf("uploaded %d bytes\n", result.TotalBytes)
+```
+
+要点:
+1. **`Send` 循环中每次调用都可能报错**(网络断开、context 取消、服务端关闭等)
+2. **`CloseAndRecv` 一步完成两件事**:关闭发送端 + 接收最终响应——减少一次 round-trip
+3. **等价写法**是分开两步:先 `stream.CloseSend()` 再 `stream.Recv()`,但 CloseAndRecv 更简洁且语义更清晰
+
+> [!question] CloseSend vs CloseAndRecv 怎么选?
+>
+> | 方法 | 语义 | 适用场景 |
+> |------|------|---------|
+> | `CloseAndRecv()` | 关发送 + 收响应,一步完成 | **大多数 client streaming 场景(推荐)** |
+> | `CloseSend()` + `Recv()` | 分开执行,两步骤 | 需要在上一步之间做自定义逻辑(如日志、指标采集) |
+
+`CloseAndRecv` 的优势在于原子性:从客户端视角看,"关闭发送端"和"获取响应"是一个操作。如果用两步写,两步之间存在微小的时间窗口——服务端可能在这期间发了响应但客户端还没调 `Recv()`,导致时序上的不确定性。虽然实际影响极小,但原子操作的语义更不容易出错。
+
+## Bidirectional Streaming Client
+
+双方互相发消息,收发通常是两个 goroutine:
+
+```go
+stream, err := client.ChatRoom(ctx)
+if err != nil {
+ return err
+}
+
+done := make(chan struct{})
+
+// recv goroutine
+go func() {
+ defer close(done)
+ for {
+ msg, err := stream.Recv()
+ if err == io.EOF {
+ return
+ }
+ if err != nil {
+ log.Printf("recv error: %v", err)
+ return
+ }
+ display(msg.Text)
+ }
+}()
+
+// send goroutine
+go func() {
+ for text := range userInputCh {
+ if err := stream.Send(&pb.Message{Text: text}); err != nil {
+ stream.CloseSend()
+ return
+ }
+ }
+}()
+
+<-done // 等待 recv 结束
+```
+
+要点:
+1. **gRPC stream object 是线程安全的**——多个 goroutine 并发调用 Send/Recv 无需额外加锁
+2. **ctx 取消会同时影响两端**——Send 和 Recv 都会立刻收到 context error,需要统一处理
+3. **send goroutine 退出时务必调用 `CloseSend()`**——通知服务端发送端已关闭,否则服务端 Recv 永远阻塞等待
+
+```mermaid
+flowchart TB
+ subgraph "Client"
+ A["send goroutine
Send loop"] -->|"HTTP/2 stream"| D["Server Handler"]
+ E["recv goroutine
Recv loop"]
+ end
+
+ subgraph "Server"
+ D -->|"HTTP/2 stream"| F["reply Send"]
+ G["server Recv"]
+ end
+
+ F --> E
+ A -.->|"CloseSend when done"| D
+
+ style A fill:#00D866,color:#fff
+ style E fill:#4FC08D,color:#fff
+ style D fill:#FF9F43,color:#000
+ style F fill:#EE5A24,color:#fff
+```
+
+双向流的特殊性在于:**发送和接收是两个独立的 flow**。任何一方都可以随时发送而不阻塞对方,这也是为什么需要两个 goroutine 来管理生命周期。
+
+## 错误分类速查表
+
+| 错误类型 | 表现 | 处理方式 |
+|----------|------|---------|
+| `io.EOF` | Recv 返回 EOF | break loop,正常结束 |
+| `context.Canceled` | Recv 返回 context error | 检查是否主动 cancel |
+| `codes.Unavailable` | Recv/Send 返回 unavailable | 可能需要重试或 reconnect |
+| `codes.ResourceExhausted` | Send 返回 resource exhausted | 背压 / 限流 / 暂停发送 |
+| `codes.Internal` | Recv 返回 internal | 通常是服务端 bug,记录日志 |
+
+当 stream 中出现非 EOF 错误后:
+1. 后续所有 Send/Recv 会立刻返回同一个错误
+2. 建议直接退出当前函数或返回上层处理
+3. 不要尝试在同一个 stream 上恢复操作
+
+## 批量发送优化
+
+对于 client streaming 场景,减少 syscall 次数可以显著提升吞吐量:
+
+```go
+// 不好:每条消息都触发一次 syscall
+for item := range items {
+ stream.Send(item) // N 次 syscall
+}
+
+// 好:聚合后批量发送
+var buf []*pb.Item
+for item := range items {
+ buf = append(buf, item)
+ if len(buf) >= 64 { // 批次大小按场景调优
+ stream.Send(&pb.Batch{Items: buf})
+ buf = buf[:0]
+ }
+}
+if len(buf) > 0 {
+ stream.Send(&pb.Batch{Items: buf})
+}
+```
+
+另一种思路是利用 Protobuf 的 `repeated` 字段在服务端一次解包多条消息。将业务层的 batching 逻辑与协议层的设计对齐,可以减少网络往返次数并降低 CPU 开销。
+
+## Context 取消与优雅退出
+
+流式 RPC 的上下文是一个 `context.Context`——它的取消会**同时影响** Send 和 Recv:
+
+```go
+stream, err := client.ChatRoom(ctx)
+if err != nil {
+ return err
+}
+
+// ctx cancel 后,Recv/Send 都会报错
+defer stream.CloseSend() // ⚠️ defer 放这里而非 goroutine 内
+```
+
+> [!warning] Common Pitfall
+> 在 bidirectional streaming 中,很多开发者会把 `CloseSend()` 放在 send goroutine 里——但如果函数先因其他原因返回,send goroutine 可能还没执行到 `CloseSend()`。**把 `defer stream.CloseSend()` 放在主函数的开头是最安全的做法**:它保证无论哪种路径退出,都会关闭发送端。
+
+ctx 取消时的行为:
+
+```mermaid
+flowchart TD
+ A["ctx Cancelled"] --> B["Send 立即报错
context.Canceled"]
+ A --> C["Recv 返回错误
context.Canceled"]
+ A --> D["Server Handler 收到
context.Canceled"]
+
+ B --> E["清理资源
CloseSend + 退出"]
+ C --> E
+ D --> F["停止处理并 Return"]
+
+ style E fill:#00B6BC,color:#fff
+ style F fill:#FF9F43,color:#000
+```
+
+要点:
+1. **不要在 Recv 循环中吞掉 `context.Canceled`**:它是主动取消的信号,不是网络异常,不需要重试
+2. **`defer CloseSend()` 要放在主函数**,不在子 goroutine 里——确保一定被执行
+3. **服务端收到客户端的 `CloseSend` 后**(即 `Recv` 返回 `io.EOF`),应正常结束 handler
+
+> [!question] 如果 ctx 超时了,已经发出去的消息会不会丢?
+> 不会。gRPC 底层走的是 HTTP/2,消息一旦写入操作系统内核 buffer 就算已发出。ctx 取消只是通知本地 gRPC 库:"不要再接收或发送新数据",但已在途的消息不受影响。
+
+## 最佳实践速查
+
+在实际项目中,这份 checklist 能帮你避开大部分坑:
+
+1. **永远带 context**:每个 `client.Xxx(ctx, ...)` 的 ctx 应该携带 deadline 或 cancel,避免流永远挂起。
+2. **defer CloseSend() 放主函数**:bidirectional stream 中确保退出路径一定关闭发送端。
+3. **不要吞掉 context.Canceled**:主动取消不需要重试——重试只会制造重复流量。
+4. **Recv 后检查 EOF,再检查 error**:EOF 是正常结束信号,不是错误,顺序不能反。
+5. **goroutine 生命周期配对**:每开一个 recv goroutine 就要有一个 done channel 对应收取完毕。
+
+> [!tip] 进阶方向
+> - 需要重试机制?看 [[hhs/gRPC/4. 客户端开发/12-Call Options 与 Context]] 中的 retry policy
+> - 需要拦截所有 stream 日志和指标?看 [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪]]
+> - 想了解服务端对应写法?看 [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
+
+## 关联笔记
+
+- [[hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览]] — 四种 RPC 调用模式全景对比
+- [[hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial]] — Stream 建立在 client connection 之上
diff --git a/hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器.md b/hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器.md
new file mode 100644
index 0000000..26cde38
--- /dev/null
+++ b/hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器.md
@@ -0,0 +1,283 @@
+---
+tags: [gRPC, Interceptor, Middleware, Go]
+create time: 2026-05-11 16:00
+---
+
+# Unary 与 Stream 拦截器
+
+## 概述
+
+Interceptor 是 gRPC 的「插件系统」——在每个 RPC 调用执行前后注入逻辑。它和 HTTP middleware 概念类似,但接口更底层、更灵活。本文档完整覆盖服务端和客户端的 Unary / Stream 拦截器签名、链式调用原理、Recovery、错误码映射等核心模式——理解这些是你实现鉴权、日志、重试的前提。
+
+> [!tip] Interceptor 是单例
+> Interceptor 在 server/client 初始化时注册一次,之后对每个请求生效。不要在 interceptor 里持有 per-request 状态。
+
+## 正文
+
+### Unary Interceptor 签名
+
+Unary(普通 RPC)拦截器的核心类型如下:
+
+```go
+type UnaryServerInterceptor func(
+ ctx context.Context,
+ req interface{},
+ info *UnaryServerInfo,
+ handler UnaryHandler,
+) (interface{}, error)
+
+type UnaryServerInfo struct {
+ Server string
+ FullMethod string // e.g. "user.v1.UserService/CreateUser"
+}
+```
+
+`handler` 就是真正的业务方法实现。你可以选择在调用 handler 之前做任何事(比如鉴权),也可以在调用之后做后处理(比如记录日志)。关键技巧:**你可以在调用 handler 之前或之后插入逻辑**。
+
+```go
+func MyInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ // BEFORE: 前置逻辑 — 鉴权、校验、埋点
+ result, err := handler(ctx, req) // 调用真正 handler
+ // AFTER: 后置逻辑 — 日志、指标、错误处理
+ return result, err
+}
+```
+
+### Stream Interceptor 签名
+
+Streaming RPC 的拦截器有所不同,因为数据是通过流传递的:
+
+```go
+type StreamServerInterceptor func(
+ srv interface{},
+ ss ServerStream,
+ info *StreamServerInfo,
+ handler StreamHandler,
+) error
+```
+
+注意几点差异:
+- 第一个参数是 `srv`(服务实例),而非 `ctx`——stream 的 context 通过 `ss.Context()` 获取
+- 返回的是整条 stream 的错误,不是单个 message 的错误
+- 你无法直接修改发送/接收的消息内容
+
+### 客户端拦截器
+
+服务端拦截器处理入站请求,而客户端拦截器包裹出站调用。它们的签名略有不同:
+
+```go
+// 客户端 Unary
+type UnaryClientInterceptor func(
+ ctx context.Context,
+ method string,
+ req any,
+ reply any,
+ cc *grpc.ClientConn,
+ invoker grpc.UnaryInvoker,
+ opts ...grpc.CallOption,
+) error
+
+// 客户端 Stream
+type StreamClientInterceptor func(
+ ctx context.Context,
+ desc *StreamDesc,
+ cc *grpc.ClientConn,
+ method string,
+ streamer grpc.Streamer,
+ opts ...grpc.CallOption,
+) (grpc.ClientStream, error)
+```
+
+关键差异:
+- `method` 是路径名如 `/user.v1.UserService/CreateUser`,非完整 method string
+- `req` 和 `reply` 都是 `any`——你可以反序列化后检查响应内容
+- `opts ...grpc.CallOption` 允许链式追加 CallOption(比如超时、metadata)
+- Client Stream Interceptor 返回 `grpc.ClientStream`,而非 `error`——真正的错误在后续收发消息时抛出
+
+```go
+func TimeoutInterceptor(ctx context.Context, method string, req any, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
+ ctx, cancel := context.WithTimeout(ctx, time.Second*5)
+ defer cancel()
+ return invoker(ctx, method, req, reply, cc, opts...)
+}
+```
+
+这段代码自动为每个 RPC 调用添加 5 秒超时,无需手动在每个 call 中设置——这是客户端拦截器最常见的用途之一。
+
+### 手动构建 Interceptor Chain
+
+gRPC Go 原生支持链式调用,我们先手动实现一个 chain 来理解其原理:
+
+```go
+func chainUnaryInterceptors(interceptors ...grpc.UnaryServerInterceptor) grpc.UnaryServerInterceptor {
+ n := len(interceptors)
+ return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ ch := handler
+ for i := n - 1; i >= 0; i-- {
+ finalHandler := ch
+ ch = func(c context.Context, r interface{}) (interface{}, error) {
+ return interceptors[i](c, r, info, finalHandler)
+ }
+ }
+ return ch(ctx, req)
+ }
+}
+```
+
+这段代码的关键在于从右到左包裹——最后一个 interceptor 最先被传入,离 handler 最近。请求进来时执行顺序是 **A → B → C → handler**,返回时反向通过每一层。**外层 interceptor 能捕获内层的一切异常**(包括 panic 和 error),这正是链式拦截器的核心设计。
+
+> [!question] 为什么循环要从 n-1 到 0?
+> 因为最后一个 interceptor 应该最先执行(最靠近 handler),这样才能保证第一个 interceptor 在最外层捕获所有下游异常。
+
+### gRPC 官方推荐方式
+
+实际使用中直接使用 gRPC 内置的 chain 函数:
+
+```go
+server := grpc.NewServer(
+ grpc.ChainUnaryInterceptor(
+ logInterceptor, // 第 1 层(最外层)
+ authInterceptor, // 第 2 层
+ recoveryInterceptor, // 第 3 层(最内层)
+ ),
+ grpc.ChainStreamInterceptor(
+ logStreamInterceptor,
+ authStreamInterceptor,
+ ),
+)
+```
+
+执行顺序与手动 chain 一致:请求到达时从左到右依次进入每一层(A → B → C → handler),返回时反向退出。**最外层最先看到请求、最后看到响应**。recovery interceptor 放在最内侧以捕获所有 panic。如果 recovery 放最外侧,它会先捕获其他 interceptor 抛出的异常而非让业务处理——这些不是 bug,而是不满足条件的正常错误流。
+
+### 完整示例:Request Logger
+
+下面是一个实用的请求日志 interceptor:
+
+```go
+func RequestLogger(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ start := time.Now()
+ resp, err := handler(ctx, req)
+ dur := time.Since(start)
+
+ log.Printf("rpc: %s %s %.2fs err=%v",
+ info.FullMethod,
+ reflect.TypeOf(req),
+ dur.Seconds(),
+ err,
+ )
+ return resp, err
+}
+```
+
+这个 interceptor 做了三件事:记录开始时间、调用 handler、打印耗时和错误信息。它可以作为所有 interceptor 链的基础层。
+
+### 实战示例:Panic Recovery
+
+服务崩掉一个 handler 不应影响整个进程,用 `recover()` 兜底:
+
+```go
+func RecoveryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Error("panic recovered", "error", r, "method", info.FullMethod)
+ }
+ }()
+ return handler(ctx, req)
+}
+```
+
+> [!tip] Panic Recovery 必须放最内层
+> 如果把 recovery 放在最外侧,它会吞掉其他 interceptor(比如 auth)主动返回的错误——这些错误不是 bug,不该被 recover。所以 recover 应该离 handler 最近,确保只捕获真正的 panic。
+
+### 错误处理规范
+
+Interceptor 中返回错误时,**不要直接返回 `errors.New`**——要用 `status.Errorf` 映射为 gRPC status code:
+
+```go
+import "google.golang.org/grpc/status"
+
+// ✗ 错误做法
+return nil, errors.New("user not found")
+
+// ✓ 正确做法
+return nil, status.Error(codes.NotFound, "user not found")
+
+// ✓ 带细节的正确做法
+return nil, status.Errorf(codes.InvalidArgument, "invalid email: %v", err)
+```
+
+| 场景 | 推荐 Code | 含义 |
+|------|-----------|------|
+| 参数校验失败 | `codes.InvalidArgument` | 客户端传参有问题 |
+| 资源不存在 | `codes.NotFound` | ID 对应的记录不存在 |
+| 未认证 | `codes.Unauthenticated` | Token 缺失或无效 |
+| 无权限 | `codes.PermissionDenied` | 认证通过但无权访问 |
+| 超时 | `codes.DeadlineExceeded` | 处理时间超出限制 |
+| 内部错误 | `codes.Internal` | 服务端意外 panic 或 DB 故障 |
+| 限流 | `codes.ResourceExhausted` | 超出速率上限 |
+
+> [!tip] Client 侧重试判定
+> 客户端 interceptor(如重试)依据 status code 决定是否重试:只有 `Unavailable`、`DeadlineExceeded`、`ResourceExhausted` 等可恢复 code 才触发重试。错误的 code 映射会导致不该重试的请求被反复发送。
+
+### Interceptor vs StatsHandler
+
+| 维度 | Interceptor | StatsHandler |
+|------|-------------|--------------|
+| 能力 | 修改 req/res、控制流程 | 纯观测(metrics/tracing) |
+| 可写 | 可以改返回值 | 只读 |
+| 性能 | 较高开销 | 更低(异步) |
+| 适用 | Auth, Recovery, RateLimit | Metrics, Tracing, Profiling |
+
+如果你在追求高性能的可观测性,优先选 StatsHandler;如果需要修改请求/响应或控制执行流程,Interceptor 是唯一选择。
+
+### Context 传递规则
+
+Interceptor 中可以向 context 注入信息,下游 handler 可以读取。服务端和客户端都有各自的传递方向:
+
+```go
+type ctxKey struct{}
+
+func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ token := extractToken(ctx)
+ claims, _ := jwt.Parse(token)
+ ctx = context.WithValue(ctx, ctxKey{}, claims)
+ return handler(ctx, req)
+}
+```
+
+**服务端**:从 incoming metadata 中提取身份信息 → 写入 context → 传给 handler
+**客户端**:从 local context 读取 token → 写入 outgoing metadata → 发送给上游
+
+注意事项:
+- value key 必须定义为专用不可比较的类型(如上 `ctxKey` struct),避免包间冲突
+- 不要在 interceptor 里阻塞或做耗时操作,否则会影响所有下游请求
+- context value 不应传递大对象或敏感明文——token 解析后的 claims 可以传,原始密码不行
+- 如果上游已经注入了相同 key 的 value,下游会覆盖它——确保 chain 中每个步骤使用唯一 key
+
+### 常见 Interceptor 模式总结
+
+```mermaid
+flowchart LR
+ subgraph ClientChain["客户端链"]
+ A1["Timeout\n自动超时"] --> A2["Retry\n错误重试"]
+ end
+
+ subgraph ServerChain["服务端链"]
+ B1["Recovery\nPanic 兜底"] --> B2["Auth\n鉴权校验"] --> B3["Logger\n记录耗时"]
+ end
+
+ ClientChain -->|"gRPC call"| ServerChain
+
+ style A1 fill:#00B6BC,color:#fff
+ style A2 fill:#FFD43B
+ style B1 fill:#EE5A24,color:#fff
+ style B2 fill:#FFD43B
+ style B3 fill:#00B6BC,color:#fff
+```
+
+一个典型生产环境的 interceptor chain 结构如上:**客户端侧**做超时控制、重试容错;**服务端侧**做 panic 恢复、鉴权和日志。每一层职责单一,便于测试和维护。
+
+## 关联笔记
+
+- [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]]
+- [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪]]
diff --git a/hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权.md b/hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权.md
new file mode 100644
index 0000000..97a8d9c
--- /dev/null
+++ b/hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权.md
@@ -0,0 +1,291 @@
+---
+tags: [gRPC, Metadata, Auth, TLS, Security, JWT, Interceptor]
+create time: 2026-05-11 16:30
+---
+
+# 元数据与鉴权
+
+## 概述
+
+Metadata 是 gRPC 的请求头(HTTP/2 headers)。你可以在 metadata 里放任何键值对,最经典的用途就是传递认证 token、租户 ID、追踪 ID。本篇讲清楚 metadata 的读写机制和几种常见的鉴权策略。
+
+## 正文
+
+### Metadata 基础
+
+服务端读取 metadata:
+
+```go
+func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return nil, status.Error(codes.Unauthenticated, "no metadata")
+ }
+
+ tokens := md.Get("authorization")
+ token := ""
+ if len(tokens) > 0 {
+ token = tokens[0]
+ }
+ // validate token...
+ _ = token
+ return &pb.User{}, nil
+}
+```
+
+客户端发送 metadata:
+
+```go
+ctx := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
+ctx = metadata.AppendToOutgoingContext(ctx, "x-correlation-id", "abc123")
+resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
+```
+
+> [!example] Metadata 典型使用场景
+> | 场景 | Key(推荐前缀) | 用途 |
+> |------|----------------|------|
+> | 认证令牌 | `authorization` | Bearer token、API Key |
+> | 链路追踪 | `x-correlation-id` / `x-trace-id` | 跨服务请求关联 |
+> | 租户隔离 | `x-tenant-id` | SaaS 多租户识别 |
+> | 国际化 | `accept-language` | 响应语言偏好 |
+> | 重试标识 | `x-retry-count` | 服务端限流或降级决策 |
+> | 自定义超时 | `grpc-timeout` | gRPC 原生支持的超时格式(如 `1S`, `500MS`) |
+> | 二进制数据 | `x-session-data-bin` | 需要 `-bin` 后缀 + base64 编码 |
+
+#### Response Metadata
+
+除了请求 metadata,gRPC 还支持在响应中附加 metadata——比如返回会话信息、速率限制等:
+
+```go
+func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
+ // ... 业务逻辑 ...
+
+ // 设置响应头(客户端可通过 resp.Header() 读取)
+ err := grpc.SetHeader(ctx, metadata.Pairs(
+ "x-rate-limit", "100",
+ "x-response-time", "42ms",
+ ))
+ if err != nil {
+ return nil, err
+ }
+
+ return &pb.User{Name: "Alice"}, nil
+}
+
+// trailer 仅在流结束时发送,适合放汇总信息:
+grpc.SetTrailer(ctx, metadata.Pairs("x-total-cost", "0.003"))
+```
+
+客户端读取 Response Header / Trailer:
+
+```go
+hdr, _ := resp.Header() // 响应头 map[string][]string
+trl := resp.Trailer() // 仅包含 SetTrailer 设置的 key
+```
+
+### 鉴权请求全链路
+
+```mermaid
+flowchart TD
+ A["客户端发送请求\n(metadata + credentials)"] --> B["gRPC 框架层\n合并 Per-RPC Credentials"]
+ B --> C["Server Interceptor Chain\n按注册逆序执行"]
+ C --> D{Auth Interceptor}
+ D -- "无 token" --> E["返回 Unauthenticated\n401"]
+ D -- "token 无效" --> E
+ D -- "token 有效" --> F["解析 claims\n注入 Context"]
+ F --> G{"白名单路由检查"}
+ G -- "公共接口" --> H["直接放行 → Handler"]
+ G -- "受保护接口" --> I["权限校验\nPermissionDenied?"]
+ I -- "无权限" --> J["返回 PermissionDenied\n403"]
+ I -- "有权限" --> H
+ H --> K["Handler 业务逻辑\n返回响应"]
+
+ style D fill:#f9d,stroke:#936
+ style E fill:#fcc,stroke:#c66
+ style J fill:#fcc,stroke:#c66
+ style F fill:#ddf,stroke:#669
+```
+
+> [!question 思考一下]
+> 为什么图中拦截器是按**注册逆序**执行的?这跟 HTTP 中间件的洋葱模型是一个道理——后注册的包裹在最外层,先执行的拦截器拿到的是完整请求上下文,适合做日志记录;先注册的在内层,靠近业务逻辑,适合做精细校验。理解这个顺序有助于调试多拦截器叠加时的行为。
+
+### Metadata 规范速查表
+
+| 维度 | 规则 |
+|------|------|
+| 编码 | 必须 ASCII(非 ASCII 需要 base64 编码后加 -bin 后缀) |
+| 大小写 | gRPC Go 统一转小写处理 |
+| 大小限制 | Default: 8KB max received metadata size |
+| Binary fields | Key 必须以 `-bin` 结尾,值是 base64 bytes |
+
+### 自定义鉴权 Interceptor
+
+将鉴权逻辑抽成 interceptor 的好处是全局生效、无需每个 handler 重复:
+
+```go
+func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return nil, status.Error(codes.Unauthenticated, "missing metadata")
+ }
+
+ auths := md.Get("authorization")
+ if len(auths) == 0 {
+ return nil, status.Error(codes.Unauthenticated, "missing auth")
+ }
+
+ token := strings.TrimPrefix(auths[0], "Bearer ")
+ claims, err := jwt.Validate(token)
+ if err != nil {
+ return nil, status.Error(codes.Unauthenticated, "invalid token")
+ }
+
+ ctx = context.WithValue(ctx, claimsKey{}, claims)
+ return handler(ctx, req)
+}
+```
+
+这里把解析后的 claims 注入到 context,下游 handler 可以直接使用,避免了在每个接口中重复解析 JWT。
+
+#### Context Key 防冲突
+
+用自定义类型作为 context key 是 Go 的最佳实践,避免与其他库产生键名冲突:
+
+```go
+// claimsKey 是 unexported type —— context.WithValue 的 key 应当不可导出
+type claimsKey struct{}
+
+// 读取时通过包级辅助函数统一取值
+func GetClaims(ctx context.Context) *jwt.Claims {
+ v := ctx.Value(claimsKey{})
+ if v == nil {
+ return nil
+ }
+ return v.(*jwt.Claims)
+}
+```
+
+> [!tip] Token 过期 vs 无权访问
+> Token 无效或过期时返回 `codes.Unauthenticated`(401);Token 有效但权限不足时返回 `codes.PermissionDenied`(403)。语义不同,前端处理方式也不同——前者应跳转到登录页,后者可能显示"无权限"弹窗。
+
+### Server-Side Streaming 拦截器
+
+Server Stream(`serverStreaming`)的拦截器签名为 `grpc.StreamServerInterceptor`,鉴权逻辑几乎一致,但需要同时处理请求和响应流:
+
+```go
+func StreamAuthInterceptor(srv interface{}, ss grpc.ServerStream,
+ *grpc.StreamServerInfo, grpc.StreamHandler,
+) error {
+ md, ok := metadata.FromIncomingContext(ss.Context())
+ if !ok {
+ return status.Error(codes.Unauthenticated, "missing metadata")
+ }
+
+ auths := md.Get("authorization")
+ if len(auths) == 0 || !strings.HasPrefix(auths[0], "Bearer ") {
+ return status.Error(codes.Unauthenticated, "missing auth token")
+ }
+
+ // Token 校验通过后放行到业务 handler
+ return handler(srv, ss)
+}
+
+// 注册方式:
+server := grpc.NewServer(
+ grpc.ChainStreamInterceptor(StreamAuthInterceptor, LoggingStreamInterceptor),
+)
+```
+
+> [!tip] Unary vs Stream 的选择
+> - Unary Interceptor 处理单请求单响应(绝大多数 RPC 方法)
+> - Stream Interceptor 处理 Server/Client/Bidirectional Stream
+> - `grpc.ChainUnaryInterceptor(...)` 和 `grpc.ChainStreamInterceptor(...)` 分别串联多个 interceptor
+> - **注意**:Chain 对 unary 和 stream 是**分开配置**的,不能混用
+
+> [!question 思考一下]
+> Bidirectional Stream(双向流)场景下,token 只需要在连接建立时校验一次即可——因为 metadata 是在 `Dial` / `NewStream` 阶段发送的,一旦握手成功整个流的生命周期内不再重新认证。这和 WebSocket 的鉴权模型是一致的。
+
+### Per-RPC Credentials
+
+当客户端需要在每次调用前动态获取 token(例如自动刷新 accessToken),可以用 Per-RPC Credentials:
+
+```go
+type bearerCreds struct {
+ token string
+}
+
+func (c bearerCreds) RequireTransportSecurity() bool { return true }
+func (c bearerCreds) GetPerRPCCredentials() (metadata.MD, error) {
+ return metadata.Pairs("authorization", "Bearer "+c.token), nil
+}
+
+// 使用:每个 call 可以传入不同的 credential
+resp, _ := client.GetUser(
+ ctx,
+ &pb.GetUserRequest{},
+ grpc.Creds(bearerCreds{token: getFreshToken()}),
+)
+```
+
+优势是每个 call 可以用不同的 credential,非常适合 token 刷新场景。缺点是需要为每个 call 单独传 option,不如 interceptor 全局生效方便。两者结合使用时,interceptor 放在外层做校验,Per-RPC credentials 负责提供最新 token。
+
+### TLS / mTLS
+
+生产环境必须启用 TLS,gRPC Go 支持完整的证书体系:
+
+```go
+cert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
+ca, _ := os.ReadFile("ca.crt")
+pool := x509.NewCertPool()
+pool.AppendCertsFromPEM(ca)
+
+creds := credentials.NewTLS(&tls.Config{
+ ClientAuth: tls.RequireAndVerifyClientCert, // mTLS 模式
+ Certificates: []tls.Certificate{cert},
+ ClientCAs: pool,
+})
+
+server := grpc.NewServer(grpc.Creds(creds))
+```
+
+Certificate 级别的选择:
+- `tls.NoClientCert`:仅服务端验证(常规 TLS)
+- `tls.VerifyClientCertIfGiven`:客户端有证书就验证,没有也行
+- `tls.RequireAnyClientCert`:必须有证书但不校验内容
+- `tls.RequireAndVerifyClientCert`:mTLS,严格双向校验
+
+### 白名单路由
+
+不是所有接口都需要鉴权——health check、ping 之类的公共接口应该放行:
+
+```go
+func ServiceAuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ allowedPublic := map[string]bool{
+ "/health.v1.Health/Check": true,
+ "/status.v1.Status/Ping": true,
+ }
+
+ if allowedPublic[info.FullMethod] {
+ return handler(ctx, req)
+ }
+ // 走正常鉴权逻辑...
+ return handler(ctx, req)
+}
+```
+
+### 最佳实践 Checklist
+
+- [ ] **公共接口排除鉴权** — health check、登录等接口不应走 auth interceptor
+- [ ] **区分 Unauthenticated (401) 和 PermissionDenied (403)** — token 问题用前者,权限问题用后者
+- [ ] **Context Key 使用 unexported type** — 避免与其他库产生键名冲突
+- [ ] **metadata key 统一小写** — gRPC Go 转小写存储,硬编码时保持一致
+- [ ] **禁止 `WithInsecure`** — production 必须 TLS,开发环境可用 `credentials.NewTLS(nil)`
+- [ ] **敏感数据不放 metadata** — metadata 最终会落入 HTTP/2 headers,可能被代理或日志记录
+- [ ] **binary field 加 `-bin` 后缀** — 非 ASCII 值必须 base64 编码 + `-bin` 命名约定
+- [ ] **控制 metadata 大小** — 默认限制 8KB,超限会被拒绝
+- [ ] **Interceptor Chain 分层设计** — 外层做认证授权(auth),内层做日志追踪(logging)
+
+## 关联笔记
+
+- [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]]
+- [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪]]
diff --git a/hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md b/hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md
new file mode 100644
index 0000000..0e2560e
--- /dev/null
+++ b/hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md
@@ -0,0 +1,217 @@
+---
+tags: [gRPC, Logging, Tracing, OpenTelemetry, Observability]
+create time: 2026-05-11 17:00
+---
+
+# 日志与链路追踪
+
+## 概述
+
+微服务的 observability 三支柱:Metrics、Logs、Traces。gRPC 生态已经为这三者提供了完善的工具链。不需要手写 logging interceptor——用成熟的库就行。但你需要理解这些库背后是怎么工作的,才能正确配置和调试。
+
+## 正文
+
+### 自动埋点(OpenTelemetry)
+
+最简单且最可靠的方式是用官方 OTel SDK,一行代码搞定 span 创建、trace context propagation、RPC metrics 采集:
+
+```go
+import (
+ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+)
+
+server := grpc.NewServer(
+ grpc.StatsHandler(otelgrpc.NewServerHandler()),
+)
+
+conn, _ := grpc.Dial(addr, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
+```
+
+这是生产环境的首选方案。你只需引入包即可自动获得完整的分布式追踪能力。
+
+> [!tip] StatsHandler vs Interceptor
+> OTel 内部使用 StatsHandler API,不是 Interceptor。这意味着它对业务逻辑零侵入、性能开销更低。这也是为什么推荐优先选 StatsHandler 做可观测性。
+
+### 日志 Interceptor(手动实现)
+
+虽然 OTel 足够好,但有时候你需要更细粒度的控制,比如把某些字段打到自己的结构化日志系统里:
+
+```go
+func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+ start := time.Now()
+ md, _ := metadata.FromIncomingContext(ctx)
+ traceID := getTraceID(md)
+
+ resp, err := handler(ctx, req) // 调用真正的业务 handler
+
+ log.Info("rpc_complete",
+ "method", info.FullMethod,
+ "duration_ms", time.Since(start).Milliseconds(),
+ "error", err,
+ "trace_id", traceID,
+ "status_code", status.Code(err),
+ )
+
+ return resp, err
+}
+
+// getTraceID 从 metadata 中提取 trace ID
+func getTraceID(md metadata.MD) string {
+ tpp := md.Get("traceparent")
+ if len(tpp) > 0 {
+ return extractTraceID(tpp[0])
+ }
+ xid := md.Get("x-trace-id")
+ if len(xid) > 0 {
+ return xid[0]
+ }
+ return ulid.Make().String() // 无上游 trace,生成根 span
+}
+
+// extractTraceID 解析 W3C Trace Context 格式
+func extractTraceID(traceParent string) string {
+ parts := strings.Split(traceParent, "-")
+ if len(parts) >= 2 {
+ return parts[1]
+ }
+ return ""
+}
+```
+
+这段代码展示了完整的 logging interceptor 模式——先提取 trace ID,调用 handler 后记录耗时和错误。`getTraceID` 的辅助逻辑做了三件事:优先解析 W3C `traceparent`;回退到 `x-trace-id`;不存在时生成新 root span。这样既能融入分布式链路,也能独立产生新链路。
+
+> [!question] 为什么在 handler 之后才打日志?
+> 因为我们需要知道请求的处理结果(耗时、错误)才能记录完整信息。如果在 handler 之前打日志,你只能拿到请求参数而拿不到响应。这也是 interceptor "洋葱模型"的核心优势——你可以包裹住 handler 的整个执行生命周期。这也正是 [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 中强调的 chain 原理。
+
+### Trace ID 透传机制
+
+gRPC 通过 metadata 传递 W3C Trace Context 标准格式——这是目前业界最通用的分布式追踪协议。Trace Context 由四个部分组成:
+
+```
+traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
+ │ ───────────────────── ────────────────────── ──
+ │ trace-id span-id flags
+ │ (16 bytes) (8 bytes) (1 byte)
+ └── version (00)
+```
+
+| 字段 | 长度 | 说明 |
+|------|------|------|
+| version | 2 hex chars | 当前固定为 `00` |
+| trace-id | 32 hex chars | 整个链路的唯一标识(所有 span 共享) |
+| span-id | 16 hex chars | 当前操作的唯一标识 |
+| flags | 2 hex chars | `01` 表示已采样,`00` 表示未采样 |
+
+#### 服务端读取上游 Trace ID
+
+```go
+md, _ := metadata.FromIncomingContext(ctx)
+traceParent := md.Get("traceparent")
+var traceID string
+if len(traceParent) > 0 {
+ traceID = extractTraceID(traceParent[0]) // 见上节 helper
+}
+// traceID 为空 → 当前服务是链路起点,需要生成新 root span
+```
+
+#### 客户端向下游注入 Trace ID
+
+在使用 OpenTelemetry SDK 时,SDK 会自动完成 context propagation(详见本节开头的 `otelgrpc`),但如果你手动构造 metadata,需要这样写:
+
+```go
+span := otel.Tracer("my-service").SpanContext()
+ctx = metadata.AppendToOutgoingContext(
+ ctx,
+ "traceparent", fmt.Sprintf("00-%s-%s-01",
+ span.TraceID().String(),
+ span.SpanID().String(),
+ ),
+)
+```
+
+> [!question] 为什么 OTel 不需要手写 traceparent?
+> OTel 的 `StatsHandler` 底层使用了 Go 的 `stats.Handler` API,它在 RPC 各个生命周期节点(如 `InboundPayload`, `OutboundPayload`)自动读写 metadata、创建 span。**你只需要引入一个包**,框架会帮你处理所有细节。这也是为什么官方强烈推荐使用自动埋点而非手动实现。
+
+### RPC Metrics 指标清单(OTel 自动产出)
+
+| Metric | Type | Labels | 用途 |
+|--------|------|--------|------|
+| `rpc.server.duration` | Histogram | method, service, status | 服务端延迟分布,用于画 P95/P99 |
+| `rpc.server.requests.count` | Counter | method, service | 服务端请求总量,监控流量趋势 |
+| `rpc.server.responses.count` | Counter | method, service, status | 按状态码统计响应数,发现错误突增 |
+| `rpc.client.duration` | Histogram | method, service, status | 客户端视角延迟,含网络 + 服务端总耗时 |
+| `rpc.client.attempts.count` | Counter | method, service | 含重试的调用次数,>1 说明有重试发生 |
+
+这些指标可以直接对接 Prometheus/Grafana,用于构建实时监控面板和告警规则。常见告警示例:
+- **SLO violation**:`rpc.server_duration{le="0.5"} / rpc_server_requests_count > 0.01` → P99 < 500ms 的 SLA 被击穿超过 1%
+- **错误突增**:`increase(rpc_server_responses_count{status="Internal"}[5m]) > 10` → 5 分钟内 Internal 错误超 10 次
+
+> [!question] client duration vs server duration 为什么不同?
+> server duration 是服务端处理耗时,client duration 包含网络 RTT + 排队 + 服务端处理。如果 client duration >> server duration,说明问题出在网络或客户端侧(比如连接池太小导致排队),而不是服务端逻辑慢。这是排查性能问题的关键区分点。[[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 中的 Interceptor 也可以手动记录 server duration 来做对比验证。
+
+### 端到端 Observability 架构
+
+```mermaid
+flowchart TB
+ subgraph Client["客户端服务"]
+ C1["App"] -->|"grpc.Dial WithStatsHandler"| C2["gRPC ClientConn"]
+ end
+
+ subgraph Network["网络层 Service Mesh / LB"]
+ S1["Proxy Collect spans + traceparent"]
+ end
+
+ subgraph Server["服务端"]
+ G1["gRPC Server"] -->|"otelgrpc StatsHandler"| O1["OTel SDK Create span"]
+ O1 -->|"exporter"| E[("Tracing Backend")]
+ O1 -->|"metrics"| P[("Metrics Backend")]
+ D1["Interceptor Chain logging + trace_id"] -->|"structured log"| L[("Log Store")]
+ end
+
+ C2 -->|"HTTP/2 + traceparent metadata"| S1
+ S1 -->|"forwarded + preserved traces"| G1
+ G1 --> H["handler"]
+ H --> D1
+
+ style C2 fill:#00B6BC,color:#fff
+ style G1 fill:#FFD43B
+ style O1 fill:#EE5A24,color:#fff
+ style S1 fill:#9B59B6,color:#fff
+```
+
+数据流说明:
+1. **客户端**通过 `otelgrpc.NewClientHandler()` 自动创建 client span,并将 `traceparent` 写入 metadata
+2. **网络层**(如 Envoy、Istio)可以采集 spans 并透传 trace context
+3. **服务端**的 `otelgrpc.NewServerHandler()` 提取 upstream trace ID,继续串联当前服务的 span
+4. **自定义 Interceptor** 从 metadata 中提取 traceID,将结构化日志发送到独立日志系统
+
+> [!tip] 为什么 Metrics 和 Traces 分开?
+> Metrics 回答"系统怎么样"(P99 延迟多少?错误率多少?),Traces 回答"哪里出问题"(哪个具体的请求链路慢了?)。二者互补——Grafana dashboard 用 metrics 发现异常,JAEGER/Tempo 用 traces 定位根因。
+
+### 性能考量
+
+- StatsHandler 比 Interceptor 性能更好——它通过 Go runtime channel 异步上报数据,不阻塞业务 goroutine
+- 生产环境优先 StatsHandler + OTel,Interceptor 做附加层(如自定义日志格式)
+- 不要每次都打印 full request/response(太吵),只在 debug level 或采样率下打印
+- OTel SDK 默认使用 `BatchSpanProcessor`,会将 spans 批量导出。调整 `ScheduleDelayMillis` 和 `ExportTimeoutMillis` 可以权衡延迟与吞吐量
+
+> [!warning] 日志采样
+> 全量打印每条 RPC 的详细信息会迅速压垮日志系统。建议对 INFO 级别做采样(如每秒 1%),DEBUG 级别仅在开发环境开启。对于 Trace ID,即使是采样的日志也必须带上——否则无法在日志系统中将分散的采样日志聚合到同一条链路。
+
+### 最佳实践 Checklist
+
+- [ ] **StatsHandler 优先** — 可观测性用 OTel StatsHandler,不需要手写 interceptor
+- [ ] **Trace Context 遵循 W3C 标准** — 统一使用 `traceparent` key,避免各团队自定 header
+- [ ] **结构化日志带 trace_id** — 即使做了采样,也要保证每条日志可追溯到对应链路
+- [ ] **Root span 生成新 trace ID** — 服务作为链路起点时,自动生成新 trace ID 而非留空
+- [ ] **Context Key 防冲突** — 见 [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]] 的 context key 规范
+- [ ] **Interceptor Chain 分层** — 外层做 auth,内层做 logging(见 [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]] 的 chain 原理)
+- [ ] **Metrics 对接 Grafana** — 利用 OTel 产出的标准 metrics,快速搭建 dashboard
+- [ ] **gRPCurl 注入 traceparent 验证** — 排查 tracing 问题时,手动注入已知 trace ID 验证透传链路
+- [ ] **区分 client/server duration** — client 视角包含网络 RTT,server 视角只含处理时间
+- [ ] **控制 metadata 大小** — 每个 traceparent 约 70 bytes,大量 metadata 累计可观,注意默认 8KB 限制
+
+## 关联笔记
+
+- [[hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器]]
+- [[hhs/gRPC/5. 中间件与拦截器/15-元数据与鉴权]]
diff --git a/hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md b/hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md
new file mode 100644
index 0000000..321ad99
--- /dev/null
+++ b/hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md
@@ -0,0 +1,344 @@
+---
+tags: [gRPC, Protobuf, protoc, Makefile, go generate, buf]
+create time: 2026-05-11 16:40
+---
+
+# protoc 工具链与 Makefile
+
+## 概述
+
+Protobuf 编译器 protoc 是整个体系的核心工具。配合不同语言的 code generator plugin,它能从同一份 .proto 文件生成各语言的类型和 stub。掌握 toolchain 的正确用法,可以避免无数个 "mismatched version" 报错。
+
+## 核心组件矩阵
+
+| 工具 | 作用 | 安装方式 |
+|------|------|---------|
+| `protoc` | 编译引擎 | 下载 binary 包 |
+| `protoc-gen-go` | 生成 Go struct | `go install google.golang.org/protobuf/cmd/protoc-gen-go@latest` |
+| `protoc-gen-go-grpc` | 生成 Go gRPC stub | `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest` |
+| `protoc-gen-js` | 生成 JS 代码 | npm 安装 |
+| `protoc-gen-java` | 生成 Java 代码 | Maven/Bazel 自带 |
+| `protoc-gen-python` | 生成 Python 代码 | pip 安装 |
+
+> [!tip] 安装验证
+> 安装后运行 `protoc --version` 确认版本,并检查 `which protoc-gen-go` 是否指向 GOPATH/bin。这是排查问题的第一步。
+
+## 基本命令
+
+最简单的单文件生成:
+
+```bash
+protoc --go_out=. --go-grpc_out=. api/user/v1/user.proto
+```
+
+批量生成整个 proto 目录:
+
+```bash
+protoc --go_out=. --go-grpc_out=. \
+ --go_opt=paths=source_relative \
+ --go-grpc_opt=paths=source_relative \
+ $(find . -name "*.proto")
+```
+
+这里的关键参数是 `--go_opt=paths=source_relative`——它是路径模式的选择,下一节详解。
+
+> [!question] 为什么需要两个 --*_out?
+> `--go_out` 生成消息结构体(pb.go),`--go-grpc_out` 生成 client/server stub(_grpc.go)。二者缺一不可,少了哪个都不会编译通过。
+
+## 核心流程图解
+
+理解数据流向是正确使用工具链的前提:
+
+```mermaid
+flowchart LR
+ subgraph Source["📁 Proto 源文件"]
+ P1["user.proto"]
+ P2["status.proto"]
+ end
+
+ subgraph Engine["⚙️ protoc 编译引擎"]
+ Parse["解析 .proto"]
+ Validate["验证 package / import"]
+ Dispatch["按 --*_out 分发"]
+ end
+
+ subgraph Plugins["🔌 Code Generator Plugins"]
+ Gopb["protoc-gen-go\n→ *.pb.go"]
+ Grpc["protoc-gen-go-grpc\n→ *_grpc.go"]
+ Other["protoc-gen-js / others"]
+ end
+
+ subgraph Output["📦 生成结果"]
+ U["user.pb.go + user_grpc.go"]
+ S["status.pb.go"]
+ end
+
+ P1 --> Parse
+ P2 --> Parse
+ Parse --> Validate
+ Validate --> Dispatch
+ Dispatch -->|--go_out| Gopb
+ Dispatch -->|--go-grpc_out| Grpc
+ Dispatch -->|其他插件| Other
+ Gopb --> U
+ Grpc --> U
+ Gopb --> S
+```
+
+> [!tip] 核心要点
+> protoc 本身 **不生成任何语言代码**——它只是一个解析器和调度器。真正的代码生成由 `protoc-gen-*` 插件完成。这就是为什么缺少某个 plugin 时会直接报错 "program not found"。
+
+## 路径模式对比(关键!)
+
+这是初学者最常踩的坑:
+
+```bash
+# source_relative(v2 唯一官方推荐)
+--go_opt=paths=source_relative
+
+# (无此选项 = 旧版默认行为,已废弃)
+# 会尝试根据 import 和 go_package 计算输出路径
+```
+
+`source_relative` 模式下,输出路径相对于 .proto 文件的 import path,符合 Go module 约定。比如:
+
+```protobuf
+// api/user/v1/user.proto
+package user.v1;
+option go_package = "github.com/mycompany/platform/api/user/v1;v1";
+```
+
+在 `source_relative` 下,生成的文件位于 `api/user/v1/user.pb.go`,与源文件同目录。旧版默认行为(不指定 `--go_opt=paths`)会根据 `go_package` 中的 module path 计算输出位置,经常导致路径混乱。**新版 protoc-gen-go(v2+)中该行为已移除,必须显式指定 `source_relative`。**
+
+> [!warning] 新版 breaking change
+> `paths=import_path_provided` 已在 protoc-gen-go v2 中移除。**必须显式指定** `--go_opt=paths=source_relative`,否则编译直接报错。升级前请确认所有 proto 生成命令都已添加此参数。
+
+## go generate 集成
+
+两种方式可以将 protoc 集成到开发流程中:
+
+### 方式一:Makefile 集中管理
+
+基础版——适合快速上手:
+
+```makefile
+PROTO_DIR = api
+PROTOS := $(shell find $(PROTO_DIR) -name "*.proto")
+
+.PHONY: proto
+proto:
+ protoc \
+ --proto_path=$(PROTO_DIR) \
+ --go_out=$(PROTO_DIR) \
+ --go_opt=paths=source_relative \
+ --go-grpc_out=$(PROTO_DIR) \
+ --go-grpc_opt=paths=source_relative \
+ $(PROTOS)
+```
+
+进阶版——带增量构建和依赖追踪,生产项目推荐:
+
+```makefile
+PROTO_DIR = api
+PROTOS := $(shell find $(PROTO_DIR) -name "*.proto")
+
+# 每个 .proto 对应一个 stamp 文件,记录生成时间
+STAMPS := $(patsubst $(PROTO_DIR)/%.proto,.gen/%.proto.done,$(PROTOS))
+
+.gen/%.proto.done: $(PROTO_DIR)/%.proto
+ @mkdir -p .gen
+ protoc --proto_path=$(PROTO_DIR) \
+ --go_out=$(PROTO_DIR) --go_opt=paths=source_relative \
+ --go-grpc_out=$(PROTO_DIR) --go-grpc_opt=paths=source_relative \
+ $<
+ touch $@
+
+.PHONY: proto clean
+proto: $(STAMPS)
+clean:
+ rm -rf .gen $(PROTO_DIR)/**/*.pb.go $(PROTO_DIR)/**/*_grpc.go
+```
+
+设计要点:
+
+- **增量构建**:只重新编译有变化的 `.proto` 文件,大项目节省大量时间。
+- **Stamp 文件**:利用 Make 的 timestamp 机制自动判断是否需要重建。
+- **`$<`**:Make 自动变量(非 shell),代表当前规则的第一个依赖文件。
+
+> [!tip] `--proto_path` 详解
+> 这个参数指定 `.proto` 文件的搜索根目录。所有 `import "xxx.proto"` 语句中的路径都是相对于 `--proto_path` 计算的。
+>
+> ```bash
+> # ✅ 正确:proto_path 指向 proto 文件所在目录
+> protoc --proto_path=api api/user/v1/user.proto
+> # user.proto 中 import "v1/status.proto" 可以正确解析
+>
+> # ❌ 错误:proto_path 指向了上级目录
+> protoc --proto_path=. api/user/v1/user.proto
+> # import 路径需要写成 "api/user/v1/status.proto",容易出错且不统一
+> ```
+
+### 方式二:go generate 内联
+
+在 `.proto` 同级或 Go 包目录下放置 `//go:generate` 指令:
+
+```go
+//go:generate protoc --proto_path=../api --go_out=. --go-grpc_out=. --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative user/v1/user.proto
+package user
+```
+
+然后运行 `go generate ./...` 触发指定包的代码生成。
+
+> [!note] 两种方式的取舍
+> - **Makefile** 适合多语言、大项目,统一入口、易 CI 化。
+> - **go generate** 适合小项目或单体仓库,每个包自包含,不需要额外工具。
+
+## 版本管理
+
+正确的做法是将 runtime library 和 code generator 的版本对齐:
+
+```toml
+require (
+ google.golang.org/protobuf v1.36.6 // indirect
+ google.golang.org/grpc v1.74.2
+)
+```
+
+关键点:
+
+| 组件 | 版本对齐规则 |
+|------|-------------|
+| `protoc` 二进制 | 仅需与 protobuf **Library** 兼容,参考 [官方兼容性矩阵](https://github.com/protocolbuffers/protobuf/releases) |
+| `protoc-gen-go` | minor 版本应与 `google.golang.org/protobuf` 一致 |
+| `protoc-gen-go-grpc` | minor 版本应与 `google.golang.org/grpc` 一致 |
+
+常用检查命令:
+
+```bash
+# 查看 protoc 编译器版本
+protoc --version
+# libprotoc 5.28.2
+
+# 查看 Go runtime 版本
+go list -m google.golang.org/protobuf
+# google.golang.org/protobuf v1.36.6
+
+# 查看 generator 版本
+protoc-gen-go --version
+# v1.36.6
+```
+
+> [!warning] 常见陷阱
+> 如果你用 `go install` 安装了最新版的 protoc-gen-go,但 go.mod 里锁的是旧版 library,就可能遇到 "field number X is out of range" 之类的诡异错误。**始终让 generator 和 library 版本对应。**
+>
+> ```bash
+> # 推荐的锁定方式——在 go.mod 中显式 require generator
+> go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
+> go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0
+> ```
+
+## 多语言生成(Buf 推荐方案)
+
+当项目需要同时产出 Go、Rust、JS 等多语言代码时,手动拼 protoc 脚本会变得异常复杂。推荐使用 Buf:
+
+```yaml
+# buf.gen.yaml
+version: v2
+plugins:
+ - remote: buf.build/community/neoeinstein-prost
+ out: gen/rs
+ - remote: buf.build/community/nipunn1313-grpc-javascript-client
+ out: gen/js
+ - local: protoc-gen-go
+ out: gen/go
+```
+
+配合 `buf.yaml` workspace 配置:
+
+```yaml
+# buf.yaml
+version: v2
+modules:
+ - path: api
+```
+
+一条命令搞定:`buf generate`。
+
+> [!tip] 为什么推荐 Buf?
+> - 内置 lint 和 breaking change 检测
+> - 自动处理 plugin 版本管理和缓存
+> - 声明式配置取代脆弱的 shell 脚本
+> - 远程 plugin 无需本地安装
+
+## 常见问题排查
+
+| 症状 | 原因 | 解决 |
+|------|------|------|
+| "protoc-gen-go: program not found" | PATH 不包含 GOPATH/bin | `export PATH=$PATH:$(go env GOPATH)/bin` |
+| "mismatched versions" | protoc-gen-go 与 library 版本不一致 | 对齐到相同的 minor 版本 |
+| "import not found" | --proto_path 未指定或相对路径错误 | 确保 --proto_path 指向 .proto 根目录 |
+| 生成文件为空 | go_package option 缺失或格式错误 | 检查 `option go_package = "module/path;pkg"` 格式 |
+| panic: proto: field XXX has bad tag | .proto 文件版本混乱 | 清理所有 pb.go 后重新生成 |
+
+### protoc vs Buf:工作流对比
+
+> [!question] protoc 和 Buf 应该选哪个?
+> 简单说:**小项目用原生 protoc,多语言或中大型项目用 Buf**。详细对比见下方流程图。
+
+```mermaid
+flowchart LR
+ subgraph Protoc["直接 protoc"]
+ A1["写 .proto"] --> B1["手写 shell / Makefile"]
+ B1 --> C1["拼 --*_out 参数"]
+ C1 --> D1["手动管理 plugin 版本"]
+ D1 --> E1["运行生成"]
+ end
+
+ subgraph Buf["Buf 封装"]
+ A2["写 .proto"] --> B2["写 buf.yaml + buf.gen.yaml"]
+ B2 --> C2["buf generate"]
+ C2 --> D2["自动下载/缓存 plugin"]
+ D2 --> E2["运行生成 + lint"]
+ end
+
+ style A1 fill:#FEF08A
+ style A2 fill:#DBEAFE
+ style E1 fill:#FEE2E2,color:#991B1B
+ style E2 fill:#D1FAE5,color:#065F46
+```
+
+| 维度 | 直接 protoc | Buf |
+|------|------------|-----|
+| **配置方式** | shell / Makefile 脚本 | YAML 声明式配置 |
+| **Plugin 管理** | 手动 `go install`,易版本混乱 | 自动下载、缓存、锁定版本 |
+| **Lint** | 无,需自建规则 | 内置 `buf lint`,可定制规则 |
+| **Breaking Change** | 无 | `buf breaking` 自动检测 API 变更 |
+| **学习成本** | 低,理解 protoc 即可 | 需额外了解 buf 概念(module/breaking) |
+| **适合场景** | 单语言、小型 Go 服务 | 多语言、API 团队、跨服务协作 |
+
+## 工具选型决策图
+
+> [!question] 项目中 protoc / go generate / Buf 怎么选?
+> 下面这张矩阵图展示了各工具在"复杂度"和"管理程度"两个维度上的定位:
+
+```mermaid
+quadrantChart
+ title 工具链选型矩阵
+ x-axis Low Complexity --> High Complexity
+ y-axis Native protoc --> Managed Workflow
+ quadrant-1 "多语言大项目"
+ quadrant-2 "小 Go 项目"
+ quadrant-3 "轻量脚本"
+ quadrant-4 "中型 Go 服务"
+ "Buf": [0.8, 0.9]
+ "go generate": [0.3, 0.7]
+ "Makefile": [0.5, 0.65]
+ "shell 脚本": [0.15, 0.2]
+```
+
+## 关联笔记
+
+- [[hhs/gRPC/README.md]] — gRPC 知识库总览
+- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md]] — Protobuf 语法基础
+- [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md]] — Service 定义与代码生成
+- [[hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范.md]] — Proto 文件组织规范
diff --git a/hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范.md b/hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范.md
new file mode 100644
index 0000000..df8f956
--- /dev/null
+++ b/hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范.md
@@ -0,0 +1,385 @@
+---
+tags: [gRPC, Protobuf, proto, module, lint, buf, API design]
+create time: 2026-05-11 16:40
+---
+
+# 模块拆分与 proto 规范
+
+## 概述
+
+多人协作时,proto 规范是最容易产生分歧的地方。没有统一的规范,proto 文件会迅速变成一团乱麻。本文档提供一套被业界(Google、Uber、Stripe)验证过的最佳实践,涵盖目录结构、命名约定、Package/Import 规范、字段编号策略以及 CI 流水线集成。
+
+> [!question] 为什么要花精力建立规范?
+> Proto 文件是服务的"契约"——一旦发布就不能随意修改。如果每个人按照自己的习惯来组织文件,三个月后你会面临:import 路径混乱、循环引用频发、breaking change 无人察觉。**规范的本质是用一致性换取可维护性。**
+
+## 推荐的目录结构
+
+```
+api/
+├── user/
+│ └── v1/
+│ ├── user.proto # core message types
+│ ├── service.proto # service definitions
+│ ├── errors.proto # common error codes
+│ └── README.md # API documentation
+├── order/
+│ └── v1/
+│ ├── order.proto
+│ └── service.proto
+├── product/
+│ └── v1/
+│ └── product.proto
+├── buf.yaml # workspace-level config
+└── buf.gen.yaml # generation config
+```
+
+这种结构的核心理念:**一个资源(resource)一个目录,一条版本号(version)一层子目录**。这与 Go module 的导入路径完全一致,生成代码后 import path 无需任何映射。
+
+> [!tip] 为什么推荐 Buf 而非原生 protoc?
+> - **内置 lint 和 breaking change 检测**——省去自建规则的成本
+> - **声明式配置取代 shell 脚本**——`buf generate` 一条命令搞定
+> - **自动处理 plugin 版本管理**——不再有 "mismatched version" 报错
+> - 详见 [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md]]
+
+## Proto 文件命名约定
+
+| 文件类型 | 命名 | 说明 |
+|----------|------|------|
+| 消息定义 | `{entity}.proto` | `user.proto`, `order.proto` |
+| 服务定义 | `service.proto` | 包含该 module 所有 RPC |
+| 错误码 | `errors.proto` | 全局错误码 |
+| 枚举 | 混入 entity.proto 或单独 `enum.proto` | 建议随 entity |
+
+> [!tip] 为什么拆分 service.proto?
+> 当业务增长时,`user.proto` 可能包含数十个 message。将 RPC 定义拆到独立的 `service.proto` 能让每次 review 聚焦单一职责——reviewer 不需要在大量 message 中查找接口变更。
+
+## Package 命名规范
+
+```protobuf
+syntax = "proto3";
+package mycompany.servicename.v1;
+option go_package = "github.com/mycompany/platform/api/servicename/v1;v1";
+```
+
+**绝对不要用 package name 作为 API versioning 的方式**——换 package name 等于破坏兼容性。版本信息应该通过目录层级 `v1/`、`v2/` 来体现,保持 package 名不变。
+
+> [!warning] 兼容性陷阱
+> 从 `package user.v1` 改为 `package user.v2` 会使得所有旧引用失效。正确做法是在新目录 `user/v2/user.proto` 中新建 package `user.v2`,同时保留旧版本的向后兼容。具体迁移方案参见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]]。
+
+### Go Module 路径对齐
+
+同一 module 下的所有 `.proto` 共享相同的 `go_package`,确保生成的所有文件都在同一个 Go package 里:
+
+```protobuf
+// api/user/v1/user.proto
+package user.v1;
+option go_package = "github.com/mycompany/platform/api/user/v1;v1";
+
+// api/user/v1/service.proto
+package user.v1;
+option go_package = "github.com/mycompany/platform/api/user/v1;v1";
+```
+
+如果 `go_package` 中的包名不同(比如分别用 `userpb` 和 `userservicepb`),编译时会报 "two different package names" 错误。所以**强烈建议统一使用同一个包名后缀**。
+
+## Import 规范
+
+```protobuf
+// api/user/v1/user.proto
+import "common/v1/errors.proto";
+import "common/v1/timestamps.proto";
+```
+
+三条铁律:
+
+1. **避免循环引用**——如果 user.proto 需要引用 order.proto,而 order.proto 又引用 user.proto,提取共享 message 到独立文件(如 `common/v1/shared.proto`)。
+2. **import 路径等于相对磁盘路径**——`protoc -I api/` 时,`user/v1/user.proto` 文件就用 `import "user/v1/user.proto"`。Buf 同理,以 `buf.yaml` 中声明的 module 路径为根。
+3. **公共类型抽离到 common 包**——错误码、通用时间戳、分页参数等跨 module 共用的类型放在 `common/` 目录下。
+
+### 字段编号分配策略
+
+每个字段都有一个 tag number,这是 proto schema 最核心的约束之一。**编号一旦分配并部署,就永远不能再复用**(详见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]])。
+
+合理的分配方式应按字段重要性和使用频率分层预留:
+
+```protobuf
+message User {
+ // === Core fields (1-9): 核心字段,几乎每次都会序列化 ===
+ string id = 1;
+ string name = 2;
+ string email = 3;
+
+ // === Secondary fields (10-19): 常用但非必需 ===
+ string phone = 10;
+ string avatar_url = 11;
+ User_Role role = 12;
+ bool active = 13;
+
+ // === Tertiary fields (20-99): 偶尔使用 ===
+ string bio = 20;
+ string website = 21;
+ Location location = 22;
+
+ // === Audit & metadata (100-199): 系统级元数据 ===
+ google.protobuf.Timestamp created_at = 100;
+ google.protobuf.Timestamp updated_at = 101;
+ string created_by = 102;
+ string updated_by = 103;
+}
+```
+
+> [!tip] 预留块的好处
+> 如果所有字段从 1 开始连续排列,每加一个都需要改后面所有的编号——而且已经部署的旧客户端会把新编号的字段当成不同的语义。**预留空位只需分配一个新编号,不影响已有字段。**
+
+> [!note] Wire Encoding 优化提示
+> 编号 1~15 编码仅需 1 byte,16~2047 需 2 bytes。对于高频通信的消息体(比如每秒钟百万调用),core fields 保持在 1~15 能节省可观的带宽开销。
+
+## 公共类型与 Well-Known Types
+
+跨服务共用的数据类型应当统一收敛到 `common/` 目录,避免每个模块各自定义导致的不一致:
+
+```protobuf
+// api/common/v1/timestamps.proto
+syntax = "proto3";
+package common.v1;
+
+message Timestamps {
+ google.protobuf.Timestamp created_at = 1;
+ google.protobuf.Timestamp updated_at = 2;
+ google.protobuf.Timestamp deleted_at = 3; // nil 表示未删除
+}
+
+// api/common/v1/pagination.proto
+syntax = "proto3";
+package common.v1;
+
+import "google/protobuf/wrappers.proto";
+
+message PaginationRequest {
+ int32 page_size = 1; // 默认值由服务端决定(通常 20)
+ string page_token = 2; // 游标翻页
+ google.protobuf.BoolValue include_deleted = 3; // 包装类型区分 unset
+}
+
+message PaginationResponse {
+ string next_page_token = 1;
+ bool has_more = 2;
+}
+
+// api/common/v1/errors.proto
+syntax = "proto3";
+package common.v1;
+
+import "google/rpc/status.proto";
+
+enum ErrorCode {
+ ERROR_CODE_UNSPECIFIED = 0;
+ ERROR_CODE_NOT_FOUND = 1;
+ ERROR_CODE_INVALID_ARG = 2;
+ ERROR_CODE_PERMISSION = 3;
+ ERROR_CODE_RATE_LIMIT = 4;
+}
+```
+
+> [!tip] Well-Known Types优先
+> Protobuf 内置了 `google/protobuf/{timestamp,duration,empty,wrapper,any,map}.proto`,尽量直接使用它们而不是自定义等价类型。这样做的好处是各语言 SDK 都有原生支持,序列化行为一致,且后续切换语言时零适配成本。
+
+### 常见模式速查
+
+| 场景 | 推荐类型 | 说明 |
+|------|---------|------|
+| 创建/更新时间 | `google.protobuf.Timestamp` | ISO 8601 格式,纳秒精度 |
+| 软删除标记 | `google.protobuf.Timestamp deleted_at` | nil = 未删除,比额外 bool 字段更省空间 |
+| 可选 bool/string | `google.protobuf.BoolValue/StringValue` | 区分 "未设置" 和 "设置为 false/空串" |
+| 无返回值 RPC | `google.protobuf.Empty` | 不要自己定义空的 message |
+| 不确定类型 | `google.protobuf.Value` | JSON-like 万能类型,牺牲类型安全换取灵活性 |
+
+## Breaking Change 检测
+
+Buf breaking 是最靠谱的 proto schema 演进保护机制:
+
+```bash
+# CI Pipeline step
+buf lint && buf breaking --against 'https://github.com/repo.git#branch=main'
+```
+
+检测内容覆盖:
+
+- 删除 / 修改 message field 类型
+- 移除 service 或 method
+- Enum value removal(除了追加新的值)
+- 字段编号复用(deleted tag number reused)
+
+```yaml
+# buf.yaml — 配置 breaking check 规则
+version: v2
+breaking:
+ use:
+ - FILE
+ - WIRE
+ ignore:
+ - user/v1/user.proto # 允许某些文件跳过检查
+```
+
+> [!tip] WIRE vs FILE
+> - `FILE`: 检测单个 .proto 文件内的 breaking change。
+> - `WIRE`: 检测 wire format 层面的兼容性问题(更严格),比如字段类型从 int32 改为 string。
+> - 生产环境推荐使用 `WIRE`。
+
+### Major Version 迁移指南
+
+当必须做不兼容变更时(如修改字段类型、重构消息嵌套关系),遵循以下流程:
+
+```mermaid
+flowchart TD
+ A["发现不兼容需求"] --> B["在 v2/ 下新建 proto\n不与 v1 共用 package"]
+ B --> C["API Gateway / Adapter\n实现 v1 <-> v2 双向转换"]
+ C --> D["灰度: 新旧客户端并行运行"]
+ D --> E{"全部客户端升级?"}
+ E -->|否| D
+ E -->|是| F["停用 v1, 清理旧代码"]
+
+ style B fill:#DBEAFE,color:#1E40AF
+ style C fill:#FEF3C7,color:#92400E
+ style F fill:#D1FAE5,color:#065F46
+```
+
+| 维度 | 并行共存(推荐) | 原地覆盖(❌ 高风险) |
+|------|-----------------|---------------------|
+| 线上影响 | 透明过渡 | 所有端同时断裂 |
+| 回滚成本 | 切回 v1 即可 | 几乎无法回滚 |
+| 开发成本 | 需写 Adapter 层 | 看似简单实则危险 |
+| 适用场景 | 所有已发布服务 | 仅限内部未发布 proto |
+
+具体兼容性矩阵和字段迁移细节参见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md#版本演进策略]]。
+
+## Lint 工具集成
+
+使用 Buf 进行 lint 检查,保证整个团队的 proto 风格一致:
+
+```yaml
+# buf.yaml
+version: v2
+lint:
+ use:
+ - STANDARD
+ ignore:
+ - user/v1/user.proto # 如果有特殊情况可以排除
+```
+
+```bash
+# 执行 lint
+buf lint
+
+# 带详细输出
+buf lint --error-format=json
+```
+
+Buf lint 默认启用 20+ 条规则,包括:
+
+- 字段编号范围(1-9999 为 reserved,10000-536870911 为用户自定义)
+- 消息字段数限制(单文件不超过 1000)
+- 枚举必须从 0 开始
+- 禁止重复字段名
+- 推荐添加注释
+
+> [!question] 为什么要统一 lint?
+> 不同开发者对字段编号的分配方式各异——有人用 1、2、3 连续编号,有人随意跳号。一旦引入 lint 规则,所有人的提交都会受到同一套标准的约束,减少 code review 中的琐事争论。
+
+## CI Pipeline 集成示例
+
+将 proto lint 和 breaking change 检测嵌入 CI 流程,确保问题在 PR 阶段就被拦截:
+
+```yaml
+# .github/workflows/proto-check.yml (GitHub Actions)
+name: Proto Check
+
+on:
+ pull_request:
+ paths:
+ - "api/**"
+
+jobs:
+ proto-lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Buf
+ uses: bufbuild/buf-setup-action@v1
+
+ - name: Run lint
+ uses: bufbuild/buf-lint-action@v1
+ with:
+ input: "api/"
+
+ - name: Run breaking change check
+ uses: bufbuild/buf-breaking-action@v1
+ with:
+ input: "api/"
+ against: "https://github.com/org/repo.git#branch=main,ref=head"
+```
+
+```yaml
+# GitLab CI 等效配置 (.gitlab-ci.yml)
+proto-check:
+ image: bufbuild/buf:latest
+ stage: test
+ script:
+ - buf lint api/
+ - buf breaking --against "https://gitlab.com/org/repo.git#branch=main,ref=head"
+```
+
+> [!tip] 关键设计原则
+> - **只在 api/ 路径变更时触发**——其他代码变化不应该阻塞 proto 检查 job
+> - **breaking 检测指向 main 分支**——对比的是当前 PR 相对于主干的变化
+> - **lint 和 breaking 分两个 job**——失败时能快速定位是风格问题还是真正的兼容性问题
+
+## Message Organization Strategy
+
+有两种主流策略对比:
+
+| 策略 | 优点 | 缺点 |
+|------|------|------|
+| 合并到一个 .proto | 简单,少文件 | 大文件难维护,冲突频繁 |
+| 拆分多个 .proto | 关注点分离,便于 diff | 容易循环引用 |
+| **推荐方案** | **按资源拆分 + 公共类型独立文件** | **团队规模 < 50 人适用** |
+
+### 推荐的文件职责边界
+
+```
+order/v1/
+├── order.proto # Order, OrderItem 等核心消息定义
+├── service.proto # CreateOrder, GetOrder, ListOrders 等 RPC
+└── errors.proto # ORDER_NOT_FOUND, ORDER_INVALID_STATE 等订单域错误码
+```
+
+每个文件的职责清晰,新增一个 RPC 只需改 `service.proto`,不影响其他文件,降低冲突概率。
+
+### 何时不应拆分
+
+并非越细越好。以下场景适合合并:
+
+- 小型项目(< 5 个 message),一个文件即可
+- 两个 message 强耦合、从不单独复用(强行拆分会增加维护成本)
+- 原型阶段,快速迭代优先于规范化
+
+> [!note] 决策树
+> ```
+> 消息数量 > 10 ?
+> ├── 是 → 拆分为 message.proto + service.proto
+> └── 否 → 是否会被其他 module 引用?
+> ├── 是 → 放到 common/ 而非各自 module
+> └── 否 → 合并在一个文件即可
+> ```
+
+## 关联笔记
+
+- [[hhs/gRPC/README.md]] — gRPC 知识库总览
+- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md]] — Protobuf 语法与消息定义基础
+- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md]] — Scalar Types、Wrapper Types、Well-Known Types 详解
+- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]] — Field Number 分配规则与版本演进策略
+- [[hhs/gRPC/2. gRPC 核心篇/06-Service 定义与代码生成.md]] — Service 定义与代码生成机制
+- [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile.md]] — protoc / Buf 工具链选型与配置
+- [[hhs/gRPC/6. 工程实践篇/19-跨语言兼容测试.md]] — 多语言互测注意事项
+- [[hhs/gRPC/6. 工程实践篇/20-性能优化与压测.md]] — 序列化大小调优与压测方法
diff --git a/hhs/gRPC/6. 工程实践篇/19-跨语言兼容测试.md b/hhs/gRPC/6. 工程实践篇/19-跨语言兼容测试.md
new file mode 100644
index 0000000..db51584
--- /dev/null
+++ b/hhs/gRPC/6. 工程实践篇/19-跨语言兼容测试.md
@@ -0,0 +1,371 @@
+---
+tags: [gRPC, Protobuf, cross-language, interoperability, type safety]
+create time: 2026-05-11 16:40
+---
+
+# 跨语言兼容测试
+
+## 概述
+
+当你的团队同时使用多种语言时,Protobuf 成了唯一的契约。但不同语言的 protobuf implementation 之间有一些微妙的差异可能导致「同一个 proto 文件生成的 Go struct 和 Java struct 行为不一致」。这篇帮你扫雷。
+
+## 协议缓冲区是平台无关的吗?
+
+简短回答:**大部分是,但有坑。**
+
+长答案:Wire format 是确定的(Google 保证了这一点),但以下方面可能因实现而异:
+
+- 字段的 zero value / default value 语义
+- repeated 字段的 empty vs unset
+- timestamp / string 序列化格式
+- wrapper types 的支持程度
+
+> [!question] 如果 wire format 是确定性的,为什么还会有问题?
+> Wire format 只保证「比特位一样」,但不规定收到比特位后语言层怎么解释。比如一个省略的 repeated 字段,wire 上根本不存在——Java runtime 返回空列表,Go runtime 返回 nil。这是语义层的不一致,不是二进制层的问题。
+
+## Go vs Java vs Node.js 对照表
+
+| 特性 | Go | Java | Node.js |
+|------|----|-----|---------|
+| int32 default | 0 | 0 | 0 |
+| string default | "" | "" | "" |
+| bool default | false | false | false |
+| repeated 空值 | nil 还是 [] | empty list | empty array |
+| wrapper types | auto-unpack ptr | null | null/undefined |
+| timestamps | RFC3339 string | com.google.protobuf.Timestamp | ISO 8601 string |
+| oneof | interface{} pattern | builder pattern | plain object |
+
+## 枚举兼容性陷阱
+
+```protobuf
+enum Status {
+ UNKNOWN = 0;
+ ACTIVE = 1;
+ INACTIVE = 2;
+}
+```
+
+| 场景 | 行为 |
+|------|------|
+| Go 收到未知枚举值 | 保持原始数值(不会 panic) |
+| Java 收到未知枚举值 | 保留 raw value,UNKNOWN 作为 fallback |
+| Node.js 收到未知枚举值 | 返回 number,不是 enum 类型 |
+
+**结论**:永远不要把 enum 当作精确类型来解析对方的值。
+
+```go
+// Go 侧安全处理枚举的写法
+switch resp.Status {
+case userpb.Status_ACTIVE:
+ handleActive()
+case userpb.Status_INACTIVE:
+ handleInactive()
+default:
+ // 未知值!可能是对方新增了 enum 而我们没更新
+ log.Warn("unknown status", "value", int(resp.Status))
+}
+```
+
+```java
+// Java 侧安全处理
+if (status == Status.ACTIVE) { ... }
+else if (status == Status.INACTIVE) { ... }
+else if (status == Status.UNSPECIFIED) {
+ // 未知值 —— protocol buffer 会将无法识别的 enum 值映射到 UNSPECIFIED
+ log.warn("unknown status raw: {}", status.getNumber());
+}
+```
+
+> [!warning] 致命模式
+> 在 TypeScript/Node.js 中使用 `enum(xxx)` 做强制转换。当对方传来一个新的枚举值时,`Status[xxx]` 可能返回 undefined,后续代码用 undefined 做判断可能直接 crash。
+
+## Timestamp 时间戳差异
+
+```protobuf
+google.protobuf.Timestamp created_at = 1;
+```
+
+各语言的表现:
+
+- **Go**: `"2024-01-01T12:00:00Z"` (RFC3339, nano precision)
+- **Java**: 同左(protobuf-java 3.x+ 遵循相同规范)
+- **Node.js**: 如果不使用 `@types/google-protobuf` 或手动处理,可能丢失 nano 精度
+
+> [!question] 为什么 timestamp 会有精度问题?
+> JavaScript 的 `Date` 只支持毫秒精度(Unix epoch / 1000),而 protobuf Timestamp 支持纳秒。当 Go server 发送了纳秒精度的时间戳时,Node.js client 默认只取到毫秒——这会导致时间排序错乱和幂等键不一致。
+
+验证测试(Node.js):
+
+```typescript
+// test-timestamp.ts
+const ts = new Date();
+const pb = Timestamp.fromDate(ts);
+console.assert(pb.toDate().getTime() === ts.getTime(), "nanosecond precision lost");
+```
+
+> [!tip] 最佳实践
+> 如果你的应用依赖 sub-second 精度,确保所有语言的运行时都是最新版本,并在跨语言 smoke test 中加入 nanosecond 级别的时间校验。
+
+## Repeated 字段的 Empty vs Unset
+
+```protobuf
+repeated string tags = 1;
+```
+
+- **Go**: untagged field → nil,empty → `[]string{}`
+- **Java**: 始终返回 non-null list(empty 或 populated)
+- **Node.js**: always array(empty or populated)
+
+这意味着 Go client 判断 tag 存在性要这样写:
+
+```go
+// 错误写法:resp.Tags != nil 无法区分 "没有设置" 和 "设置为空数组"
+// 正确写法
+if len(resp.Tags) > 0 {
+ fmt.Println("有标签:", resp.Tags)
+} else {
+ fmt.Println("无标签")
+}
+```
+
+## 前后兼容(Backward / Forward Compatibility)
+
+这是跨语言团队最常忽视的部分。Proto 的 wire format 设计本身就保证了前向和后向兼容,但前提是 **正确使用字段编号**。
+
+> [!question] 什么是前向兼容?什么是后向兼容?
+> - **后向兼容(Backward)**:新版 server + 旧版 client —— client 能正常通信,忽略新增字段。
+> - **前向兼容(Forward)**:旧版 server + 新版 client —— server 能正常通信,忽略它不认识的字段。
+
+### 核心规则
+
+| 操作 | 兼容性 | 原因 |
+|------|--------|------|
+| 删除字段 | 后向兼容 ✅ | 旧 client 忽略未定义的字段编号 |
+| 新增字段 | 后向+前向兼容 ✅ | 双方都只解析自己认识的字段 |
+| 复用字段编号 | 破坏兼容 ❌ | 旧 client 可能把新字段当旧字段解析 |
+| 修改 enum 值 | 后向兼容 ✅ | 未知 enum 值被忽略或 fallback |
+| 修改字段类型 | 破坏兼容 ❌ | 同一编号的不同类型可能导致数据损坏 |
+
+### 安全迁移模式:软删除 vs 硬删除
+
+```protobuf
+message User {
+ string name = 1;
+ bool is_active = 2;
+
+ // 方案A:软删除标记(推荐)
+ reserved 3; // 保留已删除字段的编号
+ // reserved "email"; // 也可以按名称保留
+}
+```
+
+> [!warning] 危险操作:删除和复用字段编号
+> 如果你删除了 `field 5`,然后在下一个版本把它重新分配给另一个完全不同的字段 —— 旧版本 client 会把新字段的二进制数据当作旧字段丢弃。如果新旧字段类型不同(比如 int32 → string),结果是不可预测的。始终使用 `reserved` 显式保留已废弃的编号。
+
+### 字段编号管理策略
+
+对于跨语言项目,建议建立一份 **全局字段编号注册表**:
+
+```
+proto/
+ user/v1/
+ user.proto // field 1-10 for core fields
+ user_extended.proto // field 11-20 for optional features
+```
+
+- **核心字段**:统一编号段(如 1-10),所有语言共同维护
+- **扩展字段**:独立 proto 文件,避免与核心模块争抢编号
+- **预留编号**:用 `reserved` 锁定即将删除的编号
+
+> [!tip] 最佳实践
+> 在 CI 中添加 protobuf linting(如 buf check breaking),确保任何 `.proto` 变更不会破坏 API 兼容契约。这比手动审查可靠得多。
+
+## Wrapper Types 的跨语言差异
+
+```protobuf
+import "google/protobuf/wrappers.proto";
+
+message User {
+ google.protobuf.StringValue display_name = 1;
+ google.protobuf.Int32Value age = 2;
+}
+```
+
+| 特性 | Go | Java | Node.js |
+|------|----|-----|---------|
+| 未设置 | `nil`(*StringValue) | null | undefined |
+| 设为空串 | `&wrapperspb.StringValue{Value:""}` | StringValue("") | {} |
+| 设非空值 | `&wrapperspb.StringValue{Value:"hello"}` | StringValue("hello") | "hello" (auto-unpack) |
+
+Wrapper types 是消除 zero-value 歧义的标准做法,但在跨语言时需要注意:
+
+- Go 生成的包装类型是指针,需要 nil check。
+- Java 生成的包装类型是对象引用,同样需要 null check。
+- Node.js 中 JSON 反序列化后无法区分 "未设置" 和 "设值为 null"——因为 JSON 中没有 `null` vs "missing" 的运行时差异(`undefined`)。需要通过检查 `Object.hasOwn(obj, 'fieldName')` 来手动判断。
+
+```typescript
+// TypeScript 安全读取 wrapper 字段的写法
+const displayName = user.hasOwnProperty('displayName')
+ ? user.displayName ?? 'no value'
+ : 'field not set';
+```
+
+> [!tip] Wrapper 的 JSON 陷阱
+> 通过 HTTP/JSON 透传 protobuf 数据时,`StringValue` 序列化为 `"display_name": ""` 而非 `"display_name": null`——Go 侧能正确解析,但某些 JavaScript ORM 会将空串当作已设置值。建议用 buf 配置将 proto 转为 JSON schema 时开启 `json_strip_unset` 选项。
+
+## 测试框架与工具
+
+光知道差异不够,关键是有一套自动化手段来 **持续验证** 跨语言一致性。以下是业界常用的做法:
+
+### Fixture-Based 测试模式
+
+核心思路:**一份 JSON fixture → 各语言反序列化 → 逐字段断言**。
+
+```go
+// go/test/crosslang_test.go
+func TestTimestampPreservation(t *testing.T) {
+ fixture := map[string]interface{}{
+ "name": "test-user",
+ "created_at": "2024-01-15T08:30:00.123456789Z",
+ "status": int32(1),
+ "tags": []interface{}{"a", "b"},
+ }
+ data, _ := json.Marshal(fixture)
+
+ var parsed userpb.User
+ proto.Unmarshal(data, &parsed)
+
+ assert.Equal(t, time.Date(2024, 1, 15, 8, 30, 0, 123456789, time.UTC),
+ parsed.CreatedAt.AsTime())
+}
+```
+
+Node.js 侧对应:
+
+```typescript
+// test/crosslang.test.ts
+import { User } from '../proto/user_pb';
+import { Timestamp } from '../proto/google/protobuf/timestamp_pb';
+
+describe('Timestamp round-trip', () => {
+ it('preserves nanosecond precision', () => {
+ const ts = Timestamp.fromMillis(Date.now());
+ ts.nanos = 123456000; // 设置纳秒精度
+ const raw = ts.toObject();
+ expect(raw.seconds).toBeDefined();
+ expect(raw.nanos).toBe(123456000);
+ });
+});
+```
+
+### 推荐工具链
+
+| 工具 | 用途 | 特点 |
+|------|------|------|
+| [buf](https://buf.build/) | Schema lint + breaking change detection | 比 protoc 更友好的 linting 和版本管理 |
+| gRPC-ecosystem/testrunner | 端到端集成测试 | 可在 docker-compose 中编排多语言服务 |
+| protobuf-test-fixtures | 社区 fixture 库 | 预置的标准测试数据,可直接复用 |
+| Protocol Buffers diff 工具 | 对比序列化输出 | 快速定位差异字段 |
+
+### CI 集成的最小方案
+
+```yaml
+# .github/workflows/proto-compat.yml
+name: Proto Compatibility Check
+on: [pull_request, paths: ['proto/**']]
+
+jobs:
+ check-breaking:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: bufbuild/buf-action@v1
+ with:
+ command: breaking
+ input: 'proto'
+ break-ignore: protos/breaking-rule-overrides.txt
+
+ smoke-test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ language: [go, java, node]
+ steps:
+ - run: make proto-gen-${{ matrix.language }}
+ - run: cd test/${{ matrix.language }} && make test
+```
+
+> [!tip] 最佳实践
+> 将 `buf breaking` 检查作为 PR block —— 任何破坏 API 兼容的 `.proto` 变更都会被自动拦截。这比依赖手动 review 可靠得多。
+
+## 跨语言兼容性测试流程
+
+```mermaid
+flowchart TB
+
+ subgraph SchemaLayer["Schema 层"]
+ A["proto 定义"]
+ end
+
+ subgraph CodeGen["代码生成"]
+ B["protoc-gen-go"] --> C["Go stub"]
+ D["protoc-gen-java"] --> E["Java stub"]
+ F["protoc-gen-js"] --> G["JS stub"]
+ end
+
+ subgraph SmokeTest["Smoke Test"]
+ H["统一 fixture\nJSON data"]
+ I["各语言反序列化\n对比输出"]
+ end
+
+ subgraph Assertions["断言检查"]
+ J["enum 值映射一致"]
+ K["wrapper nil vs null"]
+ L["timestamp precision"]
+ M["repeated empty vs nil"]
+ end
+
+ A --> B
+ A --> D
+ A --> F
+ H --> I
+ I --> C
+ I --> E
+ I --> G
+ C --> J
+ C --> K
+ C --> L
+ C --> M
+ E --> J
+ E --> K
+ E --> L
+ E --> M
+ G --> J
+ G --> K
+ G --> L
+ G --> M
+
+ style A fill:#EAB308,color:#fff
+ style H fill:#00B6BC,color:#fff
+ style J fill:#4FC08D,color:#fff
+ style K fill:#4FC08D,color:#fff
+ style L fill:#4FC08D,color:#fff
+ style M fill:#4FC08D,color:#fff
+```
+
+## 通用 Best Practices
+
+针对跨语言项目,遵循以下原则:
+
+1. **对于核心字段,使用 explicit wrapper types 消除歧义**——尤其是 optional string/int。
+2. **不要依赖 wire format 的稳定性**——虽然 Google 承诺了,但不要写直接解析二进制的代码。
+3. **对所有新 API 都做跨语言 smoke test**——至少在 Go、Java、Node.js 三个主流语言上跑一遍。
+4. **文档化已知差异**——如果某些行为因语言不同而有差异,记录下来写在 API doc 里。
+5. **定期同步第三方 library 版本**——特别是 protobuf runtime 的大版本升级时。
+
+## 关联笔记
+
+- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解]] — Protobuf 各数据类型的语法定义
+- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容]] — 字段编号管理、保留与废弃规则
+- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 和 wrapper types 的详细用法
+- [[hhs/gRPC/6. 工程实践篇/17-protoc 工具链与 Makefile]] — protoc 代码生成流程
+- [[hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范]] — 多模块 proto 组织方式
\ No newline at end of file
diff --git a/hhs/gRPC/6. 工程实践篇/20-性能优化与压测.md b/hhs/gRPC/6. 工程实践篇/20-性能优化与压测.md
new file mode 100644
index 0000000..70794ab
--- /dev/null
+++ b/hhs/gRPC/6. 工程实践篇/20-性能优化与压测.md
@@ -0,0 +1,459 @@
+---
+tags: [gRPC, performance, benchmark, compression, keepalive, optimization]
+create time: 2026-05-11 17:00
+---
+
+# 性能优化与压测
+
+## 概述
+
+gRPC 天生就比传统 REST API 快,但要榨干它的性能上限还需要系统性的调优。从 Protobuf 序列化大小的字节级优化、连接复用策略、Compression 压缩的精确控制到 Keepalive 参数调校——再到用 Benchmark 压测验证每一个改动是否真正生效,这篇帮你建立完整的性能优化思维模型。
+
+> [!question] gRPC 真的比 REST 快吗?
+> 以典型 User Profile 消息为例(ID + Name + Email + Avatar URL):
+>
+> | 格式 | Payload 大小 | 编码开销 |
+> |------|-------------|---------|
+> | Protobuf (binary) | ~280 bytes | Tag + varint,无字段名冗余 |
+> | JSON (UTF-8) | ~950 bytes | 键名重复出现,引号包裹 |
+> | JSON (minified) | ~720 bytes | 去掉空白符后的极限 |
+>
+> Protobuf 约为 JSON 的 **1/3 ~ 1/4**,加上 HTTP/2 的多路复用和 binary framing,通常 QPS 高 2~5 倍。但如果你已经在高效使用 HTTP/1.1 + JSON,差距可能没那么惊人。**真正的优势在于确定性与低延迟的可预测性**。
+
+## 序列化大小优化
+
+Protobuf 序列化体积是带宽、GC 压力和磁盘 IO 的上游因素,每次减少几十字节都可能在高并发场景下带来可感知的改善。
+
+### int32 vs int64
+
+Varint 编码的大小取决于数值本身,而非声明的类型。选择合适的大小能省下不少字节:
+
+```protobuf
+// 好:大部分用户 ID 不超过 int32 范围
+message User {
+ int32 id = 1; // varint, 1-5 bytes
+}
+
+// 差:没必要用 int64
+message UserID {
+ int64 id = 1; // varint, 1-10 bytes
+}
+```
+
+规则很简单:如果数值不超过 `2^31 - 1`(约 21 亿),用 `int32` 而不是 `int64`。这能节省最多 50% 的 varint 空间。
+
+> [!tip] uint32 vs fixed32
+> 固定大小的整数(如版本号、哈希值)可以用 `fixed32` / `fixed64`,解码时省去了 varint 变长解码步骤,速度更快——但代价是每个值固定占用 4/8 字节,无论数值多小。适合对性能极其敏感且数值分布广泛的场景。
+
+### packed repeated
+
+```protobuf
+// 默认 packed,节省空间
+repeated int32 tags = 1; // [1, 2, 3] -> 3 bytes instead of 9
+
+// 取消 packing(几乎不需要)
+repeated int32 tags = 1 [packed = false];
+```
+
+packed repeated 将连续的数值字段打包为变长整数序列,对于高频整型数组可节省 60~70% 的传输体积。
+
+> [!important] string repeated 不能 pack
+> `packed` 仅适用于数值类型和 `bytes`。`repeated string` 无法 packing,因为字符串长度不定,无法可靠分割。
+
+### 避免不必要的嵌套
+
+```protobuf
+// 不好:多层嵌套增加 tag overhead
+message Address {
+ Location location = 1;
+}
+
+message Location {
+ string city = 1;
+ string street = 2;
+}
+
+// 好:扁平化
+message Address {
+ string city = 1;
+ string street = 2;
+}
+```
+
+每多一层嵌套就多一组 tag+size header,对小消息影响显著。
+
+### Field Number 分配策略
+
+连续编号不会影响 serialized size(tag+varint 对于小于 16384 的编号都是 1 byte),但跳号会浪费可读性和后续扩展的空间规划:
+
+```protobuf
+// 好:预留扩展空间
+message User {
+ string id = 1;
+ string name = 2;
+ string email = 3;
+ // 预留 4-10 给后续新增字段
+}
+```
+
+> [!seealso] 深入了解
+> 更多 Field Number 的兼容细节,参见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]]。
+
+### 序列化优化最佳实践速查表
+
+| 策略 | 适用场景 | 预期收益 | 风险 |
+|------|---------|---------|------|
+| int32 代替 int64 | ID、状态码、计数器等 | 节省 20~50% varint 空间 | 数值溢出时需迁移 |
+| packed repeated | 高频整型/字节数组标签列表 | 节省 60~70% 体积 | 需 protobuf v3 或 proto2 with `[packed=true]` |
+| 扁平化消息结构 | 深层嵌套 (< 3 层) | 减少 tag overhead | 语义上是否合理 |
+| 按需返回字段 | RPC 请求中指定要哪些字段 | 减少不必要的数据传输 | 需要 oneof 或 optional 支持 |
+| string 替换 enum (small set) | 枚举值极少 (< 5 个) 且稳定 | 避免 tag 变化时的兼容问题 | 硬编码在二进制中 |
+
+> [!tip] 先测量再优化
+> 不要盲目猜测哪个字段最耗空间。用一个真实 payload 调用 `proto.Marshal()` 然后打印 `len()` 是最直接的诊断方式:
+> ```go
+> data, _ := proto.Marshal(&msg)
+> fmt.Printf("Serialized: %d bytes\n", len(data))
+> ```
+> 逐个字段注释掉再量,就能定位"胖字段"。
+
+## Connection Pooling
+
+> [!warning] 最重要的一条
+> **永远复用 conn,不要每次调用都 Dial。** 每次 Dial 建立新的 TCP/TLS 连接开销极大,在高并发场景下会导致端口耗尽和性能断崖式下跌。
+
+### conn 管理原则
+
+gRPC 内部已经实现了连接池(transport layer multiplexing),底层自动维护多路复用的 HTTP/2 连接。你只需要记住三个原则:
+
+1. **一目标一连接**:同一个 target address 全局共享一个 `*grpc.ClientConn`
+2. **进程生命周期内复用**:conn 应在应用启动时创建,退出的时候关闭
+3. **并发安全**:`*grpc.ClientConn` 天生支持多 goroutine 同时使用
+
+```go
+var conn *grpc.ClientConn
+
+func initClient(addr string) error {
+ var err error
+ conn, err = grpc.DialContext(context.Background(), addr,
+ grpc.WithTransportCredentials(credentials.NewTLS(nil)),
+ grpc.WithKeepaliveParams(keepalive.ClientParameters{
+ Time: 10 * time.Second,
+ Timeout: 5 * time.Second,
+ PermitWithoutStream: true,
+ }),
+ )
+ return err
+}
+
+// defer func() { conn.Close() }() // 退出时统一关闭
+```
+
+> [!note] 为什么不用 WithConnectTimeout?
+> gRPC Go SDK 没有 `grpc.WithConnectTimeout` 这个选项。连接超时应通过 `context.WithTimeout` 配合 `grpc.DialContext` 控制;或者使用自定义 dialer 包装 `net.Dialer.Timeout`。
+
+### 连接数与并发调优
+
+#### MaxConcurrentStreams
+
+```go
+server := grpc.NewServer(
+ grpc.MaxConcurrentStreams(100), // default: 100
+)
+```
+
+MaxConcurrentStreams 决定了单个 HTTP/2 连接上允许的最大并行流数量。调整依据:
+
+| 场景 | 推荐值 | 说明 |
+|------|--------|------|
+| 高频短流(Unary) | 100(默认) | 默认值即可,新 Stream 立即关闭 |
+| 低频长流(BiDi Streaming) | 10-50 | 降低以减少内存占用 |
+| 超大流量(万级 QPS) | 500-1000 | 配合后端 capacity 调整 |
+
+> [!danger] 不要设得太高
+> 过大的 MaxConcurrentStreams 意味着每个连接上可能堆积大量未完成的 Stream,消耗服务器内存。生产环境建议设置为实际峰值需求的 1.5~2 倍。
+
+## Compression(Gzip 压缩)
+
+Per-call 级别的压缩控制:
+
+```go
+// Client 侧调用时指定压缩算法
+resp, err := client.GetUser(ctx, req, grpc.UseCompressor(gzip.Name))
+```
+
+何时启用 gzip 压缩的判断条件:
+
+| 条件 | 建议 |
+|------|------|
+| payload > 1KB | 启用,收益明显 |
+| payload < 100B | 禁用,header 开销超过压缩收益 |
+| CPU 受限的服务 | 谨慎启用,解压有 CPU cost |
+| 延迟敏感型 API | 不加压缩,网络带宽通常不是瓶颈 |
+
+> [!tip] Server-side 全局压缩
+> gRPC 官方不支持 server-side 的全局压缩 interceptor(这是一个已知的 limitation)。如果需要全局压缩,有两种替代方案:
+>
+> 1. **在拦截器中对 Response 做 gzip 压缩后写入**——但需要客户端同步解压
+> 2. **在 lbloadbalancer 或 ingress 层统一处理**——由 Nginx/envoy 承担压缩工作
+>
+> 多数情况下,推荐在 **Client 侧按接口特性选择性开启**,这样更精细可控。
+
+```mermaid
+flowchart TD
+ Start["是否需要压缩?"] --> Payload{"Payload > 1KB?"}
+ Payload -->|"否"| Disable["禁用压缩\n节省 CPU"]
+ Payload -->|"是"| CPU{"服务 CPU 充足?"}
+ CPU -->|"否"| Disable
+ CPU -->|"是"| Network{"带宽紧张?"}
+ Network -->|"否"| Decision["视延迟敏感度而定\n通常仍然值得压缩"]
+ Network -->|"是"| Enable["启用压缩\ngzip/zstd"]
+ Enable --> Zstd{"是否可用 zstd?"}
+ Zstd -->|"是"| Best["首选 zstd\n压缩率更高, 速度更快"]
+ Zstd -->|"否"| Gzip["使用 gzip\n兼容最广"]
+
+ style Best fill:#00D866,color:#fff
+ style Gzip fill:#4FC08D,color:#fff
+ style Disable fill:#FF6B6B,color:#fff
+```
+
+> [!question] gzip 和 zstd 怎么选?
+> **zstd 是目前的最佳选择**:压缩率与 gzip 相当或更好,解压缩速度快 30%+。gRPC 自 v1.35 起原生支持 zstd compressor:
+> ```go
+> import "google.golang.org/grpc/encoding/zstd"
+> // zstd 自动注册为 "zstd",直接在 call option 中使用
+> client.GetUser(ctx, req, grpc.UseCompressor(zstd.Name))
+> ```
+
+## Keepalive 调优
+
+连接保活是确保链路健康的关键,错误的 keepalive 参数是导致"偶发超时""连接静默断裂"等问题的常见原因。
+
+### Client Parameters
+
+```go
+cap := keepalive.ClientParameters{
+ Time: 10 * time.Second, // 发送 ping 间隔
+ Timeout: 5 * time.Second, // 等待 pong 超时
+ PermitWithoutStream: true, // 空闲时也发送 ping
+}
+grpc.WithKeepaliveParams(cap)
+```
+
+| 参数 | 默认值 | 含义 | 推荐调整 |
+|------|-------|------|---------|
+| `Time` | 2h | 两次 ping 之间的间隔 | 内网 10~30s,跨云 30~60s |
+| `Timeout` | 20s | 服务端无响应则断开 | 通常保持默认 |
+| `PermitWithoutStream` | false | 即使无活动流也发送 ping | **强烈建议设为 true** |
+
+> [!tip] 为什么要设 PermitWithoutStream?
+> 默认值为 false 意味着:如果一个 Stream 完成后不再新建流,客户端将不再发送 ping。此时中间件(Nginx、Cloud LB、防火墙)可能认为连接已闲置而提前断开——等你下次发消息时才会发现连接断了,导致 `unavailable` 错误。设为 true 后可让 gRPC 自行维护连接健康状态。
+
+### Server Parameters
+
+```go
+scp := keepalive.ServerParameters{
+ Time: 10 * time.Second,
+ Timeout: 5 * time.Second,
+}
+grpc.KeepaliveParams(scp)
+```
+
+| 参数 | 默认值 | 含义 | 推荐调整 |
+|------|-------|------|---------|
+| `Time` | 2h | 两次 ping 间隔 | 同上 |
+| `Timeout` | 20s | 客户端无响应则断开 | 通常保持默认 |
+| `MinTime` | 5m | 客户端最小 ping 频率 | 防止客户端频繁 ping |
+
+> [!info] MinTime 保护服务端
+> 如果客户端 keepalive Time 设置过小(比如 1s),服务端会通过 `MinTime` 拒绝太快收到 ping 的连接。这是防止恶意或配置错误的客户端造成服务端资源浪费的安全机制。
+
+## Benchmark 方法学
+
+标准 gRPC benchmark 模板:
+
+```go
+func BenchmarkGRPCUnaryCall(b *testing.B) {
+ lis, _ := net.Listen("tcp", "localhost:0")
+ s := grpc.NewServer()
+ pb.RegisterUserServiceServer(s, mockServer{})
+ go s.Serve(lis)
+ defer s.Stop()
+
+ conn, _ := grpc.Dial(lis.Addr(),
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
+ )
+ defer conn.Close()
+
+ client := pb.NewUserServiceClient(conn)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _ = client.GetUser(context.Background(), &pb.GetUserRequest{Id: "1"})
+ }
+ b.StopTimer()
+}
+```
+
+运行:`go test -bench=. -benchmem -benchtime=5s`
+
+关键 flag 说明:
+
+- `-benchmem`: 打印内存分配统计
+- `-benchtime=5s`: 至少跑 5 秒以确保数据稳定
+- `-cpuprofile=cpu.pprof`: 导出 CPU profile 进一步分析
+
+### 解读 Benchmark 结果
+
+```
+pkg: myapp/pb
+BenchmarkGRPCUnaryCall-8 35421 33829 ns/op 4128 B/op 42 allocs/op
+```
+
+| 指标 | 含义 | 优化方向 |
+|------|------|---------|
+| `ns/op` | 单次请求平均耗时 | 降低 P99、优化串行逻辑 |
+| `B/op` | 单次请求堆分配字节数 | 减少临时对象、复用 buffer |
+| `allocs/op` | 单次请求 heap 分配次数 | 结合 `sync.Pool` 复用对象 |
+
+### 不同模式的基准对比
+
+| 模式 | 压缩 | Payload | 典型 QPS (单核) | 典型 Latency |
+|------|------|--------|----------------|-------------|
+| Unary | 无 | 500B | 18K-25K | 40-55 μs |
+| Unary | gzip | 500B | 12K-18K | 55-80 μs |
+| Unary | gzip | 5KB | 10K-15K | 80-150 μs |
+| Unary | gzip | 50KB | 3K-6K | 200-500 μs |
+| Server Stream | 无 | 每 chunk 1KB | 5K-8K | 120-200 μs |
+| BiDi Stream | gzip | 每 chunk 2KB | 2K-4K | 300-600 μs |
+
+> [!note] 注意事项
+> 1. 服务端和客户端在同一个 benchmark 函数中启动和关闭——但这不代表你应该在生产环境中这样做。
+> 2. 使用 `b.ResetTimer()` 排除 setup 耗时。
+> 3. 忽略返回值 (`_ =`) 以测量 pure throughput,保留返回值以测量 real-world latency。
+> 4. 以上数据仅作参考基准,实际性能取决于硬件、网络、Protobuf 消息结构和 handler 复杂度。
+
+## 压测实战
+
+### 端到端压测架构
+
+```mermaid
+flowchart LR
+ subloader["🧪 压测客户端"]
+ subclient["负载均衡"]
+ subservice["gRPC Service (N 副本)"]
+ subdb[("Database")]
+
+ subloader -->|"HTTP/gRPC"| subclient
+ subclient -->|"round-robin"| subservice
+ subservice -->|"query"| subdb
+
+ style subloader fill:#E1BEE7
+ style subclient fill:#BBDEFB
+ style subservice fill:#C8E6C9
+ style subdb fill:#FFCCBC
+```
+
+一个简单的端到端压测流程:
+
+```
+1. 准备 mock 数据 → 构造真实的 Proto Message
+2. 部署 1-N 个 service pod
+3. 用 hey/k6/wrk 发起压力测试
+4. 记录 P50/P90/P99/Latency/SLO 达标率
+5. 逐步增加并发直到 hitting bottleneck
+```
+
+### 常用工具推荐
+
+| 工具 | 协议支持 | 特点 | 适用场景 |
+|------|---------|------|---------|
+| [hey](https://github.com/rakyll/hey) | HTTP/1.1, h2c | 简单快速,Go 编写 | 快速 sanity check |
+| k6 | gRPC via JS API | 脚本灵活,带可视化 | 完整 E2E 压测 |
+| ghz | gRPC native | 专为 gRPC 设计,YAML 配置 | Protocol-level benchmark |
+| custom Go bench | gRPC native | 完全可控 | 开发阶段集成测试 |
+
+```bash
+# ghz 示例:一键压测已有 proto
+ghz --call demo.UserService.GetUser \
+ --data '{"id": "test-001"}' \
+ -n 10000 -c 100 \
+ localhost:50051
+```
+
+## 性能调优 Checklist
+
+| 优化项 | 预估提升 | 难度 | 优先级 |
+|--------|---------|------|--------|
+| 复用连接(不重新 Dial) | 50%+ | ⭐ | 🔴 Critical |
+| 减少 proto 文件大小 | 20-60% | ⭐⭐ | 🔴 Critical |
+| 批量 RPC(而非 N 次 unary) | 80%+ | ⭐⭐ | 🔴 Critical |
+| Keepalive 调优 | 减少断连 | ⭐⭐ | 🟡 High |
+| 开启 gzip(大 payload) | 30-80% | ⭐ | 🟡 Medium |
+| MaxConcurrentStreams 调优 | 少量 | ⭐ | 🟢 Low (specialized) |
+| Buffer pool 复用 (`sync.Pool`) | 5-15% | ⭐⭐⭐ | 🟢 Edge case |
+
+> [!tip] 优化顺序建议
+> 先做前两项(连接复用 + proto 瘦身),它们几乎零成本且回报最高。其余优化务必先用 benchmark 验证——"感觉变快了"不等于"数据上变快了"。
+
+## 监控关键指标
+
+使用 OpenTelemetry gRPC interceptor 自动采集指标:
+
+```go
+import _ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+
+// otelgrpc 自动产出的核心指标:
+// rpc.server.duration.p50 / p99 — 服务端延迟分位
+// rpc.client.sent.total_requests — 客户端发出请求总量
+// rpc.server.received_messages_per_rpc — 每次 RPC 接收消息数均值
+// grpc.transport.network.sent.bytes / received.bytes — 网络流量
+```
+
+这些指标接入 Prometheus 后,可以监控:
+
+- P99 延迟是否高于预期
+- 每秒请求量的突增/突降
+- 网络发送/接收字节的异常波动
+- 未解决的 stream 堆积数
+
+```yaml
+# prometheus scrape_config 示例
+scrape_configs:
+ - job_name: 'grpc-services'
+ metrics_path: '/metrics'
+ static_configs:
+ - targets: ['service-a:8080', 'service-b:8080']
+ # otelgrpc 默认暴露 /metrics 路径
+```
+
+> [!note] 进阶链路追踪
+> 除了延迟和吞吐量,还需要关注调用链上下文。详见 [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md]]。
+
+## 典型问题诊断流程
+
+```mermaid
+flowchart TD
+ A["慢或超时"] --> B{"P99 > P50 * 10?"}
+ B -->|Yes| C["网络问题\n查 keepalive 和 DNS resolution"]
+ B -->|No| D["服务端处理慢\nProfile, DB query, serialization"]
+
+ C --> E{"K8s 或 LB 层?"}
+ E -->|Yes| F["调整 keepalive params\n放宽中间件 idle timeout"]
+ E -->|No| G["tcpdump + h2spec 抓包分析"]
+
+ D --> H["go test -bench\n定位瓶颈"]
+
+ style D fill:#FFD43B
+ style F fill:#00D866,color:#fff
+ style H fill:#4FC08D,color:#fff
+```
+
+## 关联笔记
+
+- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md]]
+- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md]]
+- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]]
+- [[hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial.md]]
+- [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md]]
diff --git a/hhs/gRPC/README.md b/hhs/gRPC/README.md
new file mode 100644
index 0000000..e29c497
--- /dev/null
+++ b/hhs/gRPC/README.md
@@ -0,0 +1,111 @@
+---
+tags: [gRPC, Protobuf, Go, RPC, Microservice]
+create time: 2026-05-11 15:30
+---
+
+# gRPC & Protobuf 知识库
+
+## 概述
+
+gRPC 是 Google 开源的高性能 RPC 框架,基于 HTTP/2 和 Protobuf 序列化,是现代微服务通信的事实标准。本知识库从 Protobuf 消息定义入手,贯穿服务端、客户端、流式通信、中间件链到生产级工程实践。
+
+> [!question] gRPC vs REST?什么时候选 gRPC?
+> gRPC 适合内部服务间通信:强类型契约(Protobuf)、低延迟、双向流;REST 更适合对外 API:人类可读、浏览器原生支持、缓存友好。二者并非互斥——常见的做法是用 API Gateway 将 REST 转为 gRPC。
+
+## 目录索引
+
+### 1. Protobuf 基础篇
+
+- **[01-Protobuf 语法与消息定义](./1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md)** — `.proto` 文件结构、message / enum / oneof / map 字段、package 与 import
+- **[02-数据类型详解](./1. Protobuf 基础篇/02-数据类型详解.md)** — Scalar Types、Wrapper Types、Well-Known Types、Repeated 与 Packaged 语义
+- **[03-字段编号与前向兼容](./1. Protobuf 基础篇/03-字段编号与前向兼容.md)** — Field Number 分配规则、Reserved、版本演进策略、向后/向前兼容原理
+- **[04-Oneof 与包装类型](./1. Protobuf 基础篇/04-Oneof 与包装类型.md)** — Oneof 排他选择、GoogleProtobuf.Value 万能类型、Any 泛型封装
+
+### 2. gRPC 核心篇
+
+- **[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
+
+### 3. 服务端实现
+
+- **[08-Server 搭建与注册](./3. 服务端实现/08-Server 搭建与注册.md)** — grpc.NewServer、服务注册、多端口监听、Reflection
+- **[09-Streaming Handler](./3. 服务端实现/09-Streaming Handler.md)** — Send / Recv 循环模式、Context 超时处理、优雅关闭流程
+- **[10-健康检查与反射](./3. 服务端实现/10-健康检查与反射.md)** — Health Check v1 API、ServerReflection、调试接入
+
+### 4. 客户端开发
+
+- **[11-Client 连接与 Dial](./4. 客户端开发/11-Client 连接与 Dial.md)** — grpc.Dial / WithInsecure / TransportCredentials、Dial Options 精选
+- **[12-Call Options 与 Context](./4. 客户端开发/12-Call Options 与 Context.md)** — WithTimeout / WithMeta / Retry Policy、Context 取消传播
+- **[13-Streaming Client](./4. 客户端开发/13-Streaming Client.md)** — 三种 Stream 的 Client 端遍历模式、recv 错误分类
+
+### 5. 中间件与拦截器
+
+- **[14-Unary 与 Stream 拦截器](./5. 中间件与拦截器/14-Unary 与 Stream 拦截器.md)** — Chain 链构建、interceptor.Handler 签名、上下文传递
+- **[15-元数据与鉴权](./5. 中间件与拦截器/15-元数据与鉴权.md)** — MD 读取/注入、Token 校验、TLS mTLS、per-RPC Credentials
+- **[16-日志与链路追踪](./5. 中间件与拦截器/16-日志与链路追踪.md)** — 请求耗时统计、Trace ID 透传、OpenTelemetry 集成
+
+### 6. 工程实践篇
+
+- **[17-protoc 工具链与 Makefile](./6. 工程实践篇/17-protoc 工具链与 Makefile.md)** — protoc-gen-go / protoc-gen-go-grpc 版本匹配、go generate、多语言生成
+- **[18-模块拆分与 proto 规范](./6. 工程实践篇/18-模块拆分与 proto 规范.md)** — proto 目录结构、命名约定、import 路径规范、linter 集成
+- **[19-跨语言兼容测试](./6. 工程实践篇/19-跨语言兼容测试.md)** — Go ↔ Java ↔ Node 互通、默认值差异、枚举一致性
+- **[20-性能优化与压测](./6. 工程实践篇/20-性能优化与压测.md)** — 序列化大小调优、连接池复用、Compression、gRPC-Bench 压测手法
+
+## 核心架构
+
+```mermaid
+graph TB
+ subgraph "应用层"
+ A["Handler 业务逻辑"]
+ B["Interceptor Chain"]
+ C["Metadata / Auth / Trace"]
+ end
+
+ subgraph "gRPC 层"
+ D["Server / Client"]
+ E["Stream Controller"]
+ F["Call Options"]
+ end
+
+ subgraph "传输层"
+ G["HTTP/2 Framing"]
+ H["HPACK Header"]
+ I["Flow Control"]
+ end
+
+ subgraph "数据层"
+ J["Protobuf Serialize"]
+ K["Message Definition"]
+ L["Enum / Oneof / Map"]
+ end
+
+ A --> B --> D --> G --> J
+ B --> C
+ D --> E --> F
+ J --> K --> L
+
+ style A fill:#00B6BC,color:#fff
+ style D fill:#4FC08D,color:#fff
+ style G fill:#FF6B35,color:#fff
+ style J fill:#A0AEC0,color:#fff
+```
+
+## 学习路径建议
+
+```mermaid
+flowchart LR
+ P1["Protobuf 语法"] --> P2["gRPC 核心概念"]
+ P2 --> P3["服务端开发"]
+ P2 --> P4["客户端开发"]
+ P3 --> P5["中间件与拦截器"]
+ P4 --> P5
+ P5 --> P6["工程实践"]
+ P5 --> P7["性能优化"]
+
+ style P1 fill:#00D866,color:#fff
+ style P6 fill:#FF9F43,color:#000
+ style P7 fill:#EE5A24,color:#fff
+```
+
+## 关联笔记