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 +``` + +## 关联笔记