写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 10 章。前九章你一直在写一个越来越大的单体服务——用户、发帖、互动、榜单、监控都挤在同一个
webook进程里。这一章,全书的第二次重大转折来了:我们要把这个单体拆成微服务。这一章同时扔给你三个新词:gRPC、Protobuf、DDD,外加一整套"生产级 gRPC"话题(多路复用、Keepalive、TLS、Metadata……)。概念密度是全书最高的,新手极容易在这里"读着读着就迷路"。所以这一版做了四件事:
- 概念全部就地讲透。凡是你没见过的词(RPC 是啥、Protobuf 为啥不用 JSON、DDD 的限界上下文怎么定边界),当场就补「补课胶囊」,不让你自己去搜。
- 每一步都能跑。所有代码都标了文件路径、给了运行命令、写了预期输出。你照着敲,看到的应该和我写的一模一样。
- gRPC 不止"能跑"。3.16 节起补上生产级进阶:单 TCP 多路复用、连接池取舍、Keepalive 保活、错误码与统一响应、Metadata/Context 透传、TLS 通讯安全、bufconn 无网络单测——面试里"进阶追问"的题眼基本都在这里。
- 结尾有面试问答。17 道题按"基础 → 进阶 → 追问"三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。
如果你想要"高密度速查版",可以看原版第 10 章 ;原版覆盖本章主线知识,这一版在它的基础上把 gRPC 生产级进阶补齐了,讲法也更细。
一、本章导读
上一章(第 09 章)我们给单体加上了分布式定时任务做榜单计算,webook 这个单体已经非常"胖"了:用户、发帖、互动、榜单、监控全部塞在同一个进程里。能跑,但开始暴露问题——代码量变大、团队协作互相踩、发版要整个服务一起停。
这一章是全书的转折点:从这里开始,我们要把单体拆成微服务。但"拆"不是蛮力——本章先回答"为什么拆、什么时候根本不该拆",再讲清拆分需要的工具(RPC、gRPC、Protobuf)和理论依据(DDD 的限界上下文),最后给出一个可操作的"四阶段拆分路线"和一个"灰度上线方案"。
学完本章,你会具备一种判断力:什么时候该拆、怎么拆、拆完怎么平稳上线而不把线上搞崩。后面五章(数据迁移、注册发现、负载均衡、服务治理、支付)全都建立在这一章的地基上。
本章正文分两段:3.1-3.15 解决"为什么拆、怎么拆",并把 gRPC 从 proto 到调用的基础链路跑通;3.16-3.22 把这条链路从"能跑"升级到"生产可用"——多路复用与连接池、Keepalive 保活、错误规范、Metadata/Context、TLS 安全、bufconn 单测,最后用一条完整链路收口。两段都保持"先抛问题 → 给直觉 → 能跑的代码 → 小收口"的节奏,不会突然变成术语轰炸。
开读前先看一眼全局:这一章只回答两个问题——“为什么拆"和"怎么拆”。图左边是现状:所有模块挤在一个进程里;右边是目标:每个领域独立成服务,进程之间用 gRPC 通信。正文 3.13.15 讲拆分思路并把 gRPC 基础链路跑通,3.163.22 把这条链路打磨到生产可用。
flowchart LR
subgraph MONO["拆分前:单体"]
A["webook 单进程"] --> B["user / post / interaction
模块同进程函数调用"]
end
subgraph MS["拆分后:微服务"]
C["webook API 层"] -->|"gRPC"| D["user-service"]
C -->|"gRPC"| E["post-service"]
C -->|"gRPC"| F["interaction-service"]
end
MONO ==>|"按业务边界拆开"| MS这张图怎么读:左边 MONO 子图是拆分前的单体——webook 单进程里的 user/post/interaction 模块靠同进程函数调用(实线)直接互调;右边 MS 子图是拆分后的形态——webook 退化为 API 层,经三条 gRPC 调用分别访问 user-service、post-service、interaction-service 三个独立服务;中间粗箭头点明转变依据是"按业务边界拆开"。对比左右两侧的关键差异:调用方式从进程内函数调用变成跨进程网络调用——这正是 3.4 节要补的 RPC 存在的意义。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 写出一个
.proto文件,定义service+message,用protoc(或 buf)编译出.pb.go和_grpc.pb.go。 - 起一个 gRPC 服务端(监听某个端口),再用客户端连上它成功调一次 RPC 方法。
- 把本地调用和 gRPC 调用都实现同一个接口,用"阈值 + 随机数"开关把 5% 流量切到远程、其余走本地,跑通灰度。
- 按"四阶段路线图"说出从单体到微服务的演进步骤,并指出"先易后难"原则下第一个该拆的模块。
- 在 proto 里正确使用
repeated/map/enum,并说清"字段编号是字段身份证、不能改不能复用"。 - 用 bufconn(内存 listener + 自定义 dialer)给 gRPC 服务端写一条不占端口的单测,
go test跑绿。 - 给 gRPC 服务端与客户端配上 Keepalive 参数,并用
status.Error(codes.X, ...)返回规范错误、客户端按 code 分支处理。
理解(能讲清原理和取舍,不要求背代码)
- 微服务架构的动机是"分而治之",以及"为什么 S > Sa + Sb"(合并后复杂度会多出模块间交互)。
- 模块化单体 vs 微服务化在"部署 / 资源 / 演进 / 组织"四个维度的差异,重点是"实例才是最基本的运维单位"。
- RPC、HTTP、RESTful、微服务四者各是什么、谁包含谁——微服务 ≠ 必须 RPC。
- Protobuf 为什么比 JSON 省带宽、怎么靠"字段编号"做版本兼容;gRPC 基于 HTTP/2 + Protobuf 的四种调用方式。
- DDD 的限界上下文 / 聚合根 / 实体 / 值对象 / 领域事件,以及它们和"怎么拆服务"的关系(不展开成 DDD 专著)。
- HTTP/2 单 TCP 多路复用的原理(Stream / Frame / HPACK),以及"为什么一个
ClientConn就够、什么时候才需要自研连接池"。 - Keepalive 两端参数是怎么互相约束的(客户端
Timevs 服务端MinTime),以及"连接级心跳"和 etcd「服务级心跳」的区别。 - TLS / 凭证(PerRPCCredentials)/ 拦截器三层安全各自解决什么问题;Metadata 与 Context 如何把 traceId、deadline、取消信号沿调用链传下去。
了解(知道有这回事,面试能说出名字和一句话定位)
- 微服务框架选型对比(gRPC / go-zero / Dubbo-go / go-micro),结论"遇事不决选 gRPC"。
- API 仓库管理的三种方案(统一仓库 / 各服务内部 / 实现仓库 + API 仓库)。
- 微服务研发环境的三种形态(单一测试环境 / 测试+一个开发 / 测试+多个开发)。
- 微服务知识图谱的五大块(通信 / 治理 / 数据 / 基础设施 / 可观测性)。
- gRPC-Gateway(同一份
.proto同时暴露 REST/JSON)与 mTLS 双向认证——知道名字和一句话定位即可。
前置依赖与补课清单
本章用到前文已经讲过的知识,也第一次引入大量全新术语。全新术语一律就地补「补课胶囊」,不要求你提前会。
| 需要你已掌握 | 在哪里补 | 是否必须提前会 |
|---|---|---|
| 第 02 章:Gin + GORM 的 Web/Service/Repository 分层 | 第 4 节动手时回顾 | 建议会,否则看不懂分层 |
| 第 07 章:Kafka 消息从生产者到消费者 | 灰度节提到"配置中心动态下发"时回顾 | 不强制 |
| 基本的 Go 接口、泛型、依赖注入(wire) | 第 4 节用到 UserModule 接口时回顾 | 建议会 |
| RPC 是什么(全新) | 3.4 节补课胶囊 | 就地补 |
| RESTful / HTTP / RPC / gRPC 关系(全新) | 3.5 节补课胶囊 | 就地补 |
| Protobuf 是什么(全新) | 3.6 节补课胶囊 | 就地补 |
| gRPC 是什么(全新) | 3.7 节 + 3.8 节实战 | 就地补 |
| DDD 核心概念(全新) | 3.12 节补课胶囊 | 就地补 |
| 模块化单体是什么(全新) | 3.13 节补课胶囊 | 就地补 |
| 灰度上线是什么(全新) | 3.14 节 + 第四节 | 就地补 |
| HTTP/2 多路复用(全新) | 3.16 节 | 就地补 |
| Keepalive 保活(全新) | 3.17 节 | 就地补 |
| gRPC 错误码 / status(全新) | 3.18 节 | 就地补 |
| Metadata 与请求级凭证(全新) | 3.19 / 3.20 节 | 就地补 |
| TLS / mTLS(全新) | 3.20 节 | 就地补 |
| bufconn(全新) | 3.21 节 + 第 7 步 | 就地补 |
第 08 章:TraceID 与 context.Context | 3.19 节回顾 | 建议会,便于理解透传 |
预计时长与学习成果
预计时长:7 小时(精读 + 跟着敲全部代码,含生产级进阶 7 节);如果只求主干,2.5 小时可过一遍概念。
学完你能跑出来的东西(这是本章的验收标准,做到才算过关):
一个改造后的
webook:内部按领域拆成user/post等模块(模块化单体),新增api/proto/user.proto并用 protoc 编译出 Go 代码;user 模块同时提供 HTTP(:8080)和 gRPC(:9090)两种门面;post 模块通过一个UserModule接口取作者信息,开关一切就在"本地调用"和"gRPC 远程调用"之间无缝切换,并能演练"远程挂了 → 切回本地"的回滚。再补一条生产级验收:用 bufconn 给 user gRPC 服务端补一条无网络单测,并按配置给服务端/客户端切上 Keepalive 参数与 TLS 开关。
flowchart LR
A["3.1 什么是微服务"] --> B["3.2 分而治之"]
B --> C["3.3 模块化vs微服务化"]
C --> D["3.4 RPC 补课胶囊"]
D --> E["3.5 HTTP/REST/gRPC 关系"]
E --> F["3.6 Protobuf 补课胶囊"]
F --> G["3.7 gRPC 补课胶囊"]
G --> H["3.8 gRPC 实战:proto→编译→服务端/客户端"]
H --> I["3.9 Protobuf 语法详解"]
I --> J["3.10 protoc 参数与 buf"]
J --> K["3.11 gRPC 连接建立"]
K --> L["3.12 DDD 补课胶囊"]
L --> M["3.13 模块化单体胶囊"]
M --> N["3.14 拆分粒度与灰度"]
N --> N2["3.15 拆分路线图"]
N2 --> Q["3.16-3.22 gRPC 生产级进阶"]
Q --> O["第四节 跟着写 8 步"]
O --> P["第七节 面试问答"]这张图怎么读:按 A 到 P 的箭头顺序就是本章阅读主线——AC 先建立"为什么拆"的动机与概念,DK 是 gRPC 基础链路的完整链条(RPC/Protobuf/gRPC 概念 → proto 编译 → 连接建立),LN2 用 DDD 与"模块化单体"回答"边界划在哪、按什么路线拆",Q 一站收拢 3.16-3.22 的生产级进阶,最后 O(跟着写 8 步)与 P(面试问答)负责动手与验收。本章概念密度全书最高,建议第一次按顺序走完 AN2 再进 Q,不要跳读。
三、正文
3.1 什么是微服务架构
先抛问题
你现在的 webook 是一个单体:所有代码在一个进程里,整个一起编译、一起部署、一起停。随着功能越来越多,它会遇到三个现实麻烦:
- 改一行点赞逻辑,得把整个几十万行的应用重新编译、重新发版;
- 团队里小王负责用户、小李负责榜单,两人改同一个仓库,今天他的提交把我的分支覆盖了;
- 大促时只有"发帖"流量暴涨,但你不得不把整个应用都扩 10 倍机器,因为没法只扩"发帖"这一个部分。
不解决这些,团队越大、功能越多,开发速度越慢,直到谁都不敢动它。
给个直觉:把大厨房拆成小店
微服务是一种架构风格(注意:架构有很多种,微服务只是其中一种,不是银弹),它把一个大型单体应用拆成数个、数十个独立的服务,每个服务:
- 可独立开发、测试、部署、扩容;
- 自己拥有数据存储(理论上不应共享数据库);
- 通过轻量通信机制(HTTP / RPC / MQ)交互;
- 松耦合、自治、去中心化。
类比:单体应用像一个超大的厨房,洗菜、切菜、炒菜、装盘全在一个屋子里,厨师之间随时能喊话、随手拿对方的工具。微服务像是把厨房拆成「洗菜间」「切菜间」「炒菜间」几个独立小店,各干各的、通过传菜窗口(API)交接。好处是每个小店可以单独扩人手、单独关门维修;坏处是原来喊一嗓子就能办的事,现在要走流程窗口(网络调用),多了一点延迟和复杂度。
最小示例(概念图)
flowchart LR
subgraph 单体[单体: 一个进程]
A[用户] --- B[发帖]
B --- C[互动]
C --- D[榜单]
end
subgraph 微服务[微服务: 多个进程]
S1[用户服务]
S2[发帖服务]
S3[互动服务]
S4[榜单服务]
S1 -. API .- S2
S2 -. API .- S3
S3 -. API .- S4
end小收口
微服务本质是"分而治之"在工程上的体现:把"一个大而不可控的东西"拆成"几个小而能独立掌控的东西"。但拆完之后,原来的函数调用变成了网络调用——这就需要新的通信工具,也就是下一节要补的 RPC 和 gRPC。
3.2 为什么要使用微服务——分而治之
先抛问题
“分而治之"四个字谁都会说,但它在数学上到底为什么能降低复杂度?如果拆完反而更复杂了怎么办?
给个直觉:复杂度会"长利息”
直观解释:应用太复杂了,要想办法降低复杂度,降低到一个人能理解的地步。
假设两个系统的复杂度是 Sa 和 Sb,合并成一个系统后,总复杂度 S 大于 Sa + Sb——多出来的那部分,来自模块之间互相调用、互相依赖产生的交互。
flowchart LR
subgraph 单体[单体: 复杂度 S 很高]
M1[模块A] --- M2[模块B]
M2 --- M3[模块C]
M1 --- M3
end
subgraph 微服务[微服务: 各自可理解]
S1[服务A] -. API .- S2[服务B]
S2 -. API .- S3[服务C]
end左边单体里 A、B、C 两两直接依赖(三条实线),交互面很大;右边微服务之间只通过 API 这一个点耦合(虚线),内部细节互相看不见。
拆分带来三方面好处:
- 总体复杂度降低:模块间交互被压缩成"只通过 API";
- 单个模块复杂度变得可理解:每个服务小到一个人能看懂;
- 模块间仅通过 API 耦合:无需了解其他模块的内部细节,改一个服务的内部实现不影响别人。
把复杂度量化一下,你就更信了。假设三个模块各自复杂度都是 10,互相之间有交互,合并后的单体复杂度不是 10+10+10=30,而是 30 加上两两交互的"利息"——比如 A-B、B-C、A-C 三条交互线各贡献 5,单体总复杂度就是 45。拆成微服务后,每条交互线被压缩成一个 API 点(每个只贡献 2),总复杂度降到 36。关键不是"减了多少",而是每个服务内部从 45 降到 10+2=12,一个人终于看得懂了。这就是为什么"分而治之"的落脚点是"一个人能理解",而不是"总数变小"。
小收口
“分而治之"不是口号,而是有数学依据的——合并会让复杂度长出"交互利息”。但注意:拆得太碎,服务数量爆炸,运维和通信开销又会涨回来(这是 3.14 节"拆分粒度"要讨论的)。所以拆要有度。
3.3 模块化 vs 微服务化(高频面试题)
先抛问题
模块化(把代码按功能分成不同 package)本身也是"分而治之",那为什么不能直接停在模块化,非要做微服务化?这是面试最爱追问的一题。
给个直觉
答案是:不行,因为模块化只解决了"代码组织",没解决"部署与独立演进"。看这张对比表就清楚了。
| 维度 | 模块化单体 | 微服务化 |
|---|---|---|
| 部署 | 整个应用一起部署 | 每个服务独立部署 |
| 资源 | 整个应用需要的资源(如 16G 内存) | 单服务所需资源(如 4G) |
| 演进 | 强耦合,难以独立迭代 | 在 API 向后兼容下可独立演进 |
| 组织 | 一个团队维护一个庞大应用 | 一个团队对应一组服务(康威定律) |
逐行讲解(为什么这四点决定"必须微服务化")
- 部署:模块化单体改一行也要全量发版;微服务改一个服务只发它自己,出事影响面小。
- 资源:单体必须按"峰值总资源"配机器;微服务可以"哪个服务忙就只给哪个加机器"。
- 演进:单体里你改了内部函数签名,所有调用方都得跟着改;微服务只要 API 兼容,内部随便重构。
- 组织:康威定律说"系统结构会镜像团队结构"——一个 30 人团队维护一个单体,沟通成本爆炸;拆成微服务后,每个小团队拥有自己的服务,边界即职责。
运维视角的关键差异:实例才是最基本的运维单位。模块化的资源粒度是"整个单体",微服务的资源粒度是"单个服务"。你能"只重启出问题的那个服务",而不是"一动就动整块"——这是模块化单体做不到的。
常见坑
小收口
模块化是微服务的"预备动作",微服务是模块化的"独立部署版"。下一节的 3.13 会专门补"模块化单体"这个关键概念。但进入微服务前,你还得先搞懂一个更底层的问题:服务之间到底怎么"说话"?——这就是 RPC。
什么时候根本不该微服务化(面试加分项):假设你是一个 5 人小团队,做一款日活几千的内部工具,全部代码 2 万行。这时强行拆微服务,你会凭空多出:服务间网络调用(延迟 + 超时 + 重试)、分布式事务、服务注册发现、链路追踪、每个服务的独立部署流水线……这些"微服务税"需要的工程人力,远超它带来的好处。结论:团队小、业务稳、迭代慢时,模块化单体就是最优解。微服务是"复杂度/团队规模逼出来"的解药,不是维生素。
3.4 补课胶囊 · 什么是 RPC
一句话:RPC 是"远程过程调用",让你像调本地函数一样调另一台机器上的函数。
类比:本地调用像打电话——你拿起电话(写一行
obj.Method()),对面(远程服务)接了、处理、回你一句,你这边"同步"等着;而 HTTP 调接口更像发邮件/发短信——你要自己写地址、写格式、等对方查收回复,感觉差很多。最小示例:
// 没有 RPC 时,跨进程调用你得自己拼 HTTP 请求、解析 JSON,写得像"发邮件"
resp, _ := http.Post("http://user-service:8080/get?uid=1", ...)
body, _ := io.ReadAll(resp.Body)
// 再手动把 body 解析成结构体……啰嗦且易错
// 有了 RPC(gRPC)后,写起来像"打电话",和本地调用几乎一样
user, err := client.GetUserByID(ctx, &GetUserByIDRequest{UserId: 1})
回到主线:RPC 帮你把"远程调用"伪装成"本地过程调用"——名字里的 Remote Procedure Call(远程过程调用)就是这个意思。下一节我们把 RPC、HTTP、RESTful、gRPC 四者的关系一次理清。
RPC 到底帮你做了哪两件事?
client.Like(ctx, req),背后是框架把参数打包发给远端、远端执行、再把结果打包送回来。你只管写"像本地一样"的代码,脏活框架干。3.5 补课胶囊 · RESTful / HTTP / RPC / gRPC 的关系
一句话:四个词不是一回事,很多人面试答错就是因为把它们当同义词。
类比:把"微服务"想成一家公司,“通信方式"是它选的联络手段——可以发邮件(HTTP/RESTful),也可以打电话(RPC),而 gRPC 是"一种特定的电话系统(基于 HTTP/2 的电话)"。
最小示例:
微服务(架构) ── 不规定通信方式,三种都能用
├─ HTTP (应用层协议) → 可以走 RESTful 风格
├─ RPC (通信协议思想) → 像调本地函数
│ └─ gRPC(具体 RPC 框架,基于 HTTP/2 + Protobuf)
└─ MQ (消息队列,异步通信)
回到主线:微服务 ≠ 必须 RPC。下面用一张关系图把四者彻底讲清,再给"基于 HTTP vs 基于 RPC 的微服务"对比。
四个概念各是什么、谁包含谁:
- RPC(Remote Procedure Call,远程过程调用):一种通信协议思想,让你像调用本地方法一样调用远端方法。RPC 可以基于 TCP(如 Dubbo)、HTTP(如 gRPC)、UDP、甚至消息队列。
- HTTP:一种应用层协议。可以用 HTTP 实现微服务通信,也可以在 HTTP 之上封装出 RPC 协议。
- RESTful:一种软件架构风格(符合 REST 风格的 Web 应用叫 RESTful)。它和微服务没有直接关系,只是常被当作微服务的通信机制之一。
- 微服务:一种架构,对通信协议没有强制定义。
flowchart TD
MS[微服务 架构] -->|不强制| H[HTTP 协议]
MS -->|不强制| R[RPC 协议]
MS -->|不强制| Q[MQ 消息]
H --> REST[RESTful 风格]
R --> G[gRPC 框架
基于 HTTP/2 + Protobuf]结论:微服务 ≠ 必须 RPC;可以 HTTP 通信,可以 RPC 通信(如 gRPC),也可以 MQ 通信。
那"基于 HTTP"和"基于 RPC"做微服务,怎么选?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 基于 HTTP(RESTful) | 运维简单,组件少,对研发要求低,兼容异构系统 | 性能略低(JSON 文本 + 无强类型) |
| 基于 RPC(如 gRPC) | 性能高,跨语言,强类型,生态完善 | 运维复杂,对研发要求高 |
本课程的选型:Go 生态里"遇事不决选 gRPC"基本不会出错——它性能好、跨语言、生态最完善。下面两节就补 gRPC 和 Protobuf 这两个核心概念。
那我自己的项目该怎么选(决策小抄):
- 对外给前端/第三方用的公开 API、需要浏览器直接调、要被人用 Postman 随便试 → HTTP/RESTful(gRPC 浏览器原生不支持,得加网关转)。
- 内部服务之间高频调用、追求性能和强类型、团队都是 Go/多语言混合 → gRPC。
- 异步、削峰、解耦(如"下单后发短信”)→ MQ,它既不是 HTTP 也不是 gRPC,是第三种思路。
记住一句大白话:对外用 HTTP,对内用 gRPC,异步用 MQ。本课程 focus 在"内部服务拆分",所以主角是 gRPC。
3.6 补课胶囊 · Protobuf 是什么
一句话:Protobuf 是 Google 的"二进制数据格式 + 接口描述语言",用来定义 RPC 的入参出参,比 JSON 更小更快。
类比:JSON 像手写的便签(人能直接读,但占地方、机器解析慢);Protobuf 像打了条形码的快递单(人看不懂,但机器扫码秒读、体积小一半还多)。
最小示例:
// 一个最简单的 .proto 文件:定义"用户"这个数据结构和服务方法
syntax = "proto3";
package user.v1;
// message = 数据结构(相当于 Go 的 struct)
message User {
int64 id = 1; // 数字 1 是"字段编号",序列化时只传编号不传名字
string nickname = 2;
}
// service = 远程能调的方法(相当于 Go 的 interface)
service UserService {
rpc GetUserByID(GetUserByIDRequest) returns (User);
}
message GetUserByIDRequest { int64 user_id = 1; }
回到主线:Protobuf 为什么不用 JSON?它靠"字段编号 + 二进制"做到省带宽,又靠"编号不变即兼容"做到版本兼容。下一节讲 gRPC 怎么用它。
为什么 Protobuf 比 JSON 强?四个优势:
- 高效:二进制格式,序列化/反序列化速度极快,压缩率高(同样的数据体积可能只有 JSON 的 1/3~1/5)。
- 跨平台语言无关:C/C++/Java/Python/Go 都有实现,异构系统首选。
- 扩展性强:可以灵活加字段而不破坏老客户端兼容(靠字段编号,见 3.9 节详解)。
- 工具链完善:有
protoc编译器自动生成各语言代码,不用手写道。
protoc 编译流程(先有个印象,3.8/3.10 节会动手):
flowchart LR
P[.proto 文件] -->|protoc 编译| G[.pb.go
结构体定义]
P -->|protoc + 插件| R[_grpc.pb.go
服务端/客户端代码]
G --> APP[你的业务代码]
R --> APP这张图怎么读:同一份 P 节点(.proto 文件)走两条编译支路——普通 protoc 编译产出 G(.pb.go,纯结构体定义),protoc 配 go-grpc 插件产出 R(_grpc.pb.go,服务端接口与客户端代码);两份产物最后都汇入 APP,被你的业务代码 import 使用。换句话说,一份接口合同被拆成"数据结构"和"调用骨架"两个工件,所以改了 proto 必须重新编译、两份产物一起更新。
为什么能省带宽还能做版本兼容:Protobuf 序列化时不传字段名,只传"字段编号 + 值"。老客户端遇到不认识的新编号,直接跳过(因为编号对应不上它认识的字段),所以服务端加字段、老客户端照样能解析——这就是向后兼容。代价是字段编号一旦发布就不能改、不能复用(3.9 节细讲)。
3.7 补课胶囊 · gRPC 是什么
一句话:gRPC 是 Google 开源的高性能 RPC 框架,“遇事不决选 gRPC"基本不会错。
类比:如果 RPC 是"打电话"这种通信思想,那 gRPC 就是一部具体的、超好用的电话机——它规定用 HTTP/2 这条"电话线”,用 Protobuf 这种"条形码便签"来传话。
最小示例:
// gRPC 让你像打本地电话一样调远程方法,出入参都是 proto 生成的强类型结构
conn, _ := grpc.Dial("localhost:9090", grpc.WithTransportCredentials(insecure.NewCredentials()))
client := userv1.NewUserServiceClient(conn)
// 下面这行,写起来像本地调用,实际走了一次 HTTP/2 + Protobuf 的网络往返
resp, err := client.GetUserByID(ctx, &userv1.GetUserByIDRequest{UserId: 1})
回到主线:gRPC 的核心就是"HTTP/2 做传输 + Protobuf 做数据格式"。它还支持四种调用方式,这是面试常考点。
gRPC 的特点:
- 高性能:基于 HTTP/2(不是 HTTP/1.1),支持多路复用、双向流、流控和头部压缩;
- 跨语言:主流语言都有实现,异构系统首选;
- 开源:社区强大,生态丰富。
gRPC 用 IDL(接口描述语言,Interface Definition Language) 来定义客户端和服务端之间的通信格式,它选的 IDL 落地语言就是 Protobuf(上一节讲的)。
为什么内网微服务用 gRPC,而不是继续 REST + JSON? 对照着看更清楚:
| REST + JSON 的老问题 | gRPC 的解法 |
|---|---|
| 文本序列化慢、字段名冗余(体积大) | Protobuf 二进制 + 只传字段编号,体积常常小一半以上 |
| 没有强制契约,两边字段对不上要运行时才炸 | .proto 作为唯一契约源,编译期做类型检查 |
| HTTP/1.1 一请求一连接,连接开销大 | HTTP/2 单连接多路复用(3.16 节详解) |
| 双向流支持差 | 原生四种调用模式,含双向流 |
| 手写客户端;超时/重试/负载均衡要自己搭 | 代码生成 + 内置超时、取消、拦截器、负载均衡 |
架构师判断:对外(Web 页面、小程序、公众号)继续用 REST/JSON,对内微服务之间用 gRPC——两套并存是常态,不是"谁淘汰谁"。加分项:用 gRPC-Gateway 让同一份
.proto同时暴露 REST 接口(只需知道有这条路,见大纲「了解」清单)。
gRPC 的四种调用方式(面试高频):
| 调用方式 | 说明 | 生活类比 |
|---|---|---|
| 一元 RPC(Unary) | 一问一答,最常用 | 打个电话问"用户在吗",对方答"在" |
| 服务端流式 | 客户端发一次,服务端源源不断回 | 订阅电台,你只调一次台,它一直播 |
| 客户端流式 | 客户端不断发,服务端最后回一次 | 你一直往邮箱塞信,对方月底回一封总结 |
| 双向流式 | 两边都能随时发 | 微信语音通话,你说我听、我说你听 |
本章实战只用到一元 RPC(一问一答),其余三种知道名字和一句话定位即可。但了解它们的"长相"有助于面试被追问时不慌——流式调用的 proto 和一元只差一个 stream 关键字:
// 服务端流式:客户端发一次,服务端用 for 循环不断 Send 回多条
rpc WatchLogs(LogRequest) returns (stream LogEntry);
// 客户端流式:客户端用 Send 不断发,最后 Recv 一次拿汇总
rpc UploadChunks(stream Chunk) returns (UploadSummary);
// 双向流式:两边都能随时 Send,典型如聊天、实时推送
rpc Chat(stream Message) returns (stream Message);
生成的 Go 代码里,流式方法会返回 stream.Send() / stream.Recv() 接口,而不是普通的一进一出。你只要记住:一元 RPC 最常见,流式是"加了 stream 的一元",核心思想(HTTP/2 + Protobuf + 像本地调用)完全一样。
小收口
现在三件套里你已经认识了 Protobuf(数据格式)和 gRPC(框架)。接下来 3.8 节我们把它们跑起来:写 proto → 编译 → 实现服务端和客户端,这是全章最"落地"的一段。
3.8 gRPC 实战:从 proto 定义到客户端调用
下面我们用本项目的 interactive(互动:点赞/收藏/阅读数)模块走一遍完整链路。先讲概念与原理,第 4 节会换成 user 模块做工程落地。
3.8.1 先抛问题:proto 文件到底定义什么?
proto 文件要同时定义两样东西:数据结构(message) 和 能远程调的方法(service)。它相当于一份"接口合同"——服务端和客户端都照它生成的代码来写,两边才不会"对不上号"。
3.8.2 Protobuf 定义示例(最小可运行)
// 文件路径: content-community/api/proto/interactive/v1/interactive.proto
// 声明使用 proto3 语法(除非维护老系统,否则都用 proto3)
syntax = "proto3";
// 生成 Go 代码时的包路径配置(必须包含 . 或 /)
option go_package = "github.com/yourname/content-community/api/proto/interactive/v1;interactivev1";
// 包名(可选,没什么实际作用)
package interactive.v1;
// 服务定义:相当于 Go 中的 interface
service InteractiveService {
// 每个方法:一个请求 message,一个响应 message
rpc Like(LikeRequest) returns (LikeResponse);
rpc CancelLike(CancelLikeRequest) returns (CancelLikeResponse);
rpc Collect(CollectRequest) returns (CollectResponse);
rpc Get(GetRequest) returns (GetResponse);
}
// 点赞请求
message LikeRequest {
string biz = 1; // biz 标识业务类型,如 "article" 或 "comment"
int64 biz_id = 2; // 业务 ID
int64 uid = 3; // 用户 ID
}
message LikeResponse {} // 空响应:用 {} 表示"只关心成功失败"
上面只列了
Like一个方法的完整 message,其余三个方法(CancelLike / Collect / Get)结构同构,原文章节里有完整定义。这里先跑通一条链路,重点看"service + message"的写法骨架。
3.8.3 编译 Protobuf
装好两个插件(一次性),再用 protoc 编译:
# 安装 Go 和 gRPC 代码生成插件(只装一次)
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
# 务必把 $GOPATH/bin 加入 PATH,否则 protoc 找不到插件
# 编译:生成 .pb.go 和 _grpc.pb.go
protoc \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
api/proto/interactive/v1/interactive.proto
预期产物:
interactive.pb.go:纯 Go 结构体定义(由 message 生成);interactive_grpc.pb.go:gRPC 服务端接口 + 客户端代码(由 service 生成)。
打开 interactive.pb.go,你会看到结构体里多了三个"不属于你"的字段:state、sizeCache、unknownFields。它们是 Protobuf 运行时的内部状态(序列化缓存、未知字段暂存),不用管、也不要手改——你只关心自己定义的字段。
生成的客户端方法签名固定四段:①
context.Context、② 业务请求 message、③ 可选grpc.CallOption、④ 返回(业务响应 + error)。和服务端实现一一对应。
3.8.4 实现 gRPC 服务端
// 文件路径: content-community/internal/interactive/grpc/server.go
package grpc
import (
"context"
interactivev1 "github.com/yourname/content-community/api/proto/interactive/v1"
"github.com/yourname/content-community/internal/interactive/service"
)
// GRPCServer 实现 protoc 生成的服务端接口。
// 必须嵌入 UnimplementedInteractiveServiceServer:以后接口新增方法时编译不失败(向前兼容)。
type GRPCServer struct {
interactivev1.UnimplementedInteractiveServiceServer
svc *service.InteractiveService
}
func NewGRPCServer(svc *service.InteractiveService) *GRPCServer {
return &GRPCServer{svc: svc}
}
// Like 实现点赞接口。签名固定:context + 请求 message + error。
func (s *GRPCServer) Like(ctx context.Context, req *interactivev1.LikeRequest) (
*interactivev1.LikeResponse, error) {
// 从请求取参 → 委托领域服务处理 → 返回空响应
err := s.svc.Like(ctx, req.GetBiz(), req.GetBizId(), req.GetUid())
return &interactivev1.LikeResponse{}, err
}
UnimplementedInteractiveServiceServer。protoc 生成的接口里有一组"未实现"的嵌入类型。不嵌入它,将来 proto 里新增一个 RPC 方法,你的 GRPCServer 就满足不了新接口,编译直接失败。嵌入后,新增方法默认走"未实现"占位,编译照过——这就是向前兼容的关键。3.8.5 启动 gRPC 服务端
// 文件路径: content-community/internal/interactive/main.go(节选)
package main
func main() {
// 创建监听端口
lis, err := net.Listen("tcp", ":8091")
if err != nil {
panic(err)
}
// 初始化依赖(wire 生成),拿到 GRPCServer
app := wirex.NewApp()
server := app.GRPCServer
log.Println("interactive gRPC listening on :8091")
// Serve 会阻塞,直到出错或进程退出
if err = server.Serve(lis); err != nil {
panic(err)
}
}
3.8.6 实现 gRPC 客户端(带灰度开关)
// 文件路径: content-community/internal/web/client/interactive.go
package client
import (
"context"
"math/rand"
interactivev1 "github.com/yourname/content-community/api/proto/interactive/v1"
)
type InteractiveClient struct {
remote interactivev1.InteractiveServiceClient // 真实 gRPC 客户端
local interactivev1.InteractiveServiceClient // 本地适配的 gRPC 客户端
threshold int32 // 流量阈值 0~100,走远程的概率
}
// Get 按"随机数 + 阈值"决定走本地还是远程——这是灰度的核心开关
func (c *InteractiveClient) Get(ctx context.Context, biz string, bizId, uid int64) (
*interactivev1.GetResponse, error) {
randNum := rand.Int31n(100)
if randNum < c.threshold {
return c.remote.Get(ctx, &interactivev1.GetRequest{Biz: biz, BizId: bizId, Uid: uid})
}
return c.local.Get(ctx, &interactivev1.GetRequest{Biz: biz, BizId: bizId, Uid: uid})
}
这段代码用了两个设计模式:LocalInteractiveClientAdapter 把本地 service 适配成 gRPC 客户端模样(适配器模式);InteractiveClient 把"远程"和"本地"两个客户端包一层流量控制逻辑(装饰器模式)。
InteractiveServiceClient 这一个接口。只要本地和远程都实现这个接口,切换时上层一行代码都不用改——这就是"面向接口编程"在灰度方案里的真实收益。小收口
到这,一条 gRPC 链路跑通了:proto 定义 → protoc 编译 → 服务端实现 → 客户端调用(带灰度开关)。接下来 3.9 把 proto 语法补全,3.10 讲 protoc 参数和 buf 工具,3.11 讲连接是怎么建起来的。
3.9 Protobuf 语法详解——写 proto 必会的几件事
3.8.1 已覆盖最常用的 syntax / package / service / message。这里补齐新手真正会踩的语法点。
① 字段编号是字段的"身份证"。Protobuf 序列化时不传字段名,只传编号和值——这是它比 JSON 更小更快的关键。所以:
- 同一个 message 里编号不能冲突;
- 一旦对外发布,不要改老字段的编号,也不要复用已删除字段的编号,否则新老版本会把字段悄悄读错(比如把
name改成age的编号,老客户端会把数字当名字)。
删除字段的正确姿势是 reserved 占位——显式声明"这个编号和这个名字我不再用了",防止后来人(包括未来的你)误复用:
message User {
reserved 3, 5; // 3、5 号字段已删除:编号从此封存,禁止再用
reserved "old_nickname"; // 字段名也占位,防止新字段沿用老名字
int64 id = 1;
string nickname = 2;
}
② 基本类型(不用死记,IDE 有提示):
| proto 类型 | Go 类型 | 说明 |
|---|---|---|
| int32 / int64 | int32 / int64 | 变长编码,正数场景省空间 |
| uint32 / uint64 | uint32 / uint64 | 变长编码,非负数 |
| sint32 / sint64 | int32 / int64 | zigzag 编码,负数多时才比 int 族省空间 |
| fixed32 / fixed64 | uint32 / uint64 | 固定 4/8 字节,数值普遍很大时用 |
| float / double | float32 / float64 | 浮点,注意精度 |
| bool / string / bytes | bool / string / []byte | bytes 传二进制,Go 里是切片 |
③ 数组用 repeated(生成 Go 的切片):
message BatchGetRequest {
string biz = 1;
repeated int64 biz_ids = 2; // Go 里生成 []int64
}
④ map 结构:
message Counters {
map<string, int64> counters = 1; // Go 里生成 map[string]int64
}
⑤ oneof——多个字段只有一个有值(Go 里没有对应语法,生成代码后要用类型断言,用起来别扭,慎用):
message Filter {
oneof by {
string uid = 1; // 设了 uid 就不能再设 cid
int64 cid = 2;
}
}
⑥ 枚举类型(0 值惯例定义为 Unknown、不赋业务含义):
enum Status {
Unknown = 0; // 0 不要用于业务,占位
Active = 1;
Inactive = 2;
}
optional 关键字额外表达"是否被显式设置过",语义微妙。新手先掌握 repeated / map / enum,oneof 和 optional 等真遇到再查。3.10 protoc 参数详解与 buf 进阶
把 3.8.3 的编译命令逐个参数拆开:
| 参数 | 作用 |
|---|---|
--proto_path(简写 -I) | 指定 .proto 搜索路径,. 表示当前目录 |
--go_out | 生成的 Go 结构体代码(.pb.go)输出目录 |
--go_opt=paths=source_relative | 生成路径按 .proto 文件位置相对算(不加会按 go_package 拼路径) |
--go-grpc_out | gRPC 代码(_grpc.pb.go)输出目录 |
--go-grpc_opt=paths=source_relative | 同上,作用于 gRPC 产物 |
--plugin | 指定代码生成插件——protoc 调插件本质是执行 PATH 里的命令,这就是必须把 $GOPATH/bin 加入 PATH 的原因 |
- 在 PowerShell 里是特殊字符,参数可能被"吃掉",需要用反引号转义。如果嫌 protoc 参数难记、插件难管、目录定位烦,可以用 buf
(3.8.3 的提示框提过)。项目顶级目录放一个 buf.gen.yaml:
# buf.gen.yaml:buf 的代码生成配置(字段细节以官方文档为准)
version: v1
managed:
enabled: true
# 统一配置 go_package 前缀,每个 proto 里就省去老长一段
go_package_prefix:
default: github.com/yourname/content-community/api/proto
plugins:
- plugin: go # Go 结构体代码插件
out: .
opt: paths=source_relative
- plugin: go-grpc # gRPC 服务端/客户端代码插件
out: .
opt: paths=source_relative
之后在顶级目录执行 buf generate api/proto(可封装成 make grpc 一条命令),buf 自动找所有 proto 并按配置生成。
3.11 gRPC 客户端是怎么建立连接的
3.8.6 直接从"拿到了客户端"讲起,这里补上"连接是怎么来的":
// 步骤 1:建立到服务端的连接(返回的是连接抽象,不是单个 socket)
conn, err := grpc.Dial("localhost:8091",
// 测试环境跳过 TLS;生产环境要配证书
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
panic(err)
}
defer conn.Close()
// 步骤 2:用连接初始化客户端
client := interactivev1.NewInteractiveServiceClient(conn)
// 步骤 3:发起调用(此时才可能真正建网络连接——懒连接)
resp, err := client.Get(ctx, &interactivev1.GetRequest{ /* ... */ })
两个值得记住的细节:
ClientConn是"池上池":底层维护一组到服务端的 HTTP/2 连接(连接池),上层还有面向多个服务端实例的连接管理。所以它不是"一个连接",初始化一次、全程复用即可,千万不要每次请求都 Dial。grpc.Dial是懒连接:调用它时通常不会立刻建网络连接,第一次真正发 RPC 时才建连。所以"Dial 成功"不代表"服务端活着"——判断服务端是否可用,要靠真正的调用或健康检查。
grpc.NewClient 替代已标记废弃的 grpc.Dial,语义更直白(不再暗示"拨号即建连")。看到老代码里的 Dial 不用急着改,行为兼容。埋个伏笔:一个
ClientConn凭什么敢"一个连接扛住所有请求"?它靠的正是 3.16 节要讲的主角——HTTP/2 多路复用。
3.12 补课胶囊 · DDD 核心概念
一句话:DDD(领域驱动设计)是微服务拆分的"理论标准"——它告诉你"边界该划在哪"。
类比:把公司按"业务"分成"销售部 / 售后部 / 财务部",每个部门管自己的事、用自己的语言。同一个"客户"在销售部眼里是"要签单的人",在售后部眼里是"要维修的人"——这就是"限界上下文"。
最小示例:
限界上下文(销售部) ≈ 一个微服务
└─ 聚合体(一次销售闭环) = 聚合根(订单) + 值对象(金额, 地址)
└─ 只能通过 订单ID 引用别的聚合根
└─ 仓储 Repository 负责把它存进数据库
回到主线:DDD 概念很多,但和"怎么拆服务"直接相关的只有五个。下面用一张表讲清,不展开成 DDD 专著。
DDD 核心概念(只讲和拆分相关的):
| 概念 | 说明 | 和拆分的关系 |
|---|---|---|
| 限界上下文 Bounded Context | 描述问题的上下文边界 | 对应一个微服务边界。同一"产品"在销售和售后眼里特征不同 |
| 实体 Entity | 有唯一标识符(ID),属性可变 | 如用户、订单。数据库表不一定是实体 |
| 值对象 Value Object | 没有唯一标识,由属性定义 | 如价格、金额。换领域可能变实体 |
| 聚合体 Aggregate | 实体 + N 个值对象的集合,实体是聚合根 | 聚合根是修改聚合体的唯一入口;聚合体间只通过 ID 引用 |
| 领域事件 Domain Event | 系统中发生的事,如订单状态变更 | 可发到 MQ,被多个领域关心 |
几个新手容易忽略的细节(原文重点保留):
- 实体的唯一性只看 ID,不看"恰好唯一的属性":用户邮箱可能唯一,但邮箱能改,所以只有用户 ID 能唯一代表用户。订单号、手机号这些"业务上唯一"的字段,都不能当身份标识。另外,实体在库里必有一张表,但表不都是实体——“用户点赞记录表"更像关系数据,不是有独立生命周期的业务概念。
举个聚合根的例子帮你落地:在"订单"限界上下文里,Order(订单)是聚合根,OrderItem(订单项,如"2 件 T 恤”)是它的实体,Money(金额,由"数值 + 币种"两个属性定义、没有独立 ID)是值对象。外部想改订单状态(如"已支付"),只能调 Order 的方法,不能直接去改 OrderItem——Order 是这整个聚合体对外的唯一入口。而另一个聚合体 User 想和 Order 关联,只能存 orderId,不能把 Order 整个嵌进来。这就是"聚合根唯一入口 + 只通过 ID 引用"在代码里的具体样子。DDD 不用背定义,记住"边界划清、入口唯一、跨域只存 ID"这三句就够应付绝大多数拆分场景了。
- 聚合体不会被持久化:聚合体一般是查出聚合根后,在内存里重新计算各种值对象构建出来的。库里存的是聚合根和明细数据,“聚合体"这个整体只活在内存里。
- 领域事件的发布者和监听者可以是任何人:既可以是 Service,也可以是聚合体本身;外面再套 MQ 生产者/消费者。最直观认知:往 MQ 里发的消息就是领域事件。
- 领域事件从属于一个领域,但可被多个领域关心:如"订单已支付"属于订单领域,但库存、积分领域都要消费它。
flowchart TD
BC[限界上下文 = 一个微服务] --> AG[聚合体]
AG --> AR[聚合根 Entity]
AG --> VO1[值对象]
AG --> VO2[值对象]
AR -. 只通过 ID 引用 .-> AR2[另一个聚合根]
AR --> REP[(Repository 持久化)]小收口
DDD 给你的核心武器是"限界上下文 = 微服务边界”。但具体拆到多细?下一节讲"拆分粒度",并补"模块化单体"这个预备动作概念。
3.13 补课胶囊 · 什么是模块化单体
一句话:模块化单体是微服务的"预备动作"——代码已按领域拆成不同 package,但仍是单进程、一起部署。
类比:还是那家大厨房,但你已经把"洗菜区/切菜区/炒菜区"用隔断分好了、各有各的负责人;只不过厨房还是一个门、一次开门营业——客人点单你还是得整间店一起开门。
最小示例:
webook/ ← 仍是单个进程、单个仓库、一起部署
├── internal/
│ ├── user/ ← 用户模块(自己一棵子树)
│ ├── post/ ← 帖子模块
│ ├── interactive/ ← 互动模块
│ └── common/ ← 跨模块共享的值对象
└── main.go ← 一个 main,全部一起编译部署
回到主线:模块化单体把"代码边界"划清楚了,是拆微服务的前提;下一节讲"拆到多细",再进入第 4 节动手把 webook 改成模块化单体。
模块化和微服务化最大的区别(回看 3.3 表):模块化单体已经按领域拆包,但"部署单位"还是整个应用。它解决的问题是"代码不再搅在一起、互相 import 乱飞",还没解决"独立部署、独立扩容"。很多团队长期停留在模块化单体就很健康——只有当"部署/组织"矛盾压过来,才值得进一步微服务化。
3.14 拆分粒度与灰度上线的核心思想
先抛问题
“一个限界上下文一个微服务"听着干净,但真落到工程上,拆太粗和拆太细都有坑。而且——就算拆对了,怎么保证上线时不出事故?这就是"灰度"要解决的。
给个直觉:图书馆的书架
按 DDD 理论,最简单说法是"一个领域(限界上下文)就是一个微服务”。但还有两个更现实的标准:
- 团队组织:一个团队的能力上限决定服务边界。“两个披萨团队”(一顿饭两个披萨能喂饱,约 9 人)——这么多人能维护住多少业务,边界就划在哪。
- 理论标准:微服务应拆到一个人能完全掌握其中全部细节的程度(既然目标是分而治之)。
类比:书架像图书馆。排太长,管理员记不住每本书位置(服务太大,超出一人理解力);排太短,同样多的书要占更多书架和过道(服务太碎,运维和通信开销暴涨)。现实落点常是:三五个人,维护两三个微服务——比"一人一服务"宽松,但远小于"几十人维护一个巨石"。
灰度上线的核心思想(高频面试题)
什么是灰度上线:新服务上线不"一刀切",而是先放一小撮流量试跑,没问题再逐步放大,出问题立即退回老路径。
为什么要"本地调用 + 微服务调用"并行:最大风险点在"模块化"和"服务化"这两步。一旦远程调用出问题,如果没有退路,用户直接受影响。所以引入中间状态——本地和远程同时能跑,用阈值控制流量,出问题把阈值调回 0 切回本地:
flowchart TD
Q[一次请求] --> T{randNum
小于阈值?}
T -- 是 走远程 --> R[调用 interactive 微服务]
T -- 否 走本地 --> L[调用本地 Service]
R --> OK[返回]
L --> OK
R -- 出错且阈值>0 --> RB[调低阈值回滚到本地]流量调度算法:
- 最简方案:阈值 + 随机数(
randNum := rand.Int31n(100); if randNum < threshold { 走远程 })。 - 进阶方案:阈值 + 业务 ID 哈希(同一
bizId永远走同一条路径,便于排查——否则纯随机会让同一数据这次远程下次本地,出问题难复现)。
完整流程:阈值 = 0(全走老路径)→ 调大 5% 试水 → 没问题再 10% → 30% → 50% → 100% → 有问题立即调回 0。借助配置中心动态改阈值,实时同步所有节点。
配置中心长什么样(概念示意):配置中心是一个独立服务,所有节点启动时向它订阅自己关心的 key。你改了阈值,它立刻把新值推给每个订阅者,不用发版、不用重启:
// 概念示意:监听配置变更,实时调整阈值(生产用配置中心,如 Nacos/Apollo/etcd)
configClient.Subscribe("interactive.threshold", func(val string) {
threshold, _ := strconv.Atoi(val)
client.UpdateThreshold(int32(threshold)) // 立即生效,所有节点同步
})
这就是为什么"配置中心是灰度方案的基础"(见第五节工程要点第 6 条):没有它,改阈值得改配置文件 + 重新发版,等发版完事故早扩大了。配置中心推下来是毫秒级,事故窗口从"分钟"缩到"秒"。
rand.Int31n(100) 是纯随机,同一 bizId 这次远程下次本地,排查问题要同时看两处。生产进阶做法是"阈值 + 业务 ID 哈希":同 bizId 永远同路径,出问题能稳定复现。小收口
概念部分到此结束。你已经掌握了:微服务动机、RPC/HTTP/gRPC/Protobuf 关系、gRPC 实战、DDD 限界上下文、模块化单体、拆分粒度、灰度思想。下一节(3.15)我们补齐"具体怎么拆"的路线图,然后第四节动手——把 webook 改造成模块化单体,并跑通第一条 gRPC 调用链路。
3.15 单体拆分路线图:四阶段 + 选型 + 迁移步骤
上面讲了"为什么拆"和"边界划在哪",这一节把"具体怎么落地"一次讲全。原文章节的拆分路线图内容很多,这里合理归并成 7 个小题,知识点一个不丢。
3.15.1 四阶段详细(高频面试题的直接出处)
| 阶段 | 关键动作 |
|---|---|
| 混沌一片 | 完善单元测试;抽取公共 utils/helper;引入聚合层解除模块间循环依赖;按业务对象划分模块包 |
| 模块化 | 创建不同代码仓库;准备微服务环境与框架选型;搭建 CI 和集成测试环境 |
| 模块依赖化 | 业务模块逐个服务化;搭建自动部署和回滚平台;调研服务治理、网关、MQ、分布式事务、分布式任务调度、可观测性 |
| 服务化 | 引入服务治理;引入网关;引入回归测试;按业务分库 |
flowchart LR
S1[混沌一片] --> S2[模块化]
S2 --> S3[模块依赖化]
S3 --> S4[服务化]3.15.2 拆分路线选型(两条路线)
- 路线一:直接拆分某个模块为独立微服务。可提前验证全流程,但开弓没有回头箭(拆出去再合回来成本高)。
- 路线二:全部模块按"模块化 → 模块依赖化 → 微服务化"分阶段推进。有后悔药(每步都可回退),但无法提前验证全流程。
本课程采用路线一,选择**点赞/收藏(interactive)**模块作为第一个拆分对象——它最独立、业务影响小,符合下一节的"先易后难"。
3.15.3 先易后难原则
- 优先选业务影响小的:崩了也没大影响;
- 其次选最独立的:依赖少、被依赖少;
- 最后考虑 QPS 低的;
- 绝对不要选:用户、帖子、短信、热榜等核心服务(一崩全站挂)。
重构心态:要考虑"出 BUG 怎么办",而不是"怎么不出 BUG"。重构必然引入 BUG,再小心都会遇到。
3.15.4 详细迁移步骤(8 步)+ 补充测试展开
- 选定模块 A;
- 补充测试,覆盖率 ≥ 80%(业务覆盖比代码覆盖更重要);
- 代码拆分,命名为
webook-A; - 确定微服务框架技术选型;
- 将 A 改造为微服务,同时对外提供服务;
- 在原 webook 中同时使用本地调用和微服务调用,开关控制流量,允许回滚(这就是第四节的开关);
- 逐步调整流量,直到 100% 走微服务;
- 移除原 webook 中的本地调用,纯依赖微服务。
第 2 步"补充测试"是整个迁移的安全带,值得展开:
- 集成测试覆盖率比单元测试覆盖率更重要:集成测试真实连数据库、Redis,更接近线上;单测再绿也挡不住 DAO 改漏的 SQL 错误。
- 两边代码都要覆盖:interactive 模块本身覆盖率 ≥ 80%;调用了 interactive 的代码(如 article 逻辑)也要 ≥ 80%。
- 手动检查未覆盖分支:覆盖率工具只报"哪些行没跑",你要逐个确认未覆盖分支是否可接受——核心业务分支必须补测试。
- 梳理业务场景清单,确保关键场景没漏。以 interactive 为例:
| 业务 | 需要覆盖的场景 |
|---|---|
| 阅读数 | 是否有缓存;并发更新 |
| 点赞 | 点赞与取消多次;缓存命中/未命中;并发更新 |
| 收藏 | 收藏与取消;缓存;并发更新 |
| 查询 | 缓存命中与未命中 |
3.15.5 模块化执行:重构点梳理 + 步骤要点
以 interactive 模块为例,重构前先列出所有受影响文件,否则中途才发现遗漏点很难补救:
- Web 层:article.go
- Service 层:article.go、interactive.go
- Domain 层:interactive.go
- Repository 层:interactive.go
- Cache 层:interactive.go
- DAO 层:interactive.go、init.go
- wire.go、ioc:db.go、events:article.go
模块化步骤要点:
- 把模块内代码挪到独立目录(service/domain/repository 等);
- 解决数据库初始化(dao/init.go);
- 挪动 Kafka 消费者(消息事件定义也要复制一份,不能再共享,否则模块化不彻底);
- 重新生成 wire 文件(main 启动 + 集成测试两处都要改);
- 运行集成测试验证。
目标:模块不再依赖 content-community/internal 里的任何代码。
3.15.6 API 仓库管理方案
proto 文件放哪?三种方案:
| 方案 | 适用场景 |
|---|---|
| 统一 API 仓库 | 小规模(≤30 人团队),所有微服务的 proto 放一处 |
| 各服务内部维护 API 目录 | 中等规模 |
| 每个服务有实现仓库 + API 仓库 | 巨型应用,团队组织合理 |
本课程采用方案一,目录结构:
content-community/
├── api/
│ └── proto/
│ └── interactive/
│ └── v1/
│ ├── interactive.proto
│ └── gen/ # 编译产物
└── internal/
api 目录还可放 Thrift、Swagger 等定义;proto 下按服务拆目录,服务内用版本号(如 v1)作目录。
3.15.7 微服务架构知识图谱(别被吓到)
微服务在实践与面试里知识点非常多、非常杂,远不止"把服务拆开":
- 通信:RPC/HTTP、Protobuf、服务注册与发现(第 12 章)、负载均衡;
- 治理:限流、熔断、降级、超时与重试;
- 数据:分布式事务、分库分表、不停机数据迁移(第 11 章);
- 基础设施:配置中心、消息队列、分布式任务调度、网关;
- 可观测性:logging、tracing、metrics 以及对应告警系统。
小收口
路线图到此完整:四阶段演进 → 选路线一(先拆 interactive)→ 先易后难 → 8 步迁移(核心是补测试 + 双跑灰度)→ 重构点梳理 → API 仓库管理 → 知识图谱。
至此,“怎么拆"讲完了。但我们的 gRPC 还停在"能跑通"的水平:连接怎么扛高并发、断了怎么发现、报错怎么表达、traceId 怎么传、传输怎么加密、测试怎么写——这些生产环境必须回答的问题,都在接下来的 3.16-3.22 生产级 gRPC 进阶里。它和前面一样是六拍节奏,不用有压力;读完再去第四节动手,你会发现自己能顺手把很多"以后再说"的坑提前填掉。
3.16 单 TCP 多路复用与连接池:gRPC 为什么扛得住高并发
先抛问题
3.11 节说 ClientConn 初始化一次、全程复用就行。可你可能会嘀咕:几百个并发请求共用一条 TCP 连接,不会互相排队吗?
HTTP/1.1 时代就是这样:一个请求占用一条连接,想并发只能多开连接;而且同一条连接上上一个响应没回来,后面的请求就得干等(这叫队头阻塞)。微服务之间动不动每秒上千次调用,照这个玩法,连接数会爆炸、延迟也下不来。
给个直觉:从单车道到多车道高速
HTTP/1.1 像单车道土路:一次只能过一辆车,前车慢,后面全堵。
HTTP/2 把路修成了"多车道高速公路”:同一条路面(一条 TCP 连接)上划出多条车道(Stream,流),每辆车(请求)在自己的车道上跑,互不等待。gRPC 基于 HTTP/2,所以一条连接就能同时跑成百上千个 RPC——这就是它扛高并发的底层原因。
flowchart LR
subgraph H1["HTTP/1.1:一请求一连接"]
R1["请求 A 占用连接 1"]
R2["请求 B 占用连接 2"]
R3["请求 C 占用连接 3"]
end
subgraph H2["HTTP/2:单连接多路复用"]
S1["Stream #1:请求 A"]
S3["Stream #3:请求 B"]
S5["Stream #5:请求 C"]
S1 --> TCP["单条 TCP 连接
帧交错传输"]
S3 --> TCP
S5 --> TCP
end原理三件套:Stream、Frame、HPACK
- Stream(流):一次请求-响应占用的逻辑通道,有唯一编号(1、3、5、7……)。多个流可以同时在一条连接上活跃。
- Frame(帧):HTTP/2 把数据切成小帧,每帧带"我属于哪个流"的标记;不同流的帧在同一条连接上交错传输,所以慢请求堵不住快请求。
- HPACK(头部压缩):请求头里反复出现的字段(伪头、
user-agent等)只传一次索引,之后直接复用——又省一笔带宽。
帧交错传输的样子:
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: Stream1 的帧(请求 A 前半)
C->>S: Stream3 的帧(请求 B 全部)
C->>S: Stream1 的帧(请求 A 后半)
S-->>C: Stream3 响应(B 先算完先回)
S-->>C: Stream1 响应(A 后回)最小示例:100 个并发调用,只用一条连接
// 文件路径: internal/post/grpc/user_client.go(节选)
// 只建一次客户端连接,所有并发调用都复用它
conn, _ := grpc.NewClient("127.0.0.1:9090",
grpc.WithTransportCredentials(insecure.NewCredentials()))
defer conn.Close()
client := userv1.NewUserServiceClient(conn)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
// 100 个 goroutine 同时调用,底层复用同一个 ClientConn(同一条 HTTP/2 连接)
_, _ = client.GetUserByID(context.Background(),
&userv1.GetUserByIDRequest{UserId: int64(i)})
}(i)
}
wg.Wait()
这段并发调用可以先放进一个临时测试里跑(正式的"无网络单测"写法在 3.21 节)。
验证:
go run ./cmd/webook # 终端 1:起服务端
# 终端 2:跑并发调用后,观察到 :9090 的连接数
netstat -an | grep ESTABLISHED | grep 9090
# 预期:连接数是 1~2 条,而不是 100 条——请求共享了同一条 HTTP/2 连接
那还要不要"连接池"?
这是新手和面试的高频误区,一句话结论:默认不需要自研连接池。
| 场景 | 结论 | 原因 |
|---|---|---|
| 普通微服务互调 | 一个 ClientConn 就够 | 内置多路复用;对多实例的负载均衡也是这一个 Conn 的事(第 13 章 ) |
| 要限制到某后端的连接总数 | 才考虑池化 | 例如对端对连接数敏感,或要跨多个独立后端分发 |
| 想提高吞吐 | 别靠多开连接 | 单连接已能跑满;瓶颈通常在业务处理而不是连接数 |
小收口
多路复用解释了"一个 ClientConn 为什么够用",也埋下一个新问题:这么多请求共享一条长连接,一旦这条连接被中间设备"无声掐断",客户端还以为自己是好的。下一节的主角 Keepalive,就是为解决它而来。
3.17 Keepalive 保活:让长连接别"假死"
先抛问题
gRPC 是长连接,而长连接最怕两类事:
- 断而不知:NAT、防火墙、L4 负载均衡器为了省资源,会悄悄丢弃"太久没说话"的空闲连接。客户端以为连接还在,请求发出去石沉大海——不报错也不返回,用户干等。
- 僵死占坑:服务端这边,一条"对端早就没了但我不知道"的连接会一直占着资源。
给个直觉:潜水员的救生绳
Keepalive 就是每隔一会儿拉一下的"救生绳"(HTTP/2 的 PING 帧):拉一下、对方回一下(PING ACK),两边都知道"你还活着、线路还通"。对方不回,就判定这条连接出了事,断开重连。中间设备看到连接上一直有流量,也不敢轻易回收它。
sequenceDiagram
participant C as 客户端
participant S as 服务端
C->>S: PING(每隔 Time 拉一次)
S-->>C: PING ACK(Timeout 内回来 = 连接健康)
Note over C,S: 中间设备看到有流量,不会回收这条"空闲"连接
Note over S: 到达 MaxConnectionAge,主动 GOAWAY 让客户端换新连接最小示例:服务端 + 客户端参数
// 文件路径: internal/user/ioc/grpc.go(服务端,完整落地见第 8 步)
s := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 5 * time.Minute, // 空闲超过 5 分钟就回收(并提示客户端换连接)
MaxConnectionAge: 2 * time.Hour, // 单条连接最长活 2 小时,到点优雅 GOAWAY
Time: 10 * time.Second, // 每隔 10 秒发一次 PING
Timeout: 5 * time.Second, // 等 ACK 的超时;超了就判定连接已死
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 5 * time.Second, // 客户端的 PING 间隔比这还小 => 视为骚扰,直接断连
PermitWithoutStream: true, // 没有活跃请求时也允许保活
}),
)
// 文件路径: internal/post/grpc/user_client.go(客户端)
conn, _ := grpc.NewClient(addr,
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second, // 每隔 10 秒探一次
Timeout: 5 * time.Second, // 5 秒收不到 ACK 就当连接已死
PermitWithoutStream: true, // 空闲连接也保活
}),
)
逐行讲解:每个参数管什么
| 参数 | 在哪端 | 作用 | 调小的后果 | 调大的后果 |
|---|---|---|---|---|
Time | 两端 | 多久发一次 PING | 心跳流量大,还可能触发对端 MinTime 断连 | 故障发现变慢 |
Timeout | 两端 | 等 ACK 的超时 | 网络一抖就误判断连 | “假死"连接存活更久 |
MaxConnectionIdle | 服务端 | 空闲多久回收连接 | 频繁催促客户端重连 | 空闲连接占资源 |
MaxConnectionAge | 服务端 | 连接最长存活多久 | 连接反复重建 | 长连接偏斜、负载不均(第 13 章 ) |
MinTime | 服务端 | 允许的客户端 PING 最小间隔 | 容忍度低,正常客户端也可能被断 | 拦不住恶意探测 |
PermitWithoutStream | 两端 | 无活跃请求时是否允许保活 | — | — |
生产常用值:探测 10~30 秒、超时 5 秒;空闲回收与最长存活按业务场景调。容器/K8s 环境尤其要配——Service 和云负载均衡的空闲超时往往只有几分钟。
常见坑:两端参数要对齐
客户端 Time 比服务端 MinTime 还小 → 服务端把客户端当"骚扰者”,直接断开(日志里常见 too_many_pings)。改任意一边的探测参数时,记得看一眼另一边。
⚠️ 别把 Keepalive 和业务心跳混为一谈,这是三层不同的东西:
- Keepalive(连接级):这条 TCP 通道还通吗?—— 本节的主角;
- etcd 租约续期(服务级):这个服务实例还活着吗?(第 12 章 )
- 健康检查探针(K8s liveness/readiness):这个实例能正常干活吗?
三层各管各的,谁也替代不了谁。面试里能把这三层分开答,是明显的加分项。
小收口
连接能活、也能在"假死"时被及时发现。下一个问题回到请求本身:调用失败时,错误怎么才能说清楚?——引出 gRPC 的错误规范。
3.18 gRPC 错误处理与统一响应协议
先抛问题
Gin 里错误靠 HTTP 状态码 + JSON body 表达;到了 gRPC,你 return err 时客户端收到的到底是什么?如果服务端随手 errors.New("用户不存在"),客户端能区分"用户不存在"和"数据库炸了"吗?——不能:普通 error 跨进程后只剩一个 Unknown,等于什么都没说。
另一个麻烦:如果每个 RPC 都自定义返回结构,调用方和前端就要写一堆适配代码。
给个直觉:快递面单上的"异常代码"
gRPC 的错误不是一段字符串,而是"状态码 + 消息“的二元组(google.golang.org/grpc/status):状态码是标准化的"异常代码”(参数错、找不到、没权限、超时、不可用……),消息给人看。客户端先看码、再看消息,才能做出不同反应:NotFound 按业务降级、Unavailable 触发熔断重试、InvalidArgument 是调用方的 bug。
最小示例:服务端返回、客户端分支
// 文件路径: internal/user/grpc/server.go(服务端:用 status 返回规范错误)
func (s *UserServiceServer) GetUserByID(ctx context.Context, req *userv1.GetUserByIDRequest) (
*userv1.GetUserByIDResponse, error) {
if req.GetUserId() <= 0 {
// 参数错误用 InvalidArgument;若写成 return errors.New(...),客户端只会收到 Unknown
return nil, status.Error(codes.InvalidArgument, "user_id 必须为正数")
}
u, err := s.svc.GetUserByID(ctx, req.GetUserId())
if err != nil {
// 业务上的"查无此人"用 NotFound;真正的内部错误才用 Internal(且不要暴露 SQL 细节)
return nil, status.Error(codes.NotFound, "用户不存在")
}
return &userv1.GetUserByIDResponse{User: toProto(u)}, nil
}
// 文件路径: internal/post/grpc/user_client.go(客户端:按状态码分支)
resp, err := c.remote.GetUserByID(ctx, &userv1.GetUserByIDRequest{UserId: id})
if err != nil {
st, _ := status.FromError(err) // 把 error 解析回 status(含 code + message)
switch st.Code() {
case codes.NotFound:
// 作者已注销:不当作系统故障,返回默认作者信息(业务降级)
return common.UserBasicInfo{Id: id, Nickname: "已注销用户"}, nil
case codes.Unavailable, codes.DeadlineExceeded:
// 下游不可用或超时:交给熔断 / 重试 / 降级策略处理(第 14 章)
return common.UserBasicInfo{}, fmt.Errorf("user 服务暂时不可用: %w", err)
default:
// 其它错误:记日志、向上抛
return common.UserBasicInfo{}, fmt.Errorf("gRPC 查询用户 %d: %w", id, err)
}
}
u := resp.GetUser()
return common.UserBasicInfo{Id: u.GetId(), Nickname: u.GetNickname(), Avatar: u.GetAvatar()}, nil
常用状态码速查(不用背全,记住这 7 个就够):
| 状态码 | 什么时候用 | 客户端典型处理 |
|---|---|---|
InvalidArgument | 入参不合法 | 直接报错(这是调用方 bug) |
NotFound | 资源不存在 | 按业务降级 / 返回空 |
Unauthenticated / PermissionDenied | 未登录 / 无权限 | 提示登录或拒绝 |
ResourceExhausted | 被限流打回 | 退避后重试 |
DeadlineExceeded | 超时 | 降级 / 重试 |
Unavailable | 下游不可用、连接失败 | 熔断、重试、降级 |
Internal | 服务端内部错误 | 告警 + 兜底 |
统一响应协议:要不要在外面再包一层?
有的团队喜欢让所有接口都返回同一个外壳(code + message + data),像 HTTP 时代的统一 JSON。gRPC 里也能做,但要把代价想清楚:
| 方案 | 形状 | 优点 | 代价 |
|---|---|---|---|
| 原生风格(本章项目) | 具体 message + status.Error | 类型强、零解包;grpcurl、拦截器、重试策略都认 | 调用方要理解 RPC 语义 |
| 统一包装 | Response{code, message, data} | 所有接口形状一致,前端适配成本低 | data 需要 Any 装箱,类型安全削弱;业务码与 gRPC 状态码"双轨",容易打两套拳 |
真要包,写法是这样的:
// 文件路径: api/proto/common/v1/response.proto
import "google/protobuf/any.proto";
message Response {
int32 code = 1; // 业务码,0 表示成功
string message = 2;
google.protobuf.Any data = 3; // 任意业务数据"装箱",取出时再拆
}
// 文件路径: internal/post/grpc/user_client.go(拆箱 Any)
var user userv1.User
if err := anypb.UnmarshalTo(resp.GetData(), &user, proto.UnmarshalOptions{}); err != nil {
return err
}
status 状态码,少一层拆箱、类型更硬。只有"同一份 proto 要同时暴露给前端/网关"时,才值得上统一 Response——那也正是 gRPC-Gateway 的常见搭配。常见坑
return errors.New(...)→ 客户端收到Unknown,没法分支处理(新手第一大坑);- 所有错误都塞
Internal→ 客户端分不出"参数错"和"系统挂"; - 业务 handler panic → 请求以
Unknown结束,还可能把服务带崩(要挂 recovery 拦截器,见第 14 章 )。
小收口
错误规范解决了"失败怎么说清楚"。下一节解决"请求的上下文——traceId、超时、取消——怎么跨服务传下去"。
3.19 Metadata 与 Context:把 traceId、超时、取消传下去
先抛问题
一次请求跨好几个服务时,会撞上三个现实问题:① 怎么把 traceId 带下去,让各服务日志能串成一条链?② Web 层设了 3 秒超时,下游 gRPC 服务怎么"立刻知道"?③ 用户把页面关了、请求被取消,怎么通知全链路别再白干活?
给个直觉
- Metadata:像随包裹贴的"面单"——不装业务数据,专门装"单号多少、谁寄的"这类信息(可以理解成 HTTP header 的 gRPC 兄弟)。traceId 就住在这里。
- Context:像接力赛的"计时器 + 传话棒"——
deadline(截止时间)和取消信号会自动沿调用链传递:上游一取消,下游的ctx.Done()立刻触发。
(context.Context 的通用知识第 08 章 3.13 节已讲过,这里只讲它在 gRPC 里的用法。)
最小示例:注入 + 读取 + 感知取消
// 文件路径: internal/post/grpc/user_client.go(客户端:注入 traceId + 3 秒超时)
md := metadata.Pairs("x-trace-id", traceID) // traceID 由调用方生成(第 8 步给出简化实现)
ctx := metadata.NewOutgoingContext(context.Background(), md)
ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // 计时器:deadline 会自动传给下游
defer cancel()
resp, err := c.remote.GetUserByID(ctx, &userv1.GetUserByIDRequest{UserId: uid})
// 文件路径: internal/user/grpc/server.go(服务端:读面单 + 感知取消)
func (s *UserServiceServer) GetUserByID(ctx context.Context, req *userv1.GetUserByIDRequest) (
*userv1.GetUserByIDResponse, error) {
if md, ok := metadata.FromIncomingContext(ctx); ok {
// 取出 traceId 打进日志:跨服务的日志就能按同一条请求串起来(第 08 章)
if vals := md.Get("x-trace-id"); len(vals) > 0 {
logger.Info("收到请求", zap.String("trace_id", vals[0]))
}
}
select {
case <-time.After(100 * time.Millisecond): // 模拟真实业务耗时
return &userv1.GetUserByIDResponse{}, nil
case <-ctx.Done(): // 上游取消 / 超时:立刻退出,不浪费 CPU 和数据库资源
return nil, status.Error(codes.DeadlineExceeded, "上游已取消")
}
}
把链路画出来:
sequenceDiagram
participant W as Web 层(3 秒超时)
participant P as post 模块
participant U as user gRPC 服务
W->>P: ctx{deadline=3s, traceId}
P->>U: Metadata{x-trace-id} + ctx{剩余 deadline}
U-->>P: 100ms 后正常返回
Note over U: 若上游取消 / 超时:ctx.Done() 立刻触发,停止计算逐行讲解
| 代码 | 为什么这么写 |
|---|---|
metadata.Pairs(...) / NewOutgoingContext | 自定义 key 建议全小写;大对象别塞进来(它要跟着每个请求走) |
WithTimeout(ctx, 3*time.Second) | deadline 是整条链的"总预算",gRPC 会把它折算成剩余时间传给下游(3 秒减去路上和应用内已耗的时间) |
metadata.FromIncomingContext | 服务端取"面单"的唯一入口;取不到时 ok=false,要判空 |
select { ... case <-ctx.Done(): } | 最容易漏的一行:上游都走了,你还把整个查询跑完,就是纯浪费 |
常见坑
- 服务端不检查
ctx.Done()(或ctx.Err())——上游取消后白干活; - 以为 metadata 是"免费的"——它跟着每个请求传,别指望用它传大 payload;
- 忘了设置超时:一次慢下游就能把上游 goroutine 越堆越多(链路超时详见第 14 章 3.6 节);
- 客户端
context.Background()一路裸奔:没有取消、没有超时,故障时无路可退。
小收口
到这儿,请求的"身份"(traceId)和"时间预算"(deadline)都能贯穿链路了。下一个问题:明文传输的请求,怎么保证别人偷不走、改不了、冒充不了?
3.20 通讯安全:TLS + 凭证 + 拦截器
先抛问题
“内网就是安全的"是个危险假设:同网段上的进程可以抓包,中间人也可以假冒服务端。要做安全,至少要回答三件事:① 传输加密(防窃听、防篡改);② 每个请求带身份(“我是 post,我有权查用户”);③ 鉴权、日志这类横切逻辑,别在业务代码里散落一地。
给个直觉:三层防护
把数据想象成重要文件:运输车换成带防窥罩的密封车(TLS 通道加密);大楼门口有保安统一查证件(拦截器);每份文件再附一张授权函(请求级凭证)。三层各司其职:
flowchart TD
A["① TransportCredentials:TLS 加密通道
防窃听、防中间人"] --> B["② Interceptor:鉴权 / 日志 / 限流
业务代码零侵入"]
B --> C["③ PerRPCCredentials:token / JWT 随请求发送
请求级身份"]最小示例:服务端开 TLS + 客户端带凭证 + 拦截器鉴权
// 文件路径: internal/user/ioc/grpc.go(服务端:开启 TLS)
creds, err := credentials.NewServerTLSFromFile("configs/certs/server.crt", "configs/certs/server.key")
if err != nil {
panic(err) // 证书读不到就让启动失败——绝不允许"悄悄降级成明文"
}
s := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(AuthInterceptor))
// 文件路径: internal/post/grpc/credentials.go(客户端:请求级凭证)
type tokenAuth struct{ token string }
// GetRequestMetadata:每次请求把 token 塞进 metadata,服务端拦截器里就能取到
func (t tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{"authorization": t.token}, nil
}
// RequireTransportSecurity:要求必须走 TLS 才允许发送
func (t tokenAuth) RequireTransportSecurity() bool { return true }
// 文件路径: internal/user/grpc/interceptor.go(服务端:统一鉴权,业务零侵入)
func AuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok || len(md.Get("authorization")) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing token")
}
return handler(ctx, req) // 校验通过才进入业务代码
}
本地演练的证书可以自签:
# 生成自签证书(生产环境用公司 CA 或证书管理系统签发)
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout configs/certs/server.key -out configs/certs/server.crt \
-days 365 -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
# 预期:configs/certs/ 下出现 server.crt、server.key 两个文件
客户端连上它(证书文件从配置里读,方便本地/生产切换):
// 文件路径: internal/post/grpc/user_client.go
creds, err := credentials.NewClientTLSFromFile(v.GetString("grpc.tls.cert_file"), "")
conn, err := grpc.NewClient(v.GetString("user.grpc.addr"),
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(tokenAuth{token: v.GetString("grpc.auth.token")}), // token 从配置 / 环境变量注入
)
三个延伸知识点
- mTLS(双向 TLS):不只客户端校验证书,服务端也要求客户端出示证书——适合安全要求更高的内网;知道有这个选项即可。
- 开发环境为什么可以用
insecure.NewCredentials():本地没证书、进程都在你机器上,“跳过 TLS"是为了少配环境;上线前必须换成 TLS,这是代码评审的检查项。 - 拦截器不只做鉴权:日志、埋点、限流、熔断都能挂上去(第 14 章 3.5 节会系统讲拦截器链)。
常见坑
- 生产环境还用
insecure.NewCredentials()→ 明文裸奔; - 客户端加了凭证却不启用 TLS:
RequireTransportSecurity()返回true时请求会直接失败;图省事改成false,又等于把 token 明文发出去; - 拦截器里往
ctx注入值之后忘了把新 ctx 传给handler→ 后面的拦截器和业务拿不到你注入的值(要往下传,不是存起来); - 鉴权失败却返回
Internal或普通 error → 客户端拿不到Unauthenticated,没法区分"没登录"和"服务挂了”。
小收口
到这里,安全补齐了,功能代码也齐了。最后一件事:怎么测?如果测 gRPC 都要真起端口,CI 会又慢又脆——下一节用 bufconn 把这个问题一次解决。
3.21 无网络单测:bufconn 是怎么测 gRPC 的
先抛问题
要测一个 gRPC 服务端,直觉做法是"起真端口,再连上去”。问题:占端口(CI 上并发跑就冲突)、走真实网络栈(慢)、还依赖防火墙/端口可用等环境条件。测试不该是"看你机器脸色"的事。
给个直觉:在自家客厅排练话剧
bufconn 像在自家客厅排话剧:不用真把舞台搭进剧院(网卡),拿一块内存当"网线",客户端和服务端在同一个进程内完成全部通信。没有端口、没有网络、毫秒级、想并发几个用例就并发几个。
flowchart LR
T["测试用例 TestGetUserByID"] --> L["bufconn.Listen()
内存 listener"]
L --> S["gRPC Server
(真实业务逻辑)"]
T -->|"自定义 dialer:直接 dial 内存 listener"| L最小示例:完整可跑的测试
// 文件路径: internal/user/grpc/server_test.go
func TestGetUserByID(t *testing.T) {
// ① 用内存 listener 起"真"服务端:注册、拦截器、业务逻辑一样不少,只是不走网卡
lis := bufconn.Listen(1024 * 1024)
s := grpc.NewServer()
userv1.RegisterUserServiceServer(s, NewUserServiceServer(newTestService(t)))
go func() { _ = s.Serve(lis) }()
defer s.Stop()
// ② 客户端用自定义 dialer 连内存 listener,而不是 TCP 地址
dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() }
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()), // 进程内测试,无需 TLS
)
if err != nil {
t.Fatalf("连接 bufnet 失败: %v", err)
}
defer conn.Close()
// ③ 之后就像调真实 gRPC 一样调它
client := userv1.NewUserServiceClient(conn)
resp, err := client.GetUserByID(context.Background(), &userv1.GetUserByIDRequest{UserId: 1})
if err != nil {
t.Fatalf("GetUserByID 失败: %v", err)
}
if resp.GetUser().GetNickname() == "" {
t.Fatal("昵称不应为空")
}
}
运行:
go test ./internal/user/grpc/ -run TestGetUserByID -v
# 预期:--- PASS: TestGetUserByID (0.00s)
# 注意:不监听任何端口,不需要先启动 webook,也不连数据库
逐行讲解
| 代码 | 为什么这么写 |
|---|---|
bufconn.Listen(1024 * 1024) | 造一个内存 listener(参数是缓冲区大小);用法和 net.Listen 一样 |
go s.Serve(lis) + defer s.Stop() | 服务端在后台跑;别忘了 Stop,否则测试结束 goroutine 还在 |
dialer := func(...) { return lis.Dial() } | 关键一步:把"拨号"重定向到内存 listener,彻底绕开 TCP |
grpc.NewClient("passthrough:///bufnet", ...) | passthrough 表示"地址原样交给 dialer";bufnet 是随意起的名字 |
grpc.WithTransportCredentials(insecure.NewCredentials()) | 客户端必须显式给传输凭证选项;进程内测试用 insecure 即可 |
常见坑
- 忘了
defer s.Stop()→ goroutine 泄漏,多个用例跑完后进程资源越占越多; - 多个用例共享一个 listener / goroutine → 用例互相干扰;每个测试用例各自
bufconn.Listen最稳; - 客户端忘了给传输凭证选项 →
NewClient直接报no transport security set; - 把 bufconn 测试当成"连库测"的替代品 → 它测的是 RPC 层逻辑;DAO/Repository 的真实 SQL 仍要靠集成测试(第 04 章 )。
小收口
到这里,gRPC 从"能跑"到"可靠、安全、可测"就补齐了。最后一节,我们把这一路的零件拼成一张完整的图。
3.22 一条 gRPC 请求的完整链路(收口)
把 3.7-3.21 串起来,本章项目里"post 调 user"的一次请求是这样跑的:
sequenceDiagram
participant W as Web 层
participant P as post 模块(gRPC 客户端)
participant U as user gRPC 服务端
W->>P: 请求帖子详情
Note over P: ① 契约:UserServiceClient 由 proto 生成
P->>P: ② 语义:注入 traceId + 3 秒 deadline
P->>U: ③ 传输:单 ClientConn 多路复用,帧交错发送
Note over P,U: ④ 安全:TLS 加密通道 + authorization 凭证
U->>U: ⑤ 装配:拦截器鉴权 → 业务 handler
U-->>P: 返回 message 或 status.Error(code)
Note over P,U: ⑥ 质量:bufconn 单测兜底;Keepalive 维持连接按"层"再对照一遍(右边是本章 / 后续章节的位置):
| 层 | 干什么 | 在本项目里对应什么 |
|---|---|---|
| 契约层 | 同一份 .proto 生成两端强类型代码 | 3.6-3.10 节的 user.proto 与生成代码 |
| 装配层 | wire 组装依赖、注册服务、启动 server | 第 4 步的 ioc/grpc.go、第 04 章
的 wire |
| 传输层 | 单 ClientConn 多路复用、连接池取舍 | 3.16 节;多实例负载均衡见第 13 章 |
| 安全层 | TLS 通道 + 拦截器鉴权 + 请求级凭证 | 3.20 节;拦截器链见第 14 章 |
| 语义层 | Metadata 透传 traceId;ctx 传播 deadline / 取消 | 3.19 节;traceId 概念见第 08 章 |
| 质量层 | bufconn 无网络单测、Keepalive 保活 | 3.17 / 3.21 节 |
一句话版本(面试可以直接背):
Protobuf 定契约,wire 管装配,HTTP/2 管传输,TLS + 拦截器管安全,Keepalive 管存活,Metadata/Context 管贯穿,bufconn 管质量。 这七块合起来,就是一套生产可用的 gRPC 微服务。
小收口
理论部分到此全部收口。接下来第四节,我们把它变成手上能跑的项目:8 步,每一步都能编译、能验证。
四、跟着写:分步实现
上一章结束时 webook 还是所有业务代码混在 internal/ 下的单体。本节动手做两件事:先把单体改造成模块化单体,再引入 gRPC 跑通第一条服务间调用链路,全程 8 步(上面 3.8~3.21 已讲 gRPC 原理,本节换到 user 模块走工程落地,proto 细节见 3.8/3.9 节;第 7、8 步再补上无网络单测与生产参数)。
说明(避免误解):本节拿 user 模块落地,只是为了演示「post 调 user」这条最直观的服务间调用链路(用户服务是最典型、最易理解的被调方)。真正做微服务化、第一个拆成独立进程的对象,仍是 3.15 选的 interactive——别把「演示用哪个模块」和「先拆哪个模块」混为一谈。
第 1 步:模块化重构——按领域拆包,定义模块对外接口
先把 internal/ 下代码按业务领域归位,每个模块自成一棵子树,模块根目录放一个 api.go 作为对外唯一入口(★ 为本章新建,其余由旧代码整体移入):
internal/
├── common/types.go # ★ 跨模块共享的值对象(只有字段,没有逻辑)
├── user/ # 用户模块(内部分层见下方 DDD 说明)
├── post/ # 帖子模块(结构同 user)
├── interaction/ # 互动模块:第 07 章代码整体挪入
├── rank/ # 榜单模块:第 09 章代码整体挪入
├── sms/ # 短信模块
└── notification/ # 通知模块(预留空目录)
以 user 模块为例,内部按 DDD 分层:api.go(模块对外接口)、domain/(领域模型 + 仓储接口)、service/(业务逻辑)、repository/(GORM 实现)、web/(HTTP handler)、grpc/(gRPC handler,第 4 步实现);依赖方向 web/grpc → service → domain ← repository。先定义跨模块共享的值对象:
// internal/common/types.go
package common
// UserBasicInfo 是跨模块共享的「用户基础信息」值对象。
// 刻意不放 Email 等敏感字段:对外暴露越少,模块内部改动波及面越小。
type UserBasicInfo struct {
Id int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
user 模块领域模型与仓储接口(沿用第 02 章实现,模块化后挪到这里):
// internal/user/domain/user.go
package domain
import "context"
// User 是 user 模块内部的领域模型,Email 等完整字段绝不外泄到其他模块。
type User struct {
Id int64
Email string
Nickname string
Avatar string
}
// UserRepository 仓储接口:service 只依赖抽象,不依赖 GORM 实现
type UserRepository interface {
GetByID(ctx context.Context, id int64) (User, error)
GetByIDs(ctx context.Context, ids []int64) ([]User, error)
GetByEmail(ctx context.Context, email string) (User, error)
}
本章最重要的一份代码——模块对外接口:
// internal/user/api.go
package user
import (
"context"
"github.com/yourname/content-community/internal/common"
"github.com/yourname/content-community/internal/user/domain"
"github.com/yourname/content-community/internal/user/service"
)
// UserModule 是 user 模块对外暴露的唯一入口。铁律:其他模块只能依赖这个接口,
// 绝不能 import user 包的 service/repository;第 5 步换成 gRPC 实现时 post 一行都不用改。
type UserModule interface {
GetUserByID(ctx context.Context, id int64) (common.UserBasicInfo, error)
}
// UserAPI 是 UserModule 的本地实现:把领域模型「翻译」成共享的 UserBasicInfo
type UserAPI struct {
svc *service.UserService
}
func NewUserAPI(svc *service.UserService) *UserAPI {
return &UserAPI{svc: svc}
}
func (a *UserAPI) GetUserByID(ctx context.Context, id int64) (common.UserBasicInfo, error) {
u, err := a.svc.GetUserByID(ctx, id)
if err != nil {
return common.UserBasicInfo{}, err
}
return toBasicInfo(u), nil
}
// toBasicInfo 领域模型 -> 共享值对象(字段裁剪集中在此)
func toBasicInfo(u domain.User) common.UserBasicInfo {
return common.UserBasicInfo{Id: u.Id, Nickname: u.Nickname, Avatar: u.Avatar}
}
post 模块一侧改为依赖 UserModule 接口(聚合体之间只通过 ID 引用,见 3.12):
// internal/post/service/post.go
package service
import (
"context"
"fmt"
"github.com/yourname/content-community/internal/common"
"github.com/yourname/content-community/internal/post/domain"
"github.com/yourname/content-community/internal/user"
)
// PostService 依赖 user 模块的「接口」而非实现——模块化单体的核心约束
type PostService struct {
repo domain.PostRepository
users user.UserModule
}
func NewPostService(repo domain.PostRepository, users user.UserModule) *PostService {
return &PostService{repo: repo, users: users}
}
// PostDetail 帖子详情 = 帖子本体 + 作者信息(作者来自 user 模块)
type PostDetail struct {
Post domain.Post `json:"post"`
Author common.UserBasicInfo `json:"author"`
}
// Detail 先查自己的帖子,再通过接口向 user 模块要作者信息
func (s *PostService) Detail(ctx context.Context, id int64) (PostDetail, error) {
post, err := s.repo.GetByID(ctx, id)
if err != nil {
return PostDetail{}, fmt.Errorf("查询帖子: %w", err)
}
author, err := s.users.GetUserByID(ctx, post.AuthorId)
if err != nil {
return PostDetail{}, fmt.Errorf("查询作者信息: %w", err)
}
return PostDetail{Post: *post, Author: author}, nil
}
验证
go build ./... && go test ./...
# 预期:编译无输出;原有测试全部 PASS(模块化挪包不该改变任何行为)
internal/user/repository,而 user 某天也要反查帖子,编译器会立刻报循环依赖。此时不要用别名绕过,正确做法是把互相访问的逻辑上移到 main 组装层,或把共享类型下沉到 internal/common。第 2 步:定义 user.proto 与 post.proto
在项目根新建 api/proto/ 目录(API 仓库管理方案见 3.15.6 节)。语法要点(字段编号、repeated 等)上文 3.8/3.9 节已详解,这里直接写:
// api/proto/user/v1/user.proto
syntax = "proto3";
// go_package 决定生成代码落盘位置:api/gen/user/v1,包名 userv1
option go_package = "github.com/yourname/content-community/api/gen/user/v1;userv1";
package user.v1;
// 用户服务:对应 UserModule 的方法,外加按邮箱查询(登录用)
service UserService {
rpc GetUserByID(GetUserByIDRequest) returns (GetUserByIDResponse);
rpc GetUsersByIDs(GetUsersByIDsRequest) returns (GetUsersByIDsResponse);
rpc GetUserByEmail(GetUserByEmailRequest) returns (GetUserByEmailResponse);
}
message User {
int64 id = 1;
string email = 2;
string nickname = 3;
string avatar = 4;
}
message GetUserByIDRequest { int64 user_id = 1; }
message GetUserByIDResponse { User user = 1; }
message GetUsersByIDsRequest { repeated int64 user_ids = 1; } // repeated -> []int64
message GetUsersByIDsResponse { repeated User users = 1; }
message GetUserByEmailRequest { string email = 1; }
message GetUserByEmailResponse { User user = 1; }
// api/proto/post/v1/post.proto
syntax = "proto3";
option go_package = "github.com/yourname/content-community/api/gen/post/v1;postv1";
package post.v1;
// post 服务本章只定义接口、暂不实现服务端(拆它还早)
service PostService {
rpc GetPostByID(GetPostByIDRequest) returns (GetPostByIDResponse);
}
message Post {
int64 id = 1;
string title = 2;
string content = 3;
int64 author_id = 4;
}
message GetPostByIDRequest { int64 post_id = 1; }
message GetPostByIDResponse { Post post = 1; }
第 3 步:编译 proto,生成 Go 代码
protoc 与两个插件的安装命令见 3.8.3 节。在项目根目录执行:
protoc \
--go_out=. --go_opt=module=github.com/yourname/content-community \
--go-grpc_out=. --go-grpc_opt=module=github.com/yourname/content-community \
api/proto/user/v1/user.proto \
api/proto/post/v1/post.proto
验证
ls api/gen/user/v1 api/gen/post/v1
# 预期:两个目录下各有 *.pb.go 与 *_grpc.pb.go(含义见 3.8.3 节)
module=模块路径 模式——生成位置由 go_package 去掉模块前缀决定(落在 api/gen/)。如果照抄 3.8.3 节的 paths=source_relative,代码会生成到 api/proto/ 旁边而不是 api/gen/。另外:api/gen/ 下的文件要提交进 git(没装 protoc 的同事才能编译),但永远不要手改——下次生成会全部覆盖。第 4 步:实现用户服务的 gRPC 服务端
gRPC 与 web 平级,都是「服务的对外表现形式」(见 3.10 节原文章节),实现放在模块内 grpc/ 目录。服务端要做的事:实现接口 → 挂拦截器 → 与 HTTP 并行启动。
// internal/user/grpc/server.go
package grpc
import (
"context"
userv1 "github.com/yourname/content-community/api/gen/user/v1"
"github.com/yourname/content-community/internal/user/domain"
"github.com/yourname/content-community/internal/user/service"
)
// UserServiceServer 实现 protoc 生成的 UserServiceServer 接口。
// 必须嵌入 UnimplementedUserServiceServer(向前兼容,见 3.8.4 节提示框)。
type UserServiceServer struct {
userv1.UnimplementedUserServiceServer
svc *service.UserService
}
func NewUserServiceServer(svc *service.UserService) *UserServiceServer {
return &UserServiceServer{svc: svc}
}
// GetUserByID gRPC 版查单个用户:出入参全部是 proto 生成的 message。
func (s *UserServiceServer) GetUserByID(ctx context.Context, req *userv1.GetUserByIDRequest) (
*userv1.GetUserByIDResponse, error) {
// 取字段用 GetXxx:字段没赋值时安全返回零值,不会 panic
u, err := s.svc.GetUserByID(ctx, req.GetUserId())
if err != nil {
return nil, err
}
return &userv1.GetUserByIDResponse{User: toProto(u)}, nil
}
// toProto 领域模型 -> proto 消息,字段转换集中在一处
func toProto(u domain.User) *userv1.User {
return &userv1.User{Id: u.Id, Email: u.Email, Nickname: u.Nickname, Avatar: u.Avatar}
}
创建 gRPC Server 并挂拦截器(日志 + panic 兜底,规格与 HTTP 中间件对齐):
// internal/user/ioc/grpc.go
package ioc
import (
"context"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"go.uber.org/zap"
userv1 "github.com/yourname/content-community/api/gen/user/v1"
usergrpc "github.com/yourname/content-community/internal/user/grpc"
"github.com/yourname/content-community/pkg/logger"
)
// InitGRPCServer 创建带拦截器的 gRPC Server 并注册用户服务
func InitGRPCServer(l *logger.Logger, svc *usergrpc.UserServiceServer) *grpc.Server {
s := grpc.NewServer(grpc.ChainUnaryInterceptor(
logInterceptor(l), // 访问日志;recovery 拦截器同理补挂
))
userv1.RegisterUserServiceServer(s, svc)
// 注册反射服务:让 grpcurl 等调试工具能直接列出并调用接口
reflection.Register(s)
return s
}
// logInterceptor:每个 gRPC 请求打一条 zap 访问日志(与 HTTP 同规格)
func logInterceptor(l *logger.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (resp any, err error) {
start := time.Now()
resp, err = handler(ctx, req)
l.Info("gRPC 调用", zap.String("method", info.FullMethod),
zap.Duration("cost", time.Since(start)), zap.Error(err))
return resp, err
}
}
改 main:HTTP(:8080)与 gRPC(:9090)在同一进程并行跑:
// cmd/webook/main.go(关键改动;viper/zap/db/redis 等初始化沿用上一章)
func main() {
// ……上一章的初始化:配置、日志、DB、Redis、各模块组装……
httpSrv := server.NewWebServer(postHandler, userHandler)
grpcSrv := userioc.InitGRPCServer(logger, usergrpc.NewUserServiceServer(userSvc))
lis, err := net.Listen("tcp", ":9090")
if err != nil {
panic(err)
}
// 两个 server 并行跑;任何一个出错退出,整个进程一起退
var g errgroup.Group
g.Go(func() error { logger.Info("HTTP listening on :8080"); return httpSrv.Run(":8080") })
g.Go(func() error { logger.Info("user gRPC listening on :9090"); return grpcSrv.Serve(lis) })
if err = g.Wait(); err != nil {
logger.Error("服务退出", zap.Error(err))
}
}
验证
go build ./... && go run ./cmd/webook
# 预期两条启动日志:HTTP listening on :8080 / user gRPC listening on :9090
# 另开终端调 gRPC(brew install grpcurl),预期返回用户 JSON(int64 渲染为字符串):
grpcurl --plaintext -d '{"user_id": 1}' 127.0.0.1:9090 user.v1.UserService/GetUserByID
go httpSrv.Run() + go grpcSrv.Serve() 的写法,main 会立刻退出、两个服务全挂。errgroup 的价值不只是等待——它保证「一个 server 挂了,另一个也跟着退出」,交给 supervisor/systemd 拉起整个进程,避免半死不活的状态。第 5 步:封装 GRPCUserClient,post 模块改走 gRPC
客户端属于「调用方」post 模块。关键设计:gRPC 客户端同样实现 UserModule 接口,这样本地实现与远程实现随时互换——灰度切换的理论基础(上文 3.14 节)在这里落地。
// internal/post/grpc/user_client.go
package grpc
import (
"context"
"fmt"
"time"
"google.golang.org/grpc"
userv1 "github.com/yourname/content-community/api/gen/user/v1"
"github.com/yourname/content-community/internal/common"
"github.com/yourname/content-community/internal/user"
)
// GRPCUserClient 通过 gRPC 远程调用 user 服务,实现 UserModule 接口——
// post 拿到的仍是接口,从本地切到它时调用方零改动
type GRPCUserClient struct {
remote userv1.UserServiceClient
}
// 编译期检查接口实现,漏实现方法在 go build 时就报错
var _ user.UserModule = (*GRPCUserClient)(nil)
func NewGRPCUserClient(conn *grpc.ClientConn) *GRPCUserClient {
return &GRPCUserClient{remote: userv1.NewUserServiceClient(conn)}
}
func (c *GRPCUserClient) GetUserByID(ctx context.Context, id int64) (common.UserBasicInfo, error) {
// 铁律:RPC 必须带超时,否则 user 服务一挂,post 的 HTTP 请求全部跟着挂死
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resp, err := c.remote.GetUserByID(ctx, &userv1.GetUserByIDRequest{UserId: id})
if err != nil {
return common.UserBasicInfo{}, fmt.Errorf("gRPC 查询用户 %d: %w", id, err)
}
u := resp.GetUser()
return common.UserBasicInfo{Id: u.GetId(), Nickname: u.GetNickname(), Avatar: u.GetAvatar()}, nil
}
# configs/config.yaml 新增两段
user:
grpc:
addr: "127.0.0.1:9090" # 本章静态配置;第 12 章引入注册中心后删除
post:
use_grpc_user: true # true=post 走 gRPC 取用户信息;false=走进程内本地调用
main 组装模块依赖(加一个开关即可切换两种实现):
// cmd/webook/main.go(组装部分,其余初始化沿用上一章)
userSvc := userservice.NewUserService(userRepo)
var userModule user.UserModule
if viper.GetBool("post.use_grpc_user") {
// 远程模式:post 通过 gRPC 调 user 服务(本章新链路)
userModule = postgrpc.NewGRPCUserClient(postioc.InitUserConn(viper))
} else {
userModule = user.NewUserAPI(userSvc) // 本地模式:进程内直接调用(回滚目标)
}
postSvc := postservice.NewPostService(postRepo, userModule) // post 拿到的是接口
验证
# 终端 1 启动(use_grpc_user: true),终端 2 请求帖子详情,预期返回:
# {"code":0,"msg":"success","data":{"post":{...},"author":{"id":1,"nickname":"月亮湾船长",...}}}
curl -s localhost:8080/api/post/1
# 终端 1 出现 gRPC 调用日志(证明走的是远程链路):
# {"level":"info","msg":"gRPC 调用","method":"/user.v1.UserService/GetUserByID"}
第 6 步:全链路验证与回滚演练
灰度方案的灵魂是「出问题能退回来」,趁服务在手,演练一遍:
- 把
configs/config.yaml的post.use_grpc_user改为false,重启进程,再 curl 一次——author 字段照常返回,但终端里没有 gRPC 调用日志(回到本地调用); - 改回
true,再把user.grpc.addr故意改错成127.0.0.1:9999,重启并 curl——预期返回{"code":1,...}类错误响应,日志出现gRPC 查询用户 ... connection refused(故障沿链路暴露,这就是要配超时和回滚的原因); - 把 addr 改回
127.0.0.1:9090,重启恢复——完整走完「正常 → 故障 → 回滚 → 恢复」一圈。
本章 user 的 gRPC 服务端和 post 跑在同一个进程里——重点是调用已经走了 gRPC 协议。真正拆成两个进程、两个仓库是第 11、12 章的事;届时把
internal/user整体搬走即可,post 侧只依赖UserModule接口,依然一行不用改。
第 7 步:给 gRPC 服务端补一条无网络单测(bufconn)
目标:用 3.21 节讲的 bufconn,给 user gRPC 服务端写一条不占端口、不依赖网络的单测。这是"拆分后敢改代码"的底气——没有测试的微服务,比单体更危险。
// 文件路径: internal/user/grpc/server_test.go
package grpc
import (
"context"
"net"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
userv1 "github.com/yourname/content-community/api/gen/user/v1"
)
func TestGetUserByID(t *testing.T) {
// ① 内存 listener:注册、拦截器、业务逻辑一个不少,只是不走网卡
lis := bufconn.Listen(1024 * 1024)
s := grpc.NewServer()
userv1.RegisterUserServiceServer(s, NewUserServiceServer(newTestUserService(t)))
go func() { _ = s.Serve(lis) }()
defer s.Stop()
// ② 自定义 dialer:把"拨号"重定向到内存 listener,绕开 TCP 与端口
dialer := func(context.Context, string) (net.Conn, error) { return lis.Dial() }
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()), // 进程内测试无需 TLS
)
if err != nil {
t.Fatalf("连接 bufnet 失败: %v", err)
}
defer conn.Close()
// ③ 之后就像调真实 gRPC 一样调它
client := userv1.NewUserServiceClient(conn)
resp, err := client.GetUserByID(context.Background(), &userv1.GetUserByIDRequest{UserId: 1})
if err != nil {
t.Fatalf("GetUserByID 失败: %v", err)
}
if resp.GetUser().GetNickname() == "" {
t.Fatal("昵称不应为空")
}
}
newTestUserService 是测试用的 service 构造:Repository 依赖换成 mock 或内存实现(第 04 章的表驱动 + gomock 套路在这里同样是主力)。
验证:
go test ./internal/user/grpc/ -run TestGetUserByID -v
# 预期:--- PASS: TestGetUserByID (0.00s)
# 全程不监听任何端口,不需要先启动 webook,也不需要网络
第 8 步:上生产前的 gRPC 收尾三件套(可开关)
目标:把 3.17 / 3.19 / 3.20 的知识装进项目——Keepalive 参数、traceId 透传、TLS 开关。三样全部由配置控制,默认行为与第 7 步结束时完全一致(本地开发零成本)。
先加配置:
# configs/config.yaml 新增 grpc 段(其余保持不变)
grpc:
keepalive:
time: 10s # 探测间隔
timeout: 5s # 等 ACK 超时
tls:
enabled: false # 本地开发关闭;生产置 true
cert_file: configs/certs/server.crt
key_file: configs/certs/server.key
服务端按配置拼装选项(顺手把 traceId 拦截器也挂上):
// 文件路径: internal/user/ioc/grpc.go(节选:在原 InitGRPCServer 基础上增加选项)
func InitGRPCServer(v *viper.Viper, l *logger.Logger, svc *usergrpc.UserServiceServer) *grpc.Server {
opts := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(traceInterceptor(), logInterceptor(l)), // 顺序:先补全上下文,再打日志
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: v.GetDuration("grpc.keepalive.time"),
Timeout: v.GetDuration("grpc.keepalive.timeout"),
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: v.GetDuration("grpc.keepalive.time") / 2, // 客户端探测间隔别小于服务端的一半
PermitWithoutStream: true,
}),
}
if v.GetBool("grpc.tls.enabled") { // 只有开关打开才启用 TLS
creds, err := credentials.NewServerTLSFromFile(
v.GetString("grpc.tls.cert_file"), v.GetString("grpc.tls.key_file"))
if err != nil {
panic(err) // 开了 TLS 却读不到证书 = 配置错误,直接失败,绝不"降级成明文"
}
opts = append(opts, grpc.Creds(creds))
}
s := grpc.NewServer(opts...)
userv1.RegisterUserServiceServer(s, svc)
reflection.Register(s)
return s
}
// cmd/webook/main.go(节选:调用处传入 viper)
grpcSrv := userioc.InitGRPCServer(viper, logger, usergrpc.NewUserServiceServer(userSvc))
// 文件路径: internal/user/ioc/grpc.go(traceId 拦截器)
// traceKey 是 ctx 里存取 traceId 的私有 key(不要用 string 当 key)
type traceKey struct{}
// traceInterceptor:上游带了 x-trace-id 就用上游的;没带就本地生成一个兜底
func traceInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
traceID := "trace-" + strconv.FormatInt(time.Now().UnixNano(), 36)
if md, ok := metadata.FromIncomingContext(ctx); ok {
if vals := md.Get("x-trace-id"); len(vals) > 0 {
traceID = vals[0] // 同一条请求全链路共用一个 ID
}
}
ctx = context.WithValue(ctx, traceKey{}, traceID)
return handler(ctx, req) // 注意:带着新 ctx 往下走
}
}
客户端侧:调用前把 traceId 附加进 metadata,并按同一份配置决定是否走 TLS:
// 文件路径: internal/post/ioc/grpc.go(节选:在原 InitUserConn 基础上按配置决定是否启用 TLS)
func InitUserConn(v *viper.Viper) *grpc.ClientConn {
var opts []grpc.DialOption
if v.GetBool("grpc.tls.enabled") {
creds, err := credentials.NewClientTLSFromFile(v.GetString("grpc.tls.cert_file"), "")
if err != nil {
panic(err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
conn, err := grpc.NewClient(v.GetString("user.grpc.addr"), opts...)
if err != nil {
panic(err)
}
return conn
}
// 文件路径: internal/post/grpc/user_client.go(节选:每次调用附加 traceId)
func (c *GRPCUserClient) GetUserByID(ctx context.Context, id int64) (common.UserBasicInfo, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// traceId 附加进 metadata:服务端拦截器会取出来写进日志,同一条请求全链路一个 ID
traceID := "trace-" + strconv.FormatInt(time.Now().UnixNano(), 36) // 生产里用第 08 章的链路追踪 ID
ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceID)
resp, err := c.remote.GetUserByID(ctx, &userv1.GetUserByIDRequest{UserId: id})
if err != nil {
return common.UserBasicInfo{}, fmt.Errorf("gRPC 查询用户 %d: %w", id, err)
}
u := resp.GetUser()
return common.UserBasicInfo{Id: u.GetId(), Nickname: u.GetNickname(), Avatar: u.GetAvatar()}, nil
}
验证:
go build ./... && go run ./cmd/webook
# 1) 默认配置(tls.enabled=false):行为与之前一致——curl /api/post/1 正常返回
curl -s http://127.0.0.1:8080/api/post/1
# 预期:{"code":0,...,"author":{...}};服务端日志里出现 trace_id=trace-xxx(透传生效)
# 2) 演练 TLS:先生成自签证书(命令见 3.20 节),把 grpc.tls.enabled 改为 true 并重启
grpcurl -cacert configs/certs/server.crt -d '{"user_id": 1}' 127.0.0.1:9090 user.v1.UserService/GetUserByID
# 预期:正常返回用户 JSON(这条链路已被 TLS 加密)
# 改回 false 重启即恢复明文——本地开发才这么干,生产必须常开
本章完成后的项目结构
只列出本章新增(★)与修改/移入(▲)的部分:
content-community/
├── api/
│ ├── proto/user|post/v1/ # ★ user.proto / post.proto 接口定义(post 本章只定义)
│ └── gen/user|post/v1/ # ★ 生成代码 4 件套(*.pb.go 与 *_grpc.pb.go,提交 git、禁手改)
├── cmd/webook/main.go # ▲ 模块组装 + HTTP/gRPC 并行启动
├── configs/
│ ├── config.yaml # ▲ 新增 user.grpc.addr、post.use_grpc_user 与 grpc.* 段
│ └── certs/ # ★ 第 8 步:自签证书(本地演练;生产用 CA 签发)
└── internal/
├── common/types.go # ★ UserBasicInfo 共享值对象
├── user/ # ▲ 模块化移入(第 02 章分层代码)
│ ├── api.go # ★ UserModule 接口 + 本地实现
│ ├── grpc/server.go # ★ gRPC 服务端
│ ├── grpc/server_test.go # ★ 第 7 步:bufconn 无网络单测
│ └── ioc/grpc.go # ★ Server 初始化 + 日志/traceId 拦截器 + Keepalive/TLS
├── post/ # ▲ 模块化移入
│ ├── service/post.go # ▲ 改为依赖 user.UserModule 接口
│ ├── grpc/user_client.go # ★ gRPC 客户端(实现 UserModule)
│ └── ioc/grpc.go # ★ 连接初始化(viper 读地址)
├── interaction/ | rank/ | sms/ # ▲ 第 07/09 章互动、榜单、短信代码整体挪入
└── notification/ # ★ 预留空目录
验证清单
-
go build ./...编译通过,go test ./...原有测试全绿(重构未改变行为) -
api/gen/下生成 4 个.pb.go文件并已提交 git -
go run ./cmd/webook同时监听 8080 与 9090,两条启动日志都出现 - grpcurl 调
GetUserByID返回用户 JSON;curl/api/post/1返回{"code":0,...,"author":{...}}且服务端出现 gRPC 访问日志 -
post.use_grpc_user在true/false间切换,接口行为一致(本地/远程都正常) - 把
user.grpc.addr改错能复现失败、改回后恢复(回滚演练完成) -
go test ./internal/user/grpc/ -run TestGetUserByID -v通过(bufconn 单测,全程不占端口) - 服务端日志出现
trace_id=trace-xxx,同一次请求的客户端与服务端日志能按 ID 串联 - 打开
grpc.tls.enabled重启后,grpcurl 带-cacert仍能调通;关闭后恢复明文(TLS 开关演练完成)
五、工程实践要点
下面这些是从原文章节「工程实践要点」提炼 + 经验补充的生产环境注意事项,拆分微服务前务必逐条过一遍。
- 重构必先补测试:覆盖率 ≥ 80%,且要覆盖核心业务场景和主要异常场景。代码全覆盖 ≠ 业务全覆盖——覆盖率工具只告诉你"哪些行没跑到",不能告诉你"哪些业务场景忘了测"。
- 小步前进:每完成一个关键步骤就跑测试,不要憋大招。重构必然引入 BUG,再小心都会遇到,所以要考虑"出 BUG 怎么办",而不是"怎么不出 BUG"。
- 充分利用 IDE 重构功能:移包、改名、提取接口。别手动复制粘贴,容易漏改引用。
- wire 文件别漏:main 函数和集成测试各有自己的 wire 文件,拆分时两处都要改。
- API 设计向后兼容:gRPC 接口一旦上线就难大改;新增字段用新编号,不要删除老字段(见 3.9 节字段编号规则)。
- 配置中心是灰度方案的基础:阈值要能动态调整并实时生效,否则回滚得发版,太慢。
- 集成测试覆盖率优先于单元测试覆盖率:拆分动的是 DAO/Repository 这些和存储打交道的层,只有真实连库的集成测试才兜得住——单元测试跑得再绿,也挡不住 DAO 改漏带来的 SQL 错误。
- 研发环境提前规划:别指望开发者本地起齐所有依赖服务(Kafka、etcd、MySQL……内存扛不住),测试/开发环境的分离要早做(见 3.9 节三种形态)。
- 重构是升职加薪机会:能加深你对业务和架构的理解,重构成功的系统你就是专家,影响力扩大。
- 连接复用是底线:
ClientConn初始化一次、全局复用,严禁每次请求都Dial;自研连接池只在"必须限制连接总数"时考虑,且必须实现失效探活与回收(3.16 节)。 - Keepalive 两端要对齐:客户端探测间隔不能小于服务端
MinTime,否则会被当成骚扰直接断连;“探测 10~30 秒、超时 5 秒"是常见的生产起点(3.17 节)。 - 错误码就是接口契约:一律用
status.Error(codes.X, ...)表达错误,客户端按 code 分支处理;别让业务错误退化成Unknown,也别把什么都塞成Internal(3.18 节)。 - traceId 走 Metadata、deadline 走 Context:请求入口生成 traceId 并层层透传;每个 RPC 都设超时,下游要检查
ctx.Done(),上游取消后别再白干活(3.19 节)。 - 生产禁用明文凭据:上线前必须启用 TLS;请求级凭证要
RequireTransportSecurity()=true;证书有效期纳入监控,避免"过期即全站不可用”(3.20 节)。 - gRPC 单测用 bufconn:不占端口、毫秒级、可并发,天然适合 CI;但它只测 RPC 层逻辑,连库场景仍要补集成测试(3.21 节)。
六、自测题与动手练习
先自测,答案都在本章和第七节面试问答里(不在这里直接给,逼你回忆)。
自测题(覆盖全章,不给答案)
- 微服务架构的动机是什么?为什么"合并两个系统后复杂度 S > Sa + Sb"?
- 模块化单体和微服务化在"部署 / 资源 / 演进 / 组织"四个维度各有什么差异?最关键的一个词是什么?
- RPC、HTTP、RESTful、微服务四者各是什么、谁包含谁?微服务架构必须用 RPC 吗?
- Protobuf 相比 JSON 省带宽的关键是什么?字段编号为什么"发布了就不能改、不能复用"?
- gRPC 基于什么传输协议 + 什么数据格式?它的四种调用方式分别是什么?
- DDD 里"限界上下文"对应微服务的什么?聚合体之间为什么只能靠 ID 引用?
- gRPC 服务端为什么要嵌入
UnimplementedXxxServer?不嵌入会怎样? - 灰度上线为什么必须"本地调用 + 远程调用并行 + 阈值控制"?只做二选一有什么风险?
- HTTP/2 的单 TCP 多路复用靠哪三个机制实现?为什么说"一个
ClientConn就够"?什么场景才需要自研连接池? - Keepalive 的
Time/Timeout/MaxConnectionAge各管什么?客户端参数和服务端参数不对齐会发生什么? - gRPC 里为什么必须用
status.Error(codes.X, ...)?NotFound和Unavailable在客户端分别该怎么处理? - Metadata 和 Context 各负责传什么?服务端为什么必须检查
ctx.Done()? - bufconn 测 gRPC 的关键步骤是什么?它为什么比"起真端口"更适合 CI?
动手练习(2-3 个,做到什么程度算完成)
练习 1 · 写 proto 并编译(30 分钟)
按 3.8.1 写一份自己的 interactive.proto(覆盖 Like / CancelLike / Collect / Get 四个方法),用 protoc(或 buf)编译,确认生成 .pb.go 和 _grpc.pb.go 两份文件。完成标准:ls 能看到两个产物,且能 go build 通过。
练习 2 · 起服务 + 调一次(40 分钟)
用 3.8.4/3.8.5 起一个 gRPC 服务端监听 :8091,再用 3.8.6 的客户端连上它成功调一次 Like,观察返回。完成标准:服务端日志出现调用记录,客户端收到空响应无报错。
练习 3 · 加灰度开关(30 分钟)
在客户端里加一个 threshold 字段,用 rand.Int31n(100) < threshold 决定走远程,先用 threshold=5 观察大多数请求仍走本地,再逐步调到 100 体会全量切换。完成标准:能通过改 threshold 控制走远程的比例,并说清"为什么纯随机不利于排查问题"(进阶:改成 bizId 哈希)。
练习 4 · bufconn 单测 + traceId 透传(40 分钟)
给 user gRPC 服务端写一条 bufconn 单测(照抄第 7 步,替换成你自己的方法);再在客户端调用前附加 x-trace-id metadata、服务端拦截器里取出来打进日志。完成标准:go test 单测通过且不占端口;同一次调用里,客户端生成的 traceId 与服务端日志打印的一致。
七、模拟面试问答
这一节模拟真实面试。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。
一句话结论:微服务是一种把大型单体拆成多个独立服务的架构风格,动机是"分而治之"——把复杂度降到一个人能理解的程度。
展开:微服务每个服务可独立开发、部署、扩容,自己拥有数据存储,通过 HTTP/RPC/MQ 轻量通信。为什么用?因为合并两个系统后总复杂度 S > Sa + Sb(多出模块间交互),单体越大越没人敢动;拆分后总体复杂度降低、单服务可理解、模块间只通过 API 耦合。注意微服务只是架构的一种,不是银弹。
面试官想听的关键词:架构风格、分而治之、独立部署、S > Sa + Sb、轻量通信、不是银弹。
常见失分点:把微服务说成"必然比单体好";或答不出"分而治之"背后的复杂度数学依据(合并长出交互利息)。
一句话结论:模块化也是分而治之,但资源粒度是"整个单体"——部署时整个应用一起部署、按整个应用的最大资源来配;微服务化后资源粒度是"单个服务"。
展开:运维视角最关键差异是实例才是最基本的运维单位。模块化单体改一行也要全量发版、按峰值总资源配机器、一个团队维护庞大应用;微服务化后每个服务独立部署、独立扩容、独立演进,且"哪个服务出问题就只动哪个服务"。所以微服务的价值在部署与组织,不在代码组织。
面试官想听的关键词:实例是运维单位、部署粒度、资源粒度、独立演进、康威定律。
常见失分点:把"模块化"说成"迟早要拆的过渡态";正确姿态是"小团队可长期停留在模块化单体,微服务是解决部署/组织矛盾的"。
一句话结论:四者不是一回事——RPC 是通信协议思想,HTTP 是应用层协议,RESTful 是架构风格,微服务是架构;微服务不强制通信方式,三种都能用。
展开:RPC 让你像调本地函数一样调远端(可基于 TCP/HTTP/MQ);HTTP 是应用层协议,可在其上封装 RPC;RESTful 只是符合 REST 风格的 Web 设计,和微服务无直接关系。所以微服务 ≠ 必须 RPC——可以 HTTP(RESTful)、可以 RPC(如 gRPC)、可以 MQ。本课程选型"遇事不决选 gRPC"。
面试官想听的关键词:RPC 是协议思想、HTTP 是协议、RESTful 是风格、微服务是架构、不强制。
常见失分点:把四个词当同义词混用;或说"微服务必须用 RPC"(错,HTTP 也能做微服务)。
一句话结论:Protobuf 是 Google 的二进制数据格式 + 接口描述语言(IDL),比 JSON 更小更快;序列化只传"字段编号 + 值",所以编号一旦发布就不能改、不能复用。
展开:Protobuf 优势是高效(二进制体积小、序列化快)、跨语言、扩展性强、工具链完善。它序列化时不传字段名,只传编号和值,这是省带宽的关键。老客户端遇到不认识的新编号会直接跳过,所以服务端加字段老客户端仍能解析——向后兼容。代价是:改了老编号会让新老版本把字段悄悄读错,复用已删编号会让新数据被当成老字段,所以两者都禁止。
面试官想听的关键词:二进制 IDL、字段编号、向后兼容、序列化不传字段名、不可改不可复用。
常见失分点:说"Protobuf 比 JSON 快是因为压缩算法好"(错,核心是二进制 + 不传字段名);或不知道"编号不变即兼容"的原理。
一句话结论:gRPC 是 Google 开源的高性能 RPC 框架,基于 HTTP/2 传输 + Protobuf 数据格式,支持一元 / 服务端流 / 客户端流 / 双向流四种调用。
展开:gRPC 特点是高性能(HTTP/2 多路复用、流控、头部压缩)、跨语言、生态完善。它用 IDL(Protobuf)定义通信格式。四种调用:①一元 RPC(一问一答,最常用);②服务端流式(客户端发一次,服务端源源回);③客户端流式(客户端不断发,服务端最后回一次);④双向流式(两边随时发)。本章只用一元 RPC。
面试官想听的关键词:HTTP/2、Protobuf、IDL、四种流、一元最常用、高性能。
常见失分点:说 gRPC 基于 HTTP/1.1(错,是 HTTP/2);或只答出一元 RPC,说不全四种。
一句话结论:限界上下文(Bounded Context)对应一个微服务边界;聚合体之间只能靠 ID 引用,因为聚合根是修改聚合体的唯一入口,跨聚合体不能强一致。
展开:同一概念在不同上下文特征不同(“产品"在销售和售后眼里不一样),所以限界上下文即微服务边界。聚合体 = 聚合根(实体)+ 值对象,聚合根内部保证一致性;跨聚合体的修改不能在同一事务里强一致,只能通过对 ID 的引用 + 最终一致(事件/消息)实现,避免聚合体间强耦合。
面试官想听的关键词:限界上下文=微服务边界、聚合根唯一入口、ID 引用、最终一致、避免强耦合。
常见失分点:把"实体"和"聚合根"混为一谈;或答不出"为什么不能跨聚合体强一致事务”。
UnimplementedXxxServer?不嵌入会怎样?一句话结论:嵌入它是为了向前兼容——proto 新增方法时服务端结构体默认走"未实现"占位,仍能满足新接口、编译照过;不嵌入则新增方法后编译直接失败。
展开:protoc 生成的接口里有一组"未实现"的嵌入类型(UnimplementedXxxServer)。嵌入后,你的 GRPCServer 自动拥有所有方法的默认实现(返回 Unimplemented 错误),将来 proto 加方法,你的结构体依旧满足新接口。这是微服务"API 向后兼容"在工程上的具体落地。
面试官想听的关键词:向前兼容、Unimplemented 占位、编译不失败、API 兼容。
常见失分点:不知道有这个嵌入类型;或以为"不实现的方法就不写"也能编译过(实际上不嵌入就不满足接口)。
grpc.Dial 是立刻建连接的吗?ClientConn 是什么?为什么不能每次请求都 Dial?一句话结论:grpc.Dial 是懒连接(通常不立刻建网络),第一次真正发 RPC 才建连;ClientConn 是"多路复用 + 连接管理"的抽象,应初始化一次全程复用——底层 HTTP/2 让一条连接就能同时扛成百上千个请求,所以更不该每次请求都 Dial。
展开:ClientConn 底层维护到服务端的 HTTP/2 连接,上层还有面向多实例的连接管理与负载均衡,所以它不是"一个 socket"。HTTP/2 在单条 TCP 上划分多个 Stream、帧按流编号交错传输(加 HPACK 头部压缩),因此并发靠"流"而不是"开更多连接";自研连接池只在"必须限制连接总数"时才需要,还得处理失效回收。另外"Dial 成功"不代表"服务端活着"——判断存活要靠真正调用或健康检查。每次请求都 Dial 会反复建连、浪费资源、还可能引发建连风暴。新版推荐 grpc.NewClient 替代已废弃的 Dial。
面试官想听的关键词:懒连接、ClientConn 复用、HTTP/2 多路复用(Stream/帧交错/HPACK)、Dial 成功≠服务端活、池化取舍、NewClient。
常见失分点:以为"并发高就得开更多连接"(错,单连接多流就够);以为 Dial 成功就证明服务端能连(错,懒连接);或每次请求 new 一个 ClientConn。
一句话结论:Keepalive 用 HTTP/2 的 PING/ACK 给长连接做"连接级心跳"——让中间设备不敢回收、让两端及时发现"假死"连接;客户端探测间隔小于服务端 MinTime 会被当成骚扰直接断连。
展开:长连接会被 NAT / 防火墙 / L4 负载均衡器静默丢弃,客户端不察觉就会"发出去石沉大海"。客户端用 keepalive.ClientParameters(Time/Timeout/PermitWithoutStream)控制探测节奏;服务端用 ServerParameters(MaxConnectionIdle/MaxConnectionAge/Time/Timeout)控制探测与回收,并用 EnforcementPolicy.MinTime 限制客户端最小探测间隔(违规断连,日志常见 too_many_pings)。生产常配探测 10~30 秒、超时 5 秒。注意它是连接级心跳,与 etcd 租约(服务级)、K8s 探针(实例级)是三回事。
面试官想听的关键词:PING/ACK、连接级心跳、MinTime 对齐、too_many_pings、MaxConnectionAge、与业务心跳的区别。
常见失分点:把 Keepalive 当成业务心跳或健康检查;只知道客户端参数,不知道服务端有 EnforcementPolicy 约束(两边不对齐会掉进断连坑)。
一句话结论:三层防护——TransportCredentials(TLS)加密通道、PerRPCCredentials 携带请求级身份(token/JWT)、Interceptor 统一做鉴权/日志等横切逻辑。
展开:① 传输层用 credentials.NewServerTLSFromFile / NewClientTLSFromFile 开启 TLS,防窃听与中间人;② 应用层凭证实现 GetRequestMetadata 把 token 放进 metadata,RequireTransportSecurity()=true 强制要求走 TLS;③ 服务端拦截器从 metadata 取 authorization 校验,不通过返回 codes.Unauthenticated,业务代码零侵入。安全要求更高可以上 mTLS(双向证书校验)。
面试官想听的关键词:TLS 通道、PerRPCCredentials、RequireTransportSecurity、拦截器鉴权、Unauthenticated、mTLS。
常见失分点:生产还用 insecure.NewCredentials();加了凭证却不启用 TLS(或把 RequireTransportSecurity 改成 false,token 明文过网);把鉴权散落在每个业务 handler 里。
一句话结论:Metadata 负责透传键值对(traceId、token 等),Context 负责传播 deadline 与取消信号;gRPC 会把剩余超时折算后传给下游,所以链路超时自动缩短。
展开:客户端用 metadata.NewOutgoingContext 注入、服务端用 metadata.FromIncomingContext 读取;context.WithTimeout 设的是整条链的"总预算",下游拿到的是"总预算 − 已耗时"。上游取消或超时后,下游 ctx.Done() 立即触发,所以下游必须 select 检查 ctx.Done(),否则上游都返回了它还在白干。超时值要按调用链层数逐级扣减来设,而不是每层都拍一个固定值。
面试官想听的关键词:metadata 透传、traceId、deadline 折算、ctx.Done()、取消传播、链路超时。
常见失分点:服务端不检查 ctx.Done()(上游取消后还在跑完整查询);用 metadata 传大对象;以为超时只是本进程的事(实际会贯穿整条调用链)。
一句话结论:错误必须用 status.Error(codes.X, msg) 表达——普通 error 到对端只剩 Unknown;统一 Response 包装是取舍不是必须,对内微服务用"具体 message + status 状态码"更简洁。
展开:客户端用 status.FromError / status.Code 解析后按码分支:NotFound 按业务降级、Unavailable/DeadlineExceeded 交给熔断重试、InvalidArgument 是调用方 bug。统一包装(Response{code, message, data} + google.protobuf.Any)能让接口形状一致、方便网关和前端适配,但 data 装箱削弱类型安全,业务码与 gRPC 状态码还容易"双轨";只有"同一份 proto 同时要暴露 REST"时才更值得(通常搭配 gRPC-Gateway)。
面试官想听的关键词:status/codes、Unknown 的由来、按码分支处理、Any 装箱代价、多一层解包、gRPC-Gateway。
常见失分点:随手 errors.New 或把什么错都塞 Internal;不知道客户端能按 code 做差异化处理(降级 / 重试 / 熔断)。
一句话结论:四阶段是「混沌一片 → 模块化 → 模块依赖化 → 服务化」;第一个拆分模块按"先易后难"原则选最独立、业务影响小的。
展开:混沌一片(补测试、抽 utils、解循环依赖、按业务划包)→ 模块化(建仓库、准备环境框架)→ 模块依赖化(逐个服务化、建自动部署回滚、调研治理/MQ/事务/可观测)→ 服务化(引治理、网关、分库)。选型原则:优先业务影响小(崩了没事)、最独立(依赖少)、再考虑 QPS 低;绝对不选用户/帖子/短信/热榜等核心服务。
面试官想听的关键词:四阶段、先易后难、业务影响小、最独立、避开核心服务。
常见失分点:把四阶段顺序说错;或第一个就拆"用户"这种核心服务(面试官会直接扣分)。
一句话结论:重构前补测试、覆盖率 ≥ 80% 且覆盖核心业务场景;代码全覆盖只是"行跑到",业务全覆盖是"场景测到",后者靠人肉梳理。
展开:集成测试覆盖率比单元测试更重要(真实连库才兜得住 DAO 改动)。两边代码都要覆盖(被拆模块 + 调用它的代码)。但覆盖率工具只报"哪些行没跑",报不出"哪些业务场景忘了测"——所以跑完报告要逐个确认未覆盖分支是否可接受,并拉着熟悉业务的老员工过场景清单。
面试官想听的关键词:覆盖率≥80%、集成测试优先、场景清单、人肉梳理、老员工把关。
常见失分点:只说"写单测"答不出"集成测试比单测重要";或以为覆盖率 100% 就安全(漏的是业务场景)。
一句话结论:并行 + 阈值 = 0 时全走本地,逐步放大放少量流量到远程,出问题立即调回 0 切本地——这才是"可回滚"的灰度;纯随机缺陷是同一数据时远时近难复现,进阶用"阈值 + 业务 ID 哈希"。
展开:最大风险在"模块化"和"服务化"两步,一旦远程出问题没退路用户直接受影响。配置中心动态调阈值才能随时回滚。纯随机(rand.Int31n(100) < threshold)让同一 bizId 这次远程下次本地,排查要同时看两处;进阶"阈值 + bizId 哈希"让同 bizId 永远同路径,出问题能稳定复现。
面试官想听的关键词:可回滚、阈值动态、配置中心、纯随机缺陷、业务 ID 哈希。
常见失分点:只讲"切过去试"答不出"为什么必须留本地退路";或不知道纯随机的排查缺陷。
一句话结论:主流有 gRPC / go-zero / Dubbo-go / go-micro;从功能性差距不大、从成熟度和性能 gRPC 最好,所以"遇事不决选 gRPC"。
展开:选型看三维度——功能性(业务无特殊时主流都能满足)、成熟度(社区/搜索/招聘)、非功能性(性能,中小公司基本不用考虑)。gRPC 自带基础负载均衡、生态最好、配套开源最多;go-zero 集大成、底层也是 gRPC、国人维护无语言壁垒;Dubbo-go 能和 Java Dubbo 无缝通信;go-micro 主打简单易用。结论:功能性差不多时选最成熟、生态最好的 gRPC。
面试官想听的关键词:gRPC/go-zero/Dubbo-go/go-micro、三维度选型、生态最好、遇事不决选 gRPC。
常见失分点:把"框架选型"答成"挑性能最高的"(忽略成熟度和招聘难度);或不知道 gRPC 底层也是 HTTP/2。
一句话结论:用 bufconn 在内存里起服务端 + 自定义 dialer 连内存 listener——不占端口、不走网络、毫秒级、可并发,是 gRPC 测试的标准做法。
展开:bufconn.Listen 造一个内存 listener,注册真实 server 后 go s.Serve(lis);客户端用 grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(lis.Dial), grpc.WithTransportCredentials(insecure.NewCredentials())) 直接"连"到内存,之后调用方式和真实 gRPC 一模一样。细节:defer s.Stop() 防 goroutine 泄漏,每个用例用独立 listener 防互相干扰。它测的是 RPC 层逻辑;DAO/Repository 的真实 SQL 仍要靠集成测试兜底——两者分工,不互相替代。
面试官想听的关键词:内存 listener、自定义 dialer、passthrough、defer Stop、CI 里可并发、与集成测试的分工。
常见失分点:以为测 gRPC 必须起真端口;忘了 defer s.Stop() 造成 goroutine 泄漏;把 bufconn 测试当集成测试用(不连库,SQL 问题测不出来)。
八、本章小结
学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。
可勾选清单(面试前 5 分钟扫一遍)
- 微服务动机:本质是"分而治之",目标是把复杂度降到一个人能理解;合并后 S > Sa + Sb。
- 模块化 vs 微服务化:模块化只解决代码组织,微服务解决部署/组织;实例才是最基本的运维单位。
- 四概念关系:RPC 是协议思想、HTTP 是协议、RESTful 是风格、微服务是架构;微服务 ≠ 必须 RPC。
- Protobuf:二进制 IDL,序列化只传"字段编号+值";编号发布后不可改、不可复用;repeated/map/enum 会写。
- gRPC:基于 HTTP/2 + Protobuf 的 RPC 框架;四种调用(一元/服务端流/客户端流/双向流);Unimplemented 嵌入做向前兼容。
- 连接:
grpc.Dial是懒连接,ClientConn负责多路复用与连接管理、全程复用、别每次请求 Dial。 - 多路复用:HTTP/2 在单 TCP 上跑多个 Stream、帧交错、HPACK 压缩;一个
ClientConn就够,自研池只在限连接数时才考虑。 - Keepalive:PING/ACK 连接级心跳;探测 10~30s、超时 5s;客户端
Time不能小于服务端MinTime;与 etcd 租约、K8s 探针是三回事。 - 错误规范:
status.Error(codes.X, ...);客户端按 code 分支(NotFound 降级、Unavailable 熔断重试);统一 Response 包装是取舍不是必须。 - Metadata / Context:traceId 走 metadata、deadline/取消走 ctx;剩余超时自动折算传给下游;下游必须检查
ctx.Done()。 - 安全三层:TLS 通道 + PerRPCCredentials 凭证 + 拦截器鉴权;生产禁用
insecure;mTLS 是加分项。 - bufconn:内存 listener + 自定义 dialer,无端口、毫秒级单测;记得
defer s.Stop();与集成测试分工不互替。 - 完整链路:契约 → 装配 → 传输 → 安全 → 语义 → 质量;七块合起来才是一套生产可用的 gRPC 微服务。
- DDD:限界上下文 ≈ 微服务边界;聚合根唯一入口;聚合体间只靠 ID 引用;领域事件可发 MQ。
- 拆分路线:四阶段「混沌一片→模块化→模块依赖化→服务化」;先易后难,第一个拆最独立、影响小的模块。
- 灰度上线:本地 + 远程并行 + 阈值控制 + 配置中心动态调 + 随时可回滚;进阶用"阈值 + 业务 ID 哈希"。
- 工程纪律:重构前补测试覆盖率 ≥ 80%、集成测试优先于单测、代码全覆盖 ≠ 业务全覆盖、小步前进。
- 框架选型:gRPC / go-zero / Dubbo-go / go-micro,遇事不决选 gRPC。
下一章我们将进入「不停机数据迁移」——微服务化之后必须把数据库也拆开,这是生产环境最高频的难题之一。本章 user 的 gRPC 服务端和 post 还跑在同一进程里,下一章你会看到如何把 user 整体搬成独立进程、独立仓库、独立数据库,且借助第 12 章的注册中心去掉本章写死的 user.grpc.addr。
UserModule 和 gRPC 客户端,靠 post.use_grpc_user 开关切换。当 user 真的变成独立服务、数据库也拆走后,这个"本地实现"就该删掉了——但"先双跑、再切、能回滚"的灰度思想会一直沿用。带着"数据库怎么拆而不停机"的问题进入第 11 章,会学得更扎实。另外,当所有模块都拆成微服务后,“Web 层"该每个模块各带一个还是全局只留一个?提前了解 BFF(Backend For Frontend) 的概念,值得跟人讨论。