写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 04 章。它假设你刚做完第 02、03 章——你已经会用 Gin 写接口、用 GORM 连 MySQL、用 JWT 做登录态、用 Redis 做限流和 Session。换句话说,Web 工程的"骨头"你已经摸过了。
但这一章有三个你从没碰过的东西:
- 测试——你之前写的代码从没跑过
go test,也没给任何函数写过测试。- 依赖注入(DI)/ 控制反转(IoC)——你之前是"在哪用就在哪
new",从没想过"把依赖从外面传进来"。- 接第三方服务——你之前只连过自己的 MySQL/Redis,从没对接过腾讯短信、从没处理过"对方挂了我怎么办"。
这一版做了三件事:
- 每一步都能跑。所有代码都标了文件路径、给了运行命令、写了预期输出。你照着敲,看到的应该和我写的一模一样。
- 术语不跳过。凡是出现你没见过的词(比如"压测"“缓存"“mock"“依赖注入"“控制反转"“wire"“装饰器模式”),当场就解释,不会让你自己去搜。
- 结尾有面试问答。每章最后 12 道题,按"基础 → 进阶 → 追问"三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。
如果你想要"高密度速查版”,可以看原版第 04 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
第 02、03 章我们把手脚架搭起来了:用户能注册、能登录、有 JWT 认证,限流兜住了恶意流量,服务也部署进了 K8s。但"能跑"和"工程质量好"是两回事——你心里其实没底:登录接口到底快不快?换个短信供应商要不要改一大片代码?你写的代码敢不敢随便重构?
这一章,我们关注点从"功能能跑"转向"工程质量”,主线是三件事:
- 性能优化:用
wrk压测把"慢在哪"用数据暴露出来,发现瓶颈在数据库,于是引入 Redis 缓存,再压一遍验证效果。这是一条完整的"发现问题 → 定位 → 解决 → 验证"闭环。 - 短信服务抽象:以"手机号收验证码登录"为载体,完整走一遍"识别变化点 → 设计三层抽象 → 用依赖注入组装"的流程,体会面向接口编程为什么值钱。
- 测试:从什么叫测试讲起,到表驱动单测、mock、集成测试,把 Handler / Service / Repository / Cache / DAO 每一层怎么测都覆盖到。
学完这章,你写出的将不只是"能工作的代码”,而是可测试、可替换、可扩展的工程化代码——这也是面试里最容易拉开差距的地方。
开读前先看一眼全局:本章的两条主线在图里——接口抽象让业务"依赖抽象而非具体厂商”,测试体系让每一层都能被独立验证。短信服务只是载体,“可替换 + 可测试"才是目的。
flowchart TB
subgraph IFACE["接口抽象:依赖倒置"]
A["业务只依赖接口"] --> B["阿里云短信实现"]
A --> C["本地 Mock 实现"]
A --> D["其他厂商实现"]
end
B --> E["分层架构
Handler → Service → Repository"]
subgraph TEST["测试体系"]
F["表驱动单测"] --> G["mock 隔离依赖"] --> H["集成测试"]
end
E --> F
subgraph PERF["压测验证"]
I["wrk 压测"] --> J["QPS / P99 基线"]
end
E --> I这张图怎么读:核心是接口抽象子图——业务代码(A)只依赖抽象接口,向下分出阿里云、本地 Mock、其他厂商三套实现,换供应商只是换一条分支,业务代码不动;实现最终落进分层架构(E:Handler→Service→Repository),E 再分出两条验证线:一条进测试体系(F 表驱动单测→G mock 隔离依赖→H 集成测试,粒度由细到粗),一条进压测验证(I wrk 压测→J 产出 QPS/P99 基线)。整体读法是"抽象→落地→验证"三步,与本章"压测暴露瓶颈→引入缓存→短信三层抽象→DI 组装→各层测试"的行文顺序一一对应。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用
wrk对一个 HTTP 接口做基础压测,能看懂输出里的 QPS、延迟、P99 这些数字代表什么。 - 在 Repository 层用
UserCache给"查用户"套一层 Redis 缓存,写出"先查缓存、未命中查库、回写缓存"的FindById。 - 把"短信 → 验证码 → 登录"拆成三层接口,各写至少一个实现,并用依赖注入把它们串起来。
- 用
mockgen给一个接口生成 mock,写出一个**表驱动(Table-Driven)**单元测试并跑绿。 - 用
httptest测一个 Gin Handler,用sqlmock测一个 GORM 的 DAO。
理解(能讲清原理和取舍,不要求背代码)
- 依赖注入(DI)和控制反转(IoC)是什么、为什么测试离不开它、
wire是怎么用"代码生成"而非"运行时反射"来组装依赖的。 - 为什么要"面向接口编程”——哪些地方该留接口、哪些地方暂时不用留,以及"超前设计、最小化实现"的分寸。
- 单元测试 vs 集成测试的区别、各自的适用边界、为什么测试不能真连数据库真发短信(mock / 打桩的意义)。
- 分布式环境下"check-then-act"为什么不能用
sync.Mutex解决,为什么要用 Redis Lua 脚本保证原子性。 - 装饰器模式如何无侵入地给第三方调用叠加限流、超时、重试、熔断等能力。
了解(知道有这回事,面试能说出名字和一句话定位)
- 压测工具
wrk、指标 QPS / 延迟 / P99 的含义;测试金字塔的概念。 go generate机制,以及它和go build完全无关(只负责调用 mockgen / wire / stringer 这类生成器)。- 第三方服务治理的高可用思路:客户端限流、超时、重试、熔断、failover 切换。
- 缓存的三大经典问题(穿透 / 击穿 / 雪崩)的雏形——本章首次引入缓存,先建立直觉,第 06 章会专门展开。
前置依赖与补课清单
本章用到几个前文从未系统讲过的技术名词。下表列出它们,并在正文里就地补「补课胶囊」,不用你去别处查:
| 本章用到的"新词” | 在哪节首次出现 | 补课胶囊位置 |
|---|---|---|
| 压测 / wrk / QPS / 延迟 / P99 | 3.1 节 | 见 3.1 节「补课胶囊 · 压测」 |
| 缓存(Cache) | 3.3 节 | 见 3.3 节「补课胶囊 · 缓存」 |
| 单元测试 vs 集成测试 | 3.8 节 | 见 3.8 节「补课胶囊 · 测试的两种粒度」 |
| mock / 打桩 | 3.9 节 | 见 3.9 节「补课胶囊 · mock 与打桩」 |
| 依赖注入 DI / 控制反转 IoC | 3.10 节 | 见 3.10 节「补课胶囊 · 依赖注入与控制反转」 |
| wire(代码生成 vs 反射) | 3.11 节 | 见 3.11 节「补课胶囊 · wire 是什么」 |
你已经有的基础(来自第 02、03 章,本章不再展开):Gin 路由与 Handler、GORM 的 DAO 写法、JWT 登录态、Redis 的 string 结构(存过 Session)、go mod 与项目结构。
预计时长与学习成果
预计时长:4 小时(精读 + 跟着敲全部代码);如果只求主干,2 小时可过一遍。
学完你能跑出来的东西(这是本章的验收标准,做到才算过关):
在你的
webook项目里:① 用wrk给登录接口压出一份"压测前后"对比数据;② 新增UserCache并在FindById里接入缓存;③ 新增sms.Service/CodeService两个接口及腾讯、本地两套实现,用 wire 串好;④go test ./...跑通至少一组表驱动单测 + 一组集成测试。全绿。
flowchart LR
A["3.1 压测暴露瓶颈"] --> B["3.3 引入缓存 UserCache"]
B --> C["3.6-3.7 短信三层抽象"]
C --> D["3.10-3.11 DI / IoC / wire"]
D --> E["3.8 测试: 单测 vs 集成"]
E --> F["3.9 mockgen 各层测试"]
F --> G["第四节 跟着写
接入缓存+短信+测试"]
G --> H["第七节 面试问答"]这张图怎么读:路线先走性能线——3.1 用 wrk 压测暴露瓶颈,3.3 引入 UserCache 缓存化解数据库 IO;再走抽象线——3.6-3.7 把短信拆成三层接口,3.10-3.11 用 DI/IoC/wire 把实现组装起来;最后进入测试线——3.8 分清单测与集成测试的边界,3.9 用 mockgen 补齐各层测试。三条线依次衔接、最终汇入第四节"跟着写:接入缓存+短信+测试",由第七节面试问答收口。其中 D(DI/wire)是枢纽:它既是短信抽象的组装手段,也是后面各层能换 mock、可测的前提。
go build ./... 验证一次。三、正文
3.1 为什么要压测:先有数据,再谈优化
先抛问题
你写完了登录接口,信心满满。同事问你:“这接口能扛多少并发?““平均响应多久?“你一时语塞——凭感觉说"应该挺快的"在工程师眼里等于零。
问题来了:怎么知道一个接口到底慢不慢、慢在哪? 拍脑袋优化是最危险的——你以为瓶颈在数据库,结果其实是加密算法吃 CPU,白忙活一场。
给个直觉:给汽车做"体检”
压测(英文 pressure test,也叫 benchmark / 负载测试)就像给汽车做"极速与油耗体检”:
- 你不开上赛道狠踩油门(高并发打流量),就永远不知道这辆车极速多少、哪里先出问题。
- 体检报告上的"百公里加速 8 秒"“极速 220”(QPS / 延迟),就是接口的性能指标。
- 只测一个司机匀速开(低并发)看不出问题;要测"满载 + 急刹 + 连续跑”(高并发 + 长时间)才暴露真实短板。
补课胶囊 · 压测
一句话:压测 = 用工具模拟大量用户同时访问你的接口,看它撑不撑得住、哪里先垮。
类比:春运前铁路做"满负荷演练”,提前发现哪节车厢会挤爆。
最小示例(装好
wrk后,对一个本地接口压 1 秒):# Mac 安装 brew install wrk # 对本地登录接口压 1 秒、2 个并发连接、1 个线程 wrk -t1 -d1s -c2 http://localhost:8080/users/login回到主线:下面我们就是用
wrk把"慢在哪"用数据暴露出来,再决定怎么优化。
看懂 wrk 输出里这几个数字(原文压测后会看到类似结果,数值只是示意):
| 指标 | 含义 | 一句话白话 |
|---|---|---|
Requests/sec(QPS) | 每秒处理多少请求 | 接口的"吞吐能力",越大越好 |
Latency(延迟) | 单个请求从发出到收到响应的时间 | 用户体感到的"卡不卡" |
Latency distribution 里的 p99 | 99% 的请求都比这个值快 | 长尾用户的最差体验,比平均值更值得看 |
Errors | 压测期间报错数 | 越多越说明撑不住了 |
小知识:P99 是什么? 把压测期间所有请求的延迟从小到大排成一列,P99 就是"排在第 99% 位置"那个值——意思是 99% 的请求都比它快,只有最慢的 1% 比它慢。为什么看 P99 而不是平均值?因为平均值会被大量"很快的请求"拉低,掩盖了"最慢那批用户其实等了很久"的事实。面试常问,记住:P99 = 长尾延迟,代表最差 1% 用户的体验。
最小可运行示例:先确认接口能跑
在压测之前,你的登录接口必须已经在本地 go run 起来了。确认它能正常响应:
# 文件路径: 终端,确认服务在 8080 端口监听
curl -X POST http://localhost:8080/users/login \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"hello#world123"}'
预期输出(示意,取决于你的登录接口返回格式):
{"code":200,"msg":"登录成功","data":{"token":"eyJhbGc..."}}
如果这里都通不过,先回去把服务跑起来,再谈压测。
常见坑:压测前忘了"松绑"
压测结果不准,往往不是工具的问题,而是环境没准备好:
⚠️ 压测前三个必做动作:
- 临时启用 JWT 且把登录态有效期调长(比如 30 分钟),否则压测 Profile 接口时 token 频繁过期,测到的全是"重新登录"的开销。
- 去掉限流中间件,否则你压的不是业务接口,而是被自己的限流挡住了(第 03 章的限流会拒绝高频请求)。
- 关掉或调低日志级别,疯狂打日志本身会拖慢接口,污染数据。
小收口
压测不是"秀肌肉",而是"找病根"。记住一句话:先有数据,再谈优化;凭感觉优化是工程大忌。 下一节我们真正用 wrk 把登录接口压一遍,看看瓶颈到底出在 CPU 还是 IO。需要强调的是,压测结果会随机器配置、数据量、网络环境变化,所以同一份代码在你本地跑出的数字和本文"示意"数据不同是完全正常的,关键看"优化前后的相对趋势"。
3.2 用 wrk 把接口压出瓶颈
先抛问题
理论上登录接口慢,可能是"查数据库慢"(IO 瓶颈),也可能是"比密码慢"(CPU 瓶颈),还可能两个都有。我们要用数据区分它们,才能对症下药。
给个直觉:医生分科
就像肚子疼要分清是"胃的问题"还是"肠的问题"——查血(CPU 占用)和拍片(IO 等待)是两个不同的检查。压测后看哪类资源先被打满,就锁定了瓶颈科室。
最小可运行示例
wrk 支持用 -s 接一个 Lua 脚本,用来构造 POST 请求体(动态拼 JSON)。下面是压测注册接口的命令骨架:
# 文件路径: 终端
# -t 线程数(一般 ≤ CPU 核数)
# -d 持续时间,如 1s / 10s / 1m
# -c 并发连接数
# -s Lua 脚本,负责构造请求体
wrk -t1 -d10s -c50 -s ./scripts/wrk/signup.lua \
http://localhost:8080/users/signup
signup.lua 长得像这样(只演示骨架,不需要背):
-- scripts/wrk/signup.lua
-- wrk 的 Lua 扩展:动态生成每个请求的 body
wrk.method = "POST"
wrk.headers["Content-Type"] = "application/json"
wrk.body = '{"email":"test@test.com","password":"hello#world123","confirmPassword":"hello#world123"}'
压测读接口(Profile,需要登录态)时,要在脚本里带上 Authorization 头和 User-Agent:
wrk -t1 -d10s -c50 -s ./scripts/wrk/profile.lua \
http://localhost:8080/users/profile
预期输出(示意,数值不代表真实成绩):
Running 10s test @ http://localhost:8080/users/signup
1 threads and 50 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 118.50ms 42.10ms 310.22ms 78.30%
Req/Sec 412.33 55.21 520.00 68.00%
Latency Distribution
50% 110.00ms
90% 180.00ms
99% 295.00ms
4123 requests in 10.02s, 1.92MB read
Requests/sec: 411.68
Transfer/sec: 196.41KB
逐行讲解:从这份报告读出瓶颈
| 报告字段 | 你读到了什么 |
|---|---|
Requests/sec: 411.68 | 这台机器上登录接口大约每秒处理 412 个请求。这个 QPS 高不高?要看业务量级,但作为"优化前基线"先记下来。 |
Latency Avg 118ms | 平均每个请求 118 毫秒。用户会觉得"有点慢"。 |
99% 295ms | 最慢的 1% 用户要等 295ms,长尾不乐观。 |
Errors 没出现 | 这一轮没报错,说明还没到崩溃边缘。 |
怎么判断瓶颈在 CPU 还是 IO? 压测时另开一个终端看资源占用:
# 边压测边看 CPU / 内存占用
top -o cpu
- 如果CPU 跑满而数据库很闲 → 瓶颈在 CPU,大概率是
bcrypt比密码这种加密算法。 - 如果数据库 CPU/连接数跑满、应用 CPU 反而闲 → 瓶颈在数据库 IO(查得太多/太慢)。
常见坑:把 Lua 脚本里的账号写死成不存在的用户
login.lua 里的账号必须是数据库里真实存在的。如果你写个不存在的账号,请求会一路走到"查不到用户"直接返回,根本没触发 bcrypt 比对密码——你压测到的不是真实登录路径,数据毫无意义。登录接口因为要 bcrypt 比对,和注册接口一样吃 CPU,也可以故意换不同加密算法观察性能差异。flowchart LR
W[wrk 压测] --> T{"瓶颈在哪?"}
T -- 加密算法 bcrypt 占满 CPU --> C[CPU 瓶颈]
T -- 数据库查询排队 --> I[IO 瓶颈]
C --> RC["优化算法 / 加缓存"]
I --> RC工具选型补充一句:wrk 之外,社区还有几款主流压测工具值得对照认识——k6(Go 编写、脚本用 JS,第 19 章 Feed 压测会正式使用)、hey / vegeta(轻量 HTTP 压测)、ghz(gRPC 专用压测,第 10 章拆完微服务后压 gRPC 接口用它)。压测思想是同一套:并发梯度、时长控制、QPS 与分位数延迟解读——会一个,其它都是一天上手的事。
小收口
现在你手里有一份"优化前基线":登录接口 QPS 约 412、P99 约 295ms。接下来针对 IO 瓶颈(数据库查询),我们引入缓存——但不要一上来就蹭 Redis,而是要为业务封装一个专属的 UserCache,这是下一节的重点。
3.3 性能瓶颈分析与引入缓存
先抛问题
压测后通常会看到瓶颈集中在两类:
- 加密算法(如
bcrypt)耗费 CPU; - 数据库查询耗费 IO。
针对数据库查询,引入 Redis 缓存是惯用手段。但有个关键决策:业务代码里能不能直接写 redis.Client? 答案是——最好不要。
给个直觉:食材管理员
类比:你不会在每道菜里直接操作冰箱(自己拼 key、自己序列化),而是有一个"食材管理员"统一存取。
UserCache就是那个管理员——业务方只说"给我 id=123 的用户",不用管数据在 Redis 还是 MySQL、key 怎么拼、结构体怎么转成字节。
补课胶囊 · 缓存(Cache)
一句话:缓存 = 把"经常要、但算起来/查起来慢"的数据,先存一份到更快的地方(通常是内存),下次直接拿,省去重复劳动。
类比:你桌角贴一张便利贴写"WiFi 密码 12345678",比每次翻路由器背面快多了——便利贴就是"WiFi 密码"的缓存。
最小示例(用 Redis 当缓存,先建立直觉):
// 文件路径: internal/repository/cache/user.go(示意骨架) // 先查缓存,没有再查库,查到回写缓存 u, err := r.cache.Get(ctx, id) // 从 Redis 拿 if err != nil { u, err = r.dao.FindById(ctx, id) // 缓存没有,回源数据库 r.cache.Set(ctx, u) // 顺手写回缓存 }回到主线:本章首次正式引入缓存。第 06 章会专门讲缓存的三大经典问题(穿透 / 击穿 / 雪崩),这里你先记住一个直觉:缓存解决的是"读多写少、算得慢"的场景,它本身也会带来一致性问题,别滥用。
为什么需要一个 UserCache,而不是直接用 redis.Client?
三个"屏蔽"指向同一个思想——把"怎么存"的细节关进缓存组件里,对外只暴露"存什么":
- 屏蔽过期时间:调用方不需要关心 key 的 TTL(存活时间)是多久。
- 屏蔽 key 结构:调用方不用知道
user:info:123这种 key 是怎么拼出来的。 - 屏蔽序列化协议:调用方不用关心结构体是怎么转成
[]byte存进 Redis 的。
如果你在业务代码里到处写 key 拼接和 JSON 序列化,将来想调整缓存策略——比如换一种序列化协议、给 key 加统一前缀——就得满工程找代码改;而有了这层抽象,只需要改 UserCache 的实现这一处。
[]byte(存进 Redis)叫序列化;把 []byte 转回结构体叫反序列化。Go 里常用 JSON。这是缓存组件内部干的事,业务方完全不需要知道。压测前后对比(示意)
引入 UserCache 后,我们再压一遍 Profile 接口(高频读场景),理想结果是这样(以下数值为示意,实际取决于你的机器和数据量):
| 指标 | 优化前(直连 DB) | 优化后(加 UserCache) | 说明 |
|---|---|---|---|
| QPS | ~412 | ~2100 | 命中缓存后不再查库,吞吐大幅提升 |
| Avg 延迟 | ~118ms | ~22ms | 读内存比读库快一个数量级 |
| P99 延迟 | ~295ms | ~60ms | 长尾明显收窄 |
| 数据库 QPS | 与接口 QPS 持平 | 显著下降 | 大部分读被缓存挡住 |
注意:上面是"示意"数据,不是承诺。真实数值由你的机器配置、数据量、Redis 部署方式决定。你动手跑出来的结果可能更好也可能更差,但趋势一定是"加缓存后读接口更快、数据库压力更小"。这正是"先压测、再优化、再压测验证"闭环的价值。
常见坑:为了"缓存而缓存"
小收口
我们走完了性能优化的完整闭环:压测暴露瓶颈(数据库 IO)→ 定位(高频读 DB)→ 引入 UserCache 抽象 → 再压测验证(QPS 提升、延迟下降)。下一步是把 UserCache 真正接进代码——注意接入位置有讲究,要在 Repository 层而非 Service 层。
3.4 在 Repository 层集成 UserCache
先抛问题
缓存属于"数据怎么存",该放在哪一层?如果放在 Service 层,Service 就会知道"数据先去 Redis 还是先去 MySQL",职责就乱了。
给个直觉:仓库管理员才管库存
Service 是"柜台营业员",只管业务流程(登录、注册),不该操心货存在哪个仓库;Repository 是"仓库管理员",本来就和存储打交道,让它顺手管缓存最自然。
最小可运行示例
把 DAO 和 Cache 都通过依赖注入传进 Repository,缓存逻辑封装在 Repository 内部:
// 文件路径: internal/repository/user.go
package repository
// CachedUserRepository 在 Repository 层组合 DAO 与 Cache
type CachedUserRepository struct {
dao dao.UserDAO // 操作数据库
cache cache.UserCache // 操作 Redis
}
// NewUserRepository 依赖注入:DAO 和 Cache 都从外面传进来
func NewUserRepository(dao dao.UserDAO, cache cache.UserCache) *CachedUserRepository {
return &CachedUserRepository{dao: dao, cache: cache}
}
// FindById 先查缓存,未命中再查库并回写
func (r *CachedUserRepository) FindById(ctx context.Context, id int64) (domain.User, error) {
// 步骤 1:先查缓存
u, err := r.cache.Get(ctx, id)
if err == nil {
// 缓存命中,直接返回
return u, nil
}
// 步骤 2:缓存未命中或出错,都 fallback 到数据库
// 推荐:缓存出错也继续查数据库,保证业务可用
ue, err := r.dao.FindById(ctx, id)
if err != nil {
return domain.User{}, err
}
// 步骤 3:回写缓存,忽略错误(缓存写失败不该影响主流程)
_ = r.cache.Set(ctx, ue.ToDomain())
return ue.ToDomain(), nil
}
逐行讲解
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 7-8 | 结构体字段 dao 和 cache | 两者都是接口类型,方便测试时换成 mock(后面测试章节会用到) |
| 12-14 | NewUserRepository(dao, cache) | 依赖从外面传入,这就是依赖注入,后面 3.10 节专门讲 |
| 19-22 | 缓存命中直接返回 | 命中就没必要碰数据库,省下最贵的 IO |
| 25-28 | 缓存未命中/出错都查库 | 关键取舍:缓存出错(如 Redis 挂了)也继续查库,业务不因缓存故障而挂 |
| 30-32 | 回写缓存,忽略错误 | 缓存写失败只影响下次命中率,不该让本次请求失败 |
两种写法的取舍
| 写法 | 行为 | 优缺点 |
|---|---|---|
| 缓存出错也查 DB(推荐) | Redis 挂了业务依然可用 | 数据库可能被压垮(缓存雪崩时) |
| 缓存出错不查 DB | Redis 挂了业务也不可用 | 保护数据库,但牺牲可用性 |
选哪种取决于业务优先级:用户可用优先还是数据库保护优先。中小项目一般用"缓存出错也查 DB",因为缓存挂了的概率远低于数据库被冲垮的代价。
flowchart TD
Q[FindById] --> C{"缓存命中?"}
C -- 是 --> R[直接返回]
C -- 否/出错 --> D[查数据库]
D --> W[回写缓存]
W --> R小收口
UserCache 已经接进 Repository 层,Service 对"数据从哪来"完全无感知。但前面的代码一直用 cache.UserCache 这个接口,它到底长什么样?这里补上它的定义与唯一实现,这也是第 06 章"缓存三大问题"的起点。
定义 UserCache 接口与 Redis 实现
接口只暴露"存什么、取什么",把 key 拼装、TTL、序列化全关在里面:
// 文件路径: internal/repository/cache/user.go
package cache
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
// UserCache 业务专属缓存抽象,屏蔽 key 结构 / TTL / 序列化
type UserCache interface {
Get(ctx context.Context, id int64) (domain.User, error)
Set(ctx context.Context, u domain.User) error
}
// RedisUserCache 基于 Redis 的唯一实现
type RedisUserCache struct {
client redis.Cmdable // 接口,便于测试时替换
keyPrefix string // 如 "user:info:"
expiration time.Duration // 如 15 分钟
}
func NewUserCache(client redis.Cmdable) *RedisUserCache {
return &RedisUserCache{
client: client,
keyPrefix: "user:info:",
expiration: 15 * time.Minute,
}
}
Get 负责拼 key、查 Redis、把 []byte 反序列化成结构体;查不到返回约定错误 ErrKeyNotExist,让上层决定 fallback 到数据库:
// 文件路径: internal/repository/cache/user.go(Get/Set 片段)
func (c *RedisUserCache) Get(ctx context.Context, id int64) (domain.User, error) {
key := c.keyPrefix + strconv.FormatInt(id, 10)
val, err := c.client.Get(ctx, key).Bytes()
if err == redis.Nil {
// 缓存里没有这个 key,返回约定错误,让 Repository 去查库
return domain.User{}, ErrKeyNotExist
}
if err != nil {
return domain.User{}, err // 真正的 Redis 故障也往上抛
}
var u domain.User
if err = json.Unmarshal(val, &u); err != nil {
return domain.User{}, err
}
return u, nil
}
func (c *RedisUserCache) Set(ctx context.Context, u domain.User) error {
key := c.keyPrefix + strconv.FormatInt(u.Id, 10)
val, err := json.Marshal(u)
if err != nil {
return err
}
// EX 设置过期时间,避免缓存永久不失效
return c.client.Set(ctx, key, val, c.expiration).Err()
}
这一步刻意只留一个 Redis 实现。将来要加"本地缓存"“Memcache"“本地+远程双重缓存”,只需新增实现这个接口、用 wire 切换即可——这正是 3.10 节"留接口"判准的第一个例子。第 06 章会在这个骨架之上,专门讲缓存与数据库不一致的三大经典问题(穿透 / 击穿 / 雪崩),届时你会看到
Set的过期时间和"先更新库再删缓存"如何配合。
这一节同时埋下了两个伏笔:① cache 是接口类型(上文已定义 UserCache 接口,便于测试替换);② dao/cache 从外部传入(这就是依赖注入,3.10 节展开)。
3.5 Redis 数据结构速览(为缓存打底)
先抛问题
前面我们一直用 Redis 的 string 存用户 JSON,但 Redis 远不止一种结构。选错结构,代码会又丑又慢。
给个直觉:工具箱里不止一把锤子
Redis 就像一个多格工具箱,每种数据结构是不同工具:你要存"一个值"用 string,要存"有序队列"用 list,要"去重集合"用 set……用对工具事半功倍。
最小可运行示例(五种结构对照)
| 类型 | 用途 | 本章/前文出现处 |
|---|---|---|
| string | 简单 KV,如缓存用户 JSON | 第 03 章用 string 存 Session;本章 UserCache |
| list | 链表,如消息队列 | 后续章节消息流 |
| set | 集合,如标签、去重 | 标签系统 |
| sorted set | 有序集合,如排行榜 | 第 09 章榜单 |
| hash | 字典,如存储对象多个字段 | 存储用户多字段 |
看完这张表,带走一个结论:选数据结构先看数据形态和访问模式——纯 KV 缓存用 string,有顺序的队列用 list,要去重用 set,要按分数排序用 sorted set,要按字段存取对象用 hash。不常用的还有 bitmaps、JSON、streams、bitfields、time series,用到再查。
常见坑:把本该用 hash 的对象塞进一个超长 string
小收口
性能优化主线至此闭环:压测 → 定位 DB 瓶颈 → 引入 UserCache → 接入 Repository → 再压测验证。你已经完成了本章第一条主线。接下来进入第二条主线:短信验证码登录的三层抽象,这会引出"面向接口编程"和"依赖注入"这两个贯穿全书的工程基本功。
3.6 短信验证码登录:从需求里识别"变化点”
先抛问题
除了账号密码,让用户也能用手机号收验证码登录。这个功能本身不复杂,但它是个绝佳的载体——能练出一套通用设计方法:怎么从模糊的需求里识别出"会变的部分",把它封装成一层层独立的抽象。
给个直觉:乐高积木
好的设计像乐高:每块积木(抽象层)只干一件事,拼错一块换一块,不会牵一发动全身。“会变的地方"就单独做成一块积木,将来换积木即可。
需求分析:功能 vs 非功能
业界做需求分析有两招:一是参考竞品(大部分功能都是"你抄我我抄你”);二是从功能 / 非功能两个角度分析,再从正常 / 异常流程两个角度思考。
把主流 App 的短信登录流程走几遍,会得到这份特性清单(注意:大部分是防护措施,因为短信是按条真金白银付费的):
- 发送验证码前通常要先用拼图验证,防止恶意触发短信
- 发送验证码后不能连续发送,需间隔一段时间
- 手机号第一次使用时直接注册新账号
- 验证码有有效期(10 分钟)
- 不同业务的验证码互相独立(A 业务触发的,B 业务可立即触发新的)
- 验证码只能使用一次
功能需求:手机号收验证码登录,新号码直接注册;验证码有效期 10 分钟;同一手机号 1 分钟内只能发 1 次;登录成功跳转首页。 非功能需求:保护系统,防止攻击者恶意发短信。
服务划分:为什么要三层?
“手机验证码登录"包含两件事:验证码和登录。它们强耦合吗?别的业务(比如"找回密码”)也会用验证码吗?会。所以验证码应该是独立功能。再进一步,验证码通过短信发送,短信会不会换供应商?会。于是有两个变化点:
- 不同业务都要用短信和验证码(一层复用);
- 可能换短信供应商(另一层复用)。
这就是"业务上的超前半步设计"——只留出接口,不提前实现。把每个变化点关进各自的笼子,换供应商时不用动登录代码。
flowchart TD
L[登录服务] --> C[验证码服务]
C --> S[短信服务]
S --> T[(腾讯/阿里/本地
短信供应商)]小收口
我们识别出了两个变化点,自然引出三层抽象:短信服务 → 验证码服务 → 登录功能。下一节从最底层"短信服务"的接口开始落地,你会看到"面向接口编程"在这里怎么兑现价值。
3.7 短信服务接口与验证码服务
先抛问题
三层里最先写哪层?从最底层、最稳定的"短信发送"接口开始——因为上层都要依赖它。这个接口怎么定,决定了换供应商时改不改代码。
给个直觉:遥控器接口
短信服务接口就像电视的遥控器标准(红外 / 蓝牙协议)。不管你换索尼还是小米电视,只要符合这个协议,上层(验证码服务)拿着遥控器就能用,它根本不在乎底下是哪台电视。
最小可运行示例:短信服务接口
从特定供应商(腾讯)的 API 抽象出接口。腾讯要求初始化时传入鉴权参数(SecretId、SecretKey),发送时传入手机号、appId、签名、模板 ID、模板参数。其中 appId 和签名在我们系统里固定,可以在初始化时指定,发送时不再传递:
// 文件路径: internal/service/sms/service.go
package sms
import "context"
// Service 是短信服务的抽象接口
// 任何短信供应商实现这个接口,都能被注入到验证码服务中
type Service interface {
// Send 发送一条短信
// ctx: 用于超时与取消
// tplId: 短信模板 ID(需在服务商后台提前配置)
// args: 模板参数,如验证码本身
// numbers: 目标手机号列表(支持群发)
Send(ctx context.Context, tplId string, args []string, numbers ...string) error
}
腾讯云实现:持有 SDK 客户端与固定的 appId/签名,把 Send 参数翻译成腾讯云的请求结构发出去。注意腾讯云即使整体调用成功,单个手机号仍可能失败,所以不能只看返回的 err:
// 文件路径: internal/service/sms/tencent/service.go
package tencent
import (
"context"
"fmt"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
)
// Service 腾讯云短信实现
type Service struct {
appId string // 应用 ID
signName string // 签名
client *sms.Client // 腾讯 SDK 客户端,依赖注入
}
// NewService 通过依赖注入构造,要求外界传入 client 而不是自己初始化
func NewService(client *sms.Client, appId, signName string) *Service {
return &Service{client: client, appId: appId, signName: signName}
}
func (s *Service) Send(ctx context.Context, tplId string,
args []string, numbers ...string) error {
req := sms.NewSendSmsRequest()
req.SmsSdkAppId = &s.appId
req.SignName = &s.signName
req.TemplateId = &tplId
req.PhoneNumberSet = toStringPtrSlice(numbers)
req.TemplateParamSet = toStringPtrSlice(args)
resp, err := s.client.SendSmsWithContext(ctx, req)
if err != nil {
return fmt.Errorf("腾讯短信发送失败: %w", err)
}
for _, status := range resp.Response.SendStatusSet {
if status.Code != nil && *status.Code != "Ok" {
return fmt.Errorf("发送失败 %s: %s", *status.PhoneNumber, *status.Message)
}
}
return nil
}
本地实现用于调试——验证码直接打到控制台,不发真短信:
// 文件路径: internal/service/sms/localsms/service.go
package localsms
import (
"context"
"fmt"
)
// Service 在控制台打印验证码,方便本地调试
type Service struct{}
func NewService() *Service { return &Service{} }
func (s *Service) Send(ctx context.Context, tplId string,
args []string, numbers ...string) error {
fmt.Printf("收到验证码: %s, 手机号: %v\n", args, numbers)
return nil
}
NewService 里自己初始化 client,但那样测试时无法替换成 mock。依赖注入是工程化的基本功——后面 3.10 节会专门讲透。验证码服务接口
验证码是 6 位数字,只有 100 万种可能,所以有两个安全要求:① 控制发送频率(1 分钟 1 次、有效期 10 分钟);② 防暴力破解(验证通过立即失效、3 次失败作废,但只提示"验证码不对"避免信息泄露)。Send 和 Verify 都带 biz 参数——正是"不同业务的验证码互相独立"的落地:
// 文件路径: internal/service/code.go
package service
// CodeService 验证码服务接口
type CodeService interface {
// Send 根据 biz(业务标识)和手机号发送验证码
Send(ctx context.Context, biz, phone string) error
// Verify 校验验证码
Verify(ctx context.Context, biz, phone, inputCode string) (bool, error)
}
还欠一个"发送渠道"的抽象:验证码可以走短信、邮件、语音、站内信。按"超前设计、不超前实现"原则,等将来真有需求再抽象——现在就抽是过度设计。
并发问题:为什么不能用普通 Go 代码
“查验证码 → 写验证码"是典型的 check-then-act 问题。伪代码如果写成"先查有没有、再写新的”,分布式下两个请求会同时查到"没有",然后都写,“频率控制"就失效了:
// 错误示范:有并发问题
func (s *CodeService) Send(ctx context.Context, biz, phone string) error {
code, ttl := s.cache.Get(ctx, biz, phone)
if code != "" && ttl > 9*time.Minute {
return errors.New("发送太频繁")
}
newCode := generateCode()
return s.cache.Set(ctx, biz, phone, newCode, 10*time.Minute) // 并发下会被覆盖
}
Send 加 sync.Mutex,但多实例部署时每个进程各有一把锁,根本锁不住共享的 Redis。原子性要下沉到 Redis 本身的 Lua 脚本(或 SET NX)。解决方案:Redis Lua 脚本。Lua 是轻量嵌入式脚本语言,Redis 内置解释器,允许把"检查 + 写入"多步操作写成一个脚本发给 Redis。Redis 单线程执行 Lua 期间不会被其他命令打断,天然原子。
-- 文件路径: scripts/lua/set_code.lua
-- KEYS[1] = key 格式 phone_code:$biz:$phone
-- ARGV[1] = 验证码, ARGV[2] = 过期时间(秒)
local key = KEYS[1]
local val = redis.call('GET', key)
if val then
local ttl = redis.call('TTL', key)
if ttl > 540 then -- 540 秒 = 9 分钟,说明还没过 1 分钟,拒绝
return -1
end
end
redis.call('SET', key, ARGV[1]..'&'..'0', 'EX', ARGV[2])
return 0
Verify 同样不能"先读再写”(失败次数 +1 在并发下会丢),也是一段 Lua:
-- 文件路径: scripts/lua/verify_code.lua
-- Lua 没有内置 split,自己把 "验证码&失败次数" 拆成两段
local function split(s, sep)
local pos = string.find(s, sep)
return string.sub(s, 1, pos - 1), string.sub(s, pos + 1)
end
local key = KEYS[1]
local input = ARGV[1]
local val = redis.call('GET', key)
if not val then
return -1 -- 验证码不存在
end
local code, cnt = split(val, '&')
if tonumber(cnt) >= 3 then
return -2 -- 失败次数已超 3 次,作废
end
if code == input then
redis.call('DEL', key) -- 验证通过,删除防重放
return 0
end
cnt = tonumber(cnt) + 1 -- 验证失败,失败次数 +1
redis.call('SET', key, code..'&'..cnt, 'EX', redis.call('TTL', key))
return -3
sequenceDiagram
participant A as 请求A
participant B as 请求B
participant R as Redis
A->>R: 查验证码(不存在)
B->>R: 查验证码(不存在)
A->>R: 写新验证码(原子)
B->>R: 写新验证码(原子,被频率规则拒绝)
Note over A,B: Lua 保证原子,频率控制不失效用户验证码登录:FindOrCreate 的并发
“查找-创建"典型不一致场景:两人同时用同手机号注册,可能同时查到"不存在"然后同时创建。解决方案:给手机号加唯一索引,并发插入只有一个成功。
// 文件路径: internal/repository/user.go(FindOrCreate 片段)
func (r *CachedUserRepository) FindOrCreate(ctx context.Context, phone string) (domain.User, error) {
u, err := r.dao.FindByPhone(ctx, phone)
if err != ErrUserNotFound {
return u.ToDomain(), err
}
// 不存在,尝试创建
err = r.dao.Insert(ctx, dao.User{Phone: sql.NullString{String: phone, Valid: true}})
if err == nil {
return r.dao.FindByPhone(ctx, phone)
}
// 创建失败,可能是并发唯一索引冲突,再查一次
return r.dao.FindByPhone(ctx, phone)
}
这套"先查、没有就插、冲突再查"是利用唯一索引处理并发不一致的经典套路,以后遇到"查找-创建"场景可直接借鉴。
小收口
三层抽象已经具备雏形:短信接口(腾讯 + 本地两套实现)、验证码服务(含 Lua 原子脚本)、登录逻辑(FindOrCreate)。它们能"随换随用"靠的是下一节要讲的面向接口编程 + 依赖注入——其实前两章你一直在隐式使用它们,这一节把它们摆到台面上讲透。
3.8 测试基础:先分清楚两种粒度
先抛问题
之前你写的代码从没跑过测试。现在要补。但"测试"两个字太宽泛——测一个函数?还是测整个链路?先分清楚,才知道每种怎么写。
给个直觉:验零件 vs 验整车
- 单元测试 = 验零件:单独拿一个螺丝钉(一个函数),看它合不合格。快、便宜、不依赖外部。
- 集成测试 = 验整车:把引擎、变速箱、轮子组装起来,看车能不能跑。慢、贵、依赖真实零件。
补课胶囊 · 测试的两种粒度
一句话:单元测试盯"每个零件合不合格”(不连数据库),集成测试盯"零件组装起来能不能转"(连真实中间件)。
类比:单元测试像"单个零件质检报告";集成测试像"整车路试"。
最小示例(Go 测试三条铁律):
// 文件路径: internal/service/xxx_test.go package service import "testing" // 函数名必须以 Test 开头,参数是 *testing.T func TestAdd(t *testing.T) { if add(1, 2) != 3 { t.Fatal("add 算错了") } }go test ./... # 运行所有子包的测试回到主线:下面我们看为什么测试"不能真连数据库、真发短信",以及 mock 是什么。
单元测试 vs 集成测试五个维度对比:
| 维度 | 单元测试 | 集成测试 |
|---|---|---|
| 范围 | 单个方法 | 多个组件协作 |
| 依赖 | 不能依赖第三方(MySQL/Redis) | 可以依赖 |
| 速度 | 极快,毫秒级 | 较慢 |
| 用例设计依据 | 实现逻辑(分支覆盖) | 业务流程 |
| 修复速度 | 快速发现、快速修复 | 较慢 |
测试金字塔(了解即可):底层是大量单元测试(又快又多),中间是较少集成测试,顶层是极少端到端测试。不要反过来——端到端测试又慢又脆,堆多了团队会被测试拖垮。
Go 单元测试基础
三条硬性约定(不满足,测试就不会被发现和执行):
- 文件名以
_test.go结尾; - 测试方法以
Test开头; - 方法签名
func TestXxx(t *testing.T)。
go test your_test.go # 单文件(少用)
go test . # 当前包
go test ./... # 所有子包(日常用这个)
go test your_test.go 报 undefined——单跑一个测试文件必须把同包被引用的源文件一起列上(如 go test user_test.go user.go)。日常用 go test . 或 go test ./... 即可,单文件形式基本不用。Table Driven(表驱动)模式
Go 惯用法:把测试用例像表格一样组织,一个函数跑 N 个用例。
// 文件路径: internal/web/user_test.go(表驱动骨架)
func TestSignup(t *testing.T) {
testCases := []struct {
name string
reqBody string
wantCode int
wantRes string
}{
{
name: "注册成功",
reqBody: `{"email":"test@test.com","password":"123456","confirmPassword":"123456"}`,
wantCode: 200,
wantRes: "注册成功",
},
// ... 更多用例
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// 每个用例独立运行,失败只标红这一个
// (具体构造请求见 3.9 节 Handler 测试)
})
}
}
测试金字塔:你的测试预算该怎么花
了解即可:测试界有个经典模型叫"测试金字塔"——底层是大量的单元测试,中间是较少的集成测试,顶层是极少的端到端(E2E)测试。它告诉我们:又快又多的单测是主力,又慢又脆的 E2E 是点缀。
直觉也符合常识:单元测试毫秒级、不依赖外部、随写随跑,所以可以写很多、跑很勤;集成测试要起 Redis/MySQL,慢一些、写得少一些;端到端测试要起整个系统、还可能要登录点界面,又慢又容易因环境波动而挂,所以只能挑最关键的主链路写几条。
flowchart TD
E2E["顶层: 端到端测试
少而精, 慢而脆"] --> IT["中层: 集成测试
验证组件协作"]
IT --> UT["底层: 单元测试
多而快, 不依赖外部"]反模式是"倒金字塔"——团队偷懒只写 E2E,结果每次跑测试要半小时、还动不动挂,最后没人愿意跑测试。本章重点放在单测(mock 各层)和少量集成测试(连真实 Redis),正是遵循金字塔。
小收口
你现在知道"测什么、怎么组织用例"。但有个关键障碍:单测"不能依赖第三方",可你的 Service 依赖 Repository、Repository 依赖 Redis——怎么在不连真数据库的情况下测 Service?答案就是下一节的 mock / 打桩。
3.9 各层测试与 mock / 打桩
先抛问题
要测 UserService.Login,它内部会调 UserRepository.FindByEmail 去查数据库。如果我们真连数据库,测试就又慢又难准备数据,而且数据库挂了测试也挂——这违背了"单测不依赖第三方"的原则。怎么办?
给个直觉:替身演员
拍武打戏时,明星不真从 20 楼跳下,而是用替身演这段。测试里,我们也给依赖安排一个"替身":它长得(接口)一模一样,但内部是写死的假实现——你让它返回什么它就返回什么。这个替身就是 mock(模拟对象),制作替身的过程叫打桩(stub)。
补课胶囊 · mock 与打桩
一句话:mock = 用一个"假的实现"替换真实的依赖(如数据库),让单测不依赖外部环境也能跑。
类比:餐厅后厨测试"服务员上菜流程"时,用一碗假菜(mock)代替真做的菜,只验证"服务员会不会端、会不会报菜名",不关心菜好吃不。
最小示例(用
uber-go/mock生成一个接口的 mock):go install go.uber.org/mock/mockgen@latest mockgen -source=./internal/service/user.go \ -package=svcmocks \ -destination=./internal/service/mocks/user.mock.go回到主线:有了 mock,我们就能给 Handler / Service / Repository / Cache / DAO 每一层写单测了。
用 mockgen 生成 mock
Go 团队原 golang/mock 已停止维护,改用 uber-go/mock:
go install go.uber.org/mock/mockgen@latest
为接口生成 mock 实现:
mockgen -source=./internal/service/user.go \
-package=svcmocks \
-destination=./internal/service/mocks/user.mock.go
使用 mock 的固定套路
// 文件路径: internal/service/user_test.go(mock 使用骨架)
func TestLogin(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repo := repomocks.NewMockUserRepository(ctrl)
// 期望 FindByEmail 被调用一次,返回指定用户
repo.EXPECT().FindByEmail(gomock.Any(), "test@test.com").
Return(domain.User{Email: "test@test.com", Password: "$2a$10$xxxxx"}, nil)
svc := NewUserService(repo)
// ... 调用 svc.Login 并断言
}
重要原则:设计了几个 mock 调用,使用时就要全部用上,顺序也要对上!不能多、不能少、不能乱。 后续但凡 mock 报错,就检查多了没、少了没、漏了没、条件对上了没。
各层怎么测
Handler(用 httptest):用标准库 httptest 造请求、收响应,不用真走网络。
// 文件路径: internal/web/user_test.go(Handler 测试关键片段)
req := httptest.NewRequest(http.MethodPost, "/users/signup",
strings.NewReader(`{"email":"x@y.com"}`))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, req) // 等价于从网络收到请求
assert.Equal(t, 200, recorder.Code)
Service:给依赖的 Repository 生成 mock,针对不同分支(登录成功 / 用户不存在 / 密码错)设定不同返回值。
Repository:同时依赖 Cache 和 DAO,所以要 mock 两个——先让缓存未命中,再让 DAO 返回用户,再期望回写缓存。
// 文件路径: internal/repository/user_test.go(Repository 测试片段)
c := cachemocks.NewMockUserCache(ctrl)
d := daomocks.NewMockUserDAO(ctrl)
c.EXPECT().Get(gomock.Any(), int64(123)).Return(domain.User{}, cache.ErrKeyNotExist)
d.EXPECT().FindById(gomock.Any(), int64(123)).Return(dao.User{Id: 123}, nil)
c.EXPECT().Set(gomock.Any(), domain.User{Id: 123}).Return(nil)
UnixMilli(),否则 want 和 got 永远差几百纳秒,测试随机挂。Cache(mock redis.Cmdable):redis.Cmdable 本身是接口,可直接 mock:
mockgen -package=redismocks \
-destination=./internal/repository/cache/redismocks/cmd.mock.go \
github.com/redis/go-redis/v9 Cmdable
DAO(用 sqlmock):GORM 没接口,无法直接 mock,用 sqlmock 拦截 SQL:
// 文件路径: internal/dao/user_test.go(sqlmock 片段)
db, mock, _ := sqlmock.New()
gdb, _ := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true, // 不执行 show version
}), &gorm.Config{
DisableAutomaticPing: true, // 不 ping 数据库
SkipDefaultTransaction: true, // 不自动开事务
})
mock.ExpectExec("INSERT INTO `users`").
WithArgs("x@y.com", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
为什么要设这三个参数:它们禁用 GORM 的"隐藏调用",否则 GORM 初始化会偷偷执行 show version、ping、包事务,而 sqlmock 没设对应期望就报错。禁用后只执行你显式写的 SQL。
异常流程用例怎么设计
正常流程跑绿之后,复制粘贴改一改就是异常用例。以注册接口为例,能数出的分支有:Bind 出错、email 格式不对、两次密码不一致、邮箱冲突、系统错误。注意"Bind 出错"时根本不会调用 Service,所以 mock 返回空即可:
{
name: "Bind 出错",
mock: func(ctrl *gomock.Controller) (service.UserService, service.CodeService) {
return svcmocks.NewMockUserService(ctrl), svcmocks.NewMockCodeService(ctrl)
},
reqBody: `{"email": bad json`,
wantCode: 400,
wantRes: "",
},
bcrypt.Generate 只有超时才返回 error 的分支基本测不了;emailRegexp.MatchString 返回 error 的分支也测不了(包级变量没法 mock)。遇到测不了的分支不用硬凑,记进账:没测到的代码必须认真 review。小收口
各层测试套路总结:Handler 用 httptest、Service/Repository 用 gomock、Cache 用 redis mock、DAO 用 sqlmock。这一切能成立的前提,是"依赖以接口形式从外部传入"——这就引出下一节的核心:面向接口编程 + 依赖注入。
3.10 面向接口编程与依赖注入(DI / IoC)
先抛问题
前面所有 mock 的前提是:你的 Service 依赖的是一个接口(UserRepository),而不是具体结构体。如果你写死 repo := &UserRepository{},测试时就没法换成 mock。所以"面向接口编程"和"依赖注入"是测试能成立的根基。
给个直觉:点外卖 vs 自己做饭
依赖注入(DI)的类比:
- 自己做饭(非依赖注入)= 你想吃番茄炒蛋,得自己种番茄、养鸡、生火、炒菜。Repository 自己
new数据库、自己new缓存——它"知道"太多,耦合到死,换家超市(换数据库)整道菜重做。- 点外卖(依赖注入)= 你只说"我要一份番茄炒蛋",外卖员(外部)把做好的菜送上门。Repository 只声明"我需要一个
UserDAO和一个UserCache",至于它们哪来的、怎么造的,一概不关心。想换 implementation(mock / 腾讯 / 阿里),外面换一家配送就行。控制反转(IoC):原本"自己掌控一切"的控制权,反转给了"外部容器/调用方"。
补课胶囊 · 依赖注入与控制反转
一句话:依赖注入 = 依赖从外面传进来,而不是自己
new;控制反转 = 这种"控制权交给外部"的设计思想总称。类比:见上方"点外卖 vs 自己做饭"。
最小示例:
// 非依赖注入:Repository 自己初始化依赖,深度耦合 func NewUserRepository(cfg Config) *UserRepository { db := initDB(cfg.DB) // 自己连库 redis := initRedis(cfg.Redis) // 自己连缓存 return &UserRepository{ dao: dao.NewUserDAO(db), cache: cache.NewUserCache(redis), } } // 依赖注入:依赖从外面传入 func NewUserRepository(dao dao.UserDAO, cache cache.UserCache) *UserRepository { return &UserRepository{dao: dao, cache: cache} // 只持有,不负责造 }回到主线:非依赖注入的缺点里最致命的是"测试不友好"——想单测 Repository 时,无法把 DAO 换成假实现,只能连真数据库。而 mock 的前提恰恰是"依赖以接口形式从外部传入"。
非依赖注入的缺点:深度耦合依赖的初始化过程;需定义额外 Config 类型;依赖一变配置就得改;缺乏扩展性;测试不友好(无法 mock);难以复用公共组件。
面向接口编程:组件间的通信必须通过接口。A 调用 B 时,B 必须是接口类型:
// 非面向接口:直接依赖具体结构体,难以替换
type CodeService struct {
cache *CodeCache
}
// 面向接口:依赖接口,可注入不同实现
type CodeService struct {
sms sms.Service // 接口,可注入腾讯/本地/阿里
}
为什么要面向接口? 为了扩展性——它本身不提高性能或可靠性,但让代码随时能切换实现:腾讯短信 → 阿里短信、Redis 缓存 → 本地缓存 → Memcache。
留不留接口的判断标准(这是真功夫,不是无脑全抽):
UserCache:将来可能有本地 / Memcache / 双重缓存实现 → 留接口;UserService:理论上不太可能有第二种实现,但 Handler 层测试要 mock 它 → 也留接口(揭示:可测试性本身就是留接口的理由);UserHandler:没人调用它(注册路由不算"用它")→ 暂时不留。
分寸感:核心逻辑一定面向接口编程,但只在"有变化可能"或"要 mock"的地方抽,不滥用。
3.11 wire:用代码生成组装依赖
先抛问题
手写依赖注入链很长、没技术含量:先 initDB,再 NewUserDAO,再 NewUserCache,再 NewUserRepository……一层层套。有没有工具能自动把这些"构造函数"连起来?
给个直觉:自动拼装流水线
wire 像一个"自动拼装机器人":你告诉它有哪些零件(构造函数)和最终要什么产品(*App),它按照类型自动把零件拼好,生成一份普通的 Go 代码(wire_gen.go)。
补课胶囊 · wire 是什么
一句话:wire 是 Google 出的依赖注入代码生成器——编译前根据类型把构造函数连起来,生成普通 Go 代码,而不是运行时用反射去"猜"。
类比:运行时反射像"厨师现看菜谱现做"(慢、易错);代码生成像"工厂提前把装配图印好"(快、出错编译期就能发现)。
最小示例(写 injector,再跑
wire):// 文件路径: wire.go //go:build wireinject package main import "github.com/google/wire" func InitApp() *App { wire.Build( ioc.InitDB, ioc.InitRedis, dao.NewUserDAO, cache.NewUserCache, repo.NewUserRepository, service.NewUserService, web.NewUserHandler, ioc.InitWebServer, ) return nil }go install github.com/google/wire/cmd/wire@latest wire # 生成 wire_gen.go回到主线:wire 生成的
wire_gen.go就是普通 Go 代码,出问题能直接打开读,比运行时容器框架轻得多。
wire 的局限(它是纯静态生成器,不理解运行期概念):
- 不支持根据环境使用不同实现(本地用本地短信、线上用腾讯,要自己写
InitSmsService切换); - 不能根据接口查找实现;
- 不能根据类型查找所有实例(注册路由要手写)。
wire 报 command not found——把 $GOPATH/bin 加进 PATH;② wire 不支持单例——InitApp 被调用多次,每次都重新 InitDB 生成全新 *gorm.DB。需要"全局只有一个"时,自己保证只调用一次初始化入口。IoC 全景:依赖注入是 IoC 的一种实现,另一种是依赖查找(Dependency Lookup,调用方主动从容器查,如 service locator)。wire 属于"外部主动把依赖传进来"的注入式 IoC。
超前设计,最小化实现:这两个词成对出现——识别变化点、留出接口(超前设计),但只提供当下需要的实现(不超前实现)。本章短信服务只抽接口、只写腾讯和本地两个实现,“发送渠道"抽象留到将来。抽象不是免费的,多一层接口多一层维护成本,为不存在的需求预付就是过度设计。
3.12 第三方服务治理:装饰器与 failover
先抛问题
短信是"公司外的服务”,不在你控制范围内——它可能限流、可能超时、可能挂。怎么既保护它、又保证自己不被它拖垮?而且加这些保护时,不能把业务代码改得面目全非。
给个直觉:给水管套阀门
给短信发送"套一层阀门":限流阀(别打爆对方)、超时阀(别干等)、重试阀(偶尔失败再试)、熔断阀(对方挂了赶紧停)。关键是这些阀门像"套筒"一样一层层套上去,里面的水管(真正发送)一行都不用改。
装饰器模式(无侵入叠加)
把 Gin 的限流逻辑抽成通用限流器,再用装饰器包在真正的短信服务外面:
// 文件路径: internal/service/sms/ratelimit_service.go
type RatelimitSMSService struct {
svc Service // 被装饰者,依赖接口
limiter Limiter
}
func NewRatelimitSMSService(svc Service, limiter Limiter) *RatelimitSMSService {
return &RatelimitSMSService{svc: svc, limiter: limiter}
}
func (r *RatelimitSMSService) Send(ctx context.Context, tplId string,
args []string, numbers ...string) error {
limited, err := r.limiter.Limit(ctx, "sms:tencent")
if err != nil {
return fmt.Errorf("限流器异常: %w", err)
}
if limited {
return errors.New("触发了限流")
}
return r.svc.Send(ctx, tplId, args, numbers...) // 转交被装饰者
}
装饰器可以层层叠加:限流 → 超时 → 重试 → 真正发送:
svc := tencent.NewService(client, appId, sign)
svc = NewTimeoutSMSService(svc, 3*time.Second)
svc = NewRetrySMSService(svc, 3)
svc = NewRatelimitSMSService(svc, limiter)
Service 接口,调用方拿到后可直接 .Service.Send() 绕过限流/超时/重试,安全网形同虚设。生产用"只持有 svc 字段"的方式(方式二),强制所有调用都过装饰器。装饰器 vs 适配器:装饰器是"同一个接口上加新特性";适配器是"把 A 接口适配到 B 接口"(不同接口)。开闭原则(OCP):对修改闭合、对扩展开放——加限流就包一层,原 TencentService 一行不动。侵入式修改(把限流直接塞进 Send)是屎山之源。
failover 自动切换:服务商真挂了怎么办?准备一组实现,第一个失败换下一个;更进一步用"起点动态轮询 + 区分错误"(超时/取消类错误换一家也没用,直接返回),并用连续超时计数判定服务商健康状态、临时摘除、试探法恢复。
// 文件路径: internal/service/sms/failover_service.go(健康检查版骨架)
type TimeoutFailoverSMSService struct {
mu sync.Mutex // 保护 primary/secondary 的切换,避免并发重复交换
primary Service
secondary Service
cnt int64
threshold int64
}
func (t *TimeoutFailoverSMSService) Send(ctx context.Context, tplId string,
args []string, numbers ...string) error {
err := t.primary.Send(ctx, tplId, args, numbers...)
if !errors.Is(err, context.DeadlineExceeded) {
atomic.StoreInt64(&t.cnt, 0)
return err
}
cnt := atomic.AddInt64(&t.cnt, 1)
if cnt > t.threshold {
t.mu.Lock()
// 二次判断:加锁间隙可能已被其它 goroutine 切换并清零,避免"换两次等于没换"
if atomic.LoadInt64(&t.cnt) > t.threshold {
t.primary, t.secondary = t.secondary, t.primary // 主力/备用互换
atomic.StoreInt64(&t.cnt, 0)
}
t.mu.Unlock()
}
return err
}
并发代码没法通过 mock 判断"是不是并发安全",只能验证"没有并发问题时结果符合预期"——并发安全更大程度靠 code review 保证(呼应 3.9 节:测不到的必须认真 review)。
内部鉴权与成本控制
在大公司,短信这种要花钱的资源必须管起来,一般通过审批流程申请模板(tpl),要求业务方对自己的用量做评估,然后:
- 限定使用者:只有申请了这个 tpl 的特定业务方才能用它发短信;
- 容量控制:这个 tpl 一个月就 10 万条,超了要额外申请——达成成本控制。
怎么做到"特定业务方只能用特定资源"?用 token。 内部调用不用太复杂,静态 token 就够;进一步可以用 JWT token,两种方案:
- 在
Send方法里加一个token参数,服务端校验这个 token 有没有权限用这个 tpl; - 直接用 token 替换掉
tpl参数(推荐)——业务方申请资源时你颁发一个 JWT,里面塞了 tplId、业务方标识、配额等信息,发送时所需数据全部从 token 里解出来。
// 内部调用版接口:用 token 换掉 tplId
type Service interface {
// token 是业务方申请资源时颁发的 JWT,里面带了 tplId、业务方、配额等
Send(ctx context.Context, token string, args []string, numbers ...string) error
}
推荐第二种:业务方拿到的 token 本身就绑定了它能用的资源,认 token 不认人;还可以进一步提供动态 token 服务:给业务方颁发 appId + appSecret,它每次先用这对密钥交换一个临时 token 再发短信。
面试实战:把短信服务讲成高可用项目
本章内容可以在面试里包装成一个完整的项目经历(适合 P6 及以下岗位)。开场白先介绍架构和功能,埋下"可用性"这个鱼饵,引导面试官来问:
我在公司负责的一个核心模块是短信服务,分短信服务本身和管理后台两部分。短信服务负责发短信;管理后台提供短信的增删改查、手动补发/单发/群发、定时发送,以及按运营筛选圈目标用户发营销短信。公司相当多业务依赖它(如二次验证的手机验证码),所以维护核心目标是保障可用性。
面试官追问"你做了什么保证可用性"时,分条作答(每条都对应本节内容):
- 保证发送成功:收到请求立刻落库,随即尝试发送;发送失败有异步补偿——定时扫描未发送成功的记录补发。
- 去重:用
biz_type + biz_id唯一索引去重,收到重复请求查是否已发送成功,避免重复发送。 - 重试策略:允许不同业务方使用不同策略(是否跨进程、间隔、次数可配);最终失败后业务方可通过消息队列订阅并配告警。
- 平台容错:平台负载高时降级——同步转异步,先回"已接收",业务方通过查询/监听得知结果;平台自身限流触发的请求同样转异步。
- 服务商容错:客户端限流 + 轮询 failover + 基于健康状态的 failover(连续超时判定、临时标记、试探恢复)。
- 鉴权:业务方带从管理后台申请的 token(JWT 方案),“认 token 不认人”。
biz_type + biz_id 唯一索引去重解决)、部分短信无法实时发送(同步转异步的代价),说得出代价才显得真做过;② 回查机制(类似事务消息:先发"准备短信",业务方执行成功才真正发、失败则回滚;业务方失联时反查它的接口)难度高、场景受限,面试中谨慎主动提,容易被追问到分布式事务深水区。小收口
第二条、第三条主线也都闭环了:短信三层抽象落地(3.6-3.7),面向接口 + 依赖注入 + wire 串起整套依赖(3.10-3.11),各层测试与第三方治理讲清(3.8-3.12)。接下来第 4 节,我们把这些真正写进项目、一步一步跑通。
四、跟着写:分步实现
这一节把手写进项目。每一步结束时项目都能编译、能跑、能验证。最终你会得到一个"带缓存 + 短信登录 + 测试"的 webook。
步骤 1:新增短信服务接口与两套实现
目标:定义 sms.Service 接口,写出腾讯云与本地两套实现。
// 文件路径: internal/service/sms/service.go
package sms
import "context"
type Service interface {
Send(ctx context.Context, tplId string, args []string, numbers ...string) error
}
本地实现(调试用):
// 文件路径: internal/service/sms/localsms/service.go
package localsms
import "context"
type Service struct{}
func NewService() *Service { return &Service{} }
func (s *Service) Send(ctx context.Context, tplId string,
args []string, numbers ...string) error {
return nil // 真实实现里打印验证码,这里骨架略
}
验证命令:
go build ./...
预期输出:无报错即编译通过(此时还没被使用,仅编译)。
步骤 2:新增验证码服务(含 Lua 原子脚本)
目标:定义 CodeService 接口,写出发送/校验,频率控制下沉到 Redis Lua。
// 文件路径: internal/service/code.go
package service
type CodeService interface {
Send(ctx context.Context, biz, phone string) error
Verify(ctx context.Context, biz, phone, inputCode string) (bool, error)
}
把 3.7 节的 set_code.lua / verify_code.lua 放到 scripts/lua/,由 CodeRedisCache 用 client.Eval 调用。
验证命令:
ls scripts/lua/ && go build ./...
预期输出:两个 lua 文件存在,且 go build 无报错。
步骤 3:把短信与验证码注入 UserHandler,加路由
目标:在 Handler 里聚合 CodeService 和 UserService,新增"发送验证码"“验证码登录"两个路由。
// 文件路径: internal/web/user_handler.go(构造与路由片段)
func NewUserHandler(userSvc service.UserService, codeSvc service.CodeService) *UserHandler {
return &UserHandler{userSvc: userSvc, codeSvc: codeSvc}
}
func (h *UserHandler) RegisterRoutes(server *gin.Engine) {
// ... 原有路由
server.POST("/users/code/send", h.SendCode)
server.POST("/users/code/login", h.CodeLogin)
}
这里直接在 Handler 聚合就够——还没复杂到需要单独的"用户聚合服务”。又是"超前设计、不超前实现"的例子。
验证命令:
go run main.go # 启动后
curl -X POST http://localhost:8080/users/code/send \
-d '{"phone":"13800000000"}' -H "Content-Type: application/json"
预期输出(本地实现):终端打印 收到验证码: [...] 类信息,接口返回成功。
步骤 4:用 wire 串起全部依赖
目标:写 wire.go,让 wire 自动生成 wire_gen.go。
// 文件路径: wire.go
//go:build wireinject
package main
import "github.com/google/wire"
func InitApp() *App {
wire.Build(
ioc.InitDB, ioc.InitRedis,
dao.NewUserDAO, cache.NewUserCache,
repo.NewUserRepository,
service.NewUserService, service.NewCodeService,
smslocals.NewService,
web.NewUserHandler, ioc.InitWebServer,
)
return nil
}
验证命令:
wire
go build ./...
预期输出:生成 wire_gen.go,go build 无报错。InitApp() 现在返回一个已接好所有依赖的 *App。
步骤 5:写第一个表驱动单测
目标:给 CodeService.Send 写表驱动单测(mock 掉 SMS 与缓存)。
// 文件路径: internal/service/code_test.go(骨架)
func TestCodeService_Send(t *testing.T) {
testCases := []struct {
name string
phone string
wantErr error
}{
{name: "发送成功", phone: "13800000000", wantErr: nil},
{name: "发送太频繁", phone: "13800000001", wantErr: ErrCodeSendTooMany},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 构造 mock 的 cache 与 sms,调用 svc.Send 并断言
})
}
}
验证命令:
go test ./internal/service/...
预期输出:
ok webook/internal/service X.XXXs
步骤 6:写一条集成测试(连真实 Redis)
目标:以"发送验证码"为例,连真实 Redis,验证发送成功 / 发送太频繁 / 未知错误三个用例。
// 文件路径: internal/service/code_integration_test.go(骨架)
func TestCodeSend(t *testing.T) {
testCases := []struct {
name string
before func(t *testing.T) // 准备数据(如预置一个刚发过的验证码)
after func(t *testing.T) // 验证并清理
phone string
}{
{name: "发送成功", phone: "13800000000"},
{name: "发送太频繁", before: func(t *testing.T) {/* 预置 10 分钟验证码 */}},
{name: "未知错误", before: func(t *testing.T) {/* 塞入无 TTL 的 key */}},
}
// 遍历执行,每个用例 before/after 自管数据
}
验证命令(需本地有 Redis):
redis-server &
go test ./internal/service/... -run TestCodeSend
预期输出:三个用例均通过(前提 Redis 可达)。
before 自备现场、after 自清现场。步骤 7:用装饰器给短信加限流
目标:用 3.12 节的 RatelimitSMSService 包一层限流,配置腾讯短信 3000/s 阈值。
// 在 InitSmsService 里组装
svc := tencent.NewService(client, appId, sign)
svc = NewRatelimitSMSService(svc, limiter) // 无侵入叠加限流
验证命令:
go build ./... && go test ./...
预期输出:编译通过、全部测试仍绿。
本章完成后的项目结构(示意)
webook/
├── main.go
├── wire.go
├── wire_gen.go
├── scripts/lua/{set_code,verify_code}.lua
└── internal/
├── dao/ # GORM 实现
├── repository/ # CachedUserRepository(含 UserCache)
├── service/
│ ├── sms/ # Service 接口 + tencent / localsms 实现 + 装饰器
│ ├── code.go # CodeService 接口
│ └── user.go # UserService
├── web/ # Gin Handler
└── ioc/ # 初始化 DB/Redis/WebServer
验证清单(可勾选)
-
go build ./...无报错 - 用
wrk压过登录/Profile,拿到优化前基线数据 -
UserCache已接入FindById,再压测读接口更快 -
sms.Service有腾讯 + 本地两套实现,本地能真打出验证码 -
CodeService频率控制走 Redis Lua,集成测试覆盖成功/太频繁/未知错误 -
wire生成wire_gen.go,InitApp能返回装配好的 App - 至少一组表驱动单测 + 一组集成测试,
go test ./...全绿
五、工程实践要点
这一节把本章散落的生产经验收敛成"可直接照做"的清单。
5.1 性能优化
- 先压测,再优化,不要凭感觉。没有数据支撑的优化是赌运气。
- 性能瓶颈通常在两类:加密算法(CPU,如
bcrypt)、数据库查询(IO)。 - 缓存只放在高频读场景(如 Profile),登录这种低频操作别加。
- 缓存出错也查 DB(业务可用优先)或缓存出错不查 DB(保护 DB 优先),视业务而定——中小项目默认前者。
5.2 接口与依赖
- 核心逻辑一定面向接口编程。但只在"有变化可能"或"要 mock"的地方抽,不滥用。
- 超前设计,最小化实现:留出接口,不写多余实现;抽象不是免费的。
- 依赖注入风格贯穿始终;
NewXXX在配合 wire 时最好返回接口(类型匹配更顺)。 - 分布式并发的 check-then-act 用 Redis Lua /
SET NX解决,别用应用层sync.Mutex。
5.3 测试
- 单元测试覆盖率目标 80% 以上(团队基线),优先用 Table-Driven。
- mock 调用三条铁律:不能多、不能少、不能乱(次序也要对上)。
- 没测到的代码(如
bcrypt超时分支、正则 error 分支)必须认真 code review。 - 集成测试用例之间绝对不能互相依赖,用 before/after 自管数据。
5.4 第三方治理
- 客户端限流(保护第三方,也保护自己)、超时(必须设,否则被慢响应拖垮)、重试(注意幂等)、熔断(防故障扩散)。
- 用装饰器模式无侵入叠加这些特性,严禁侵入业务代码(侵入式修改是屎山之源)。
- 服务商容错:轮询 failover + 基于健康状态的 failover(连续超时判定、临时摘除、试探法恢复)。
5.5 升职加薪视角:提高已有系统的单元测试覆盖率
为添加必要测试花费的精力完全值得:
- 联调很快(不用起一堆服务器、从界面点点点找 bug);
- 后续变更容易发现问题;
- 方便重构——这是测试最大的隐性收益。
别人启动各种服务器部署联调、从界面找 BUG 的时候,你已经下班回家了。
六、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 压测发现瓶颈通常在哪两类?为什么引入缓存要在 Repository 层而非 Service 层?
- 短信 / 验证码 / 登录为什么分成三层?分别对应的"变化点"是什么?
- 分布式环境下"查验证码 → 写验证码"为什么不能用
sync.Mutex解决?正确的原子方案是什么? - 依赖注入和非依赖注入的核心区别是什么?
wire解决什么痛点、又有什么局限? - 单元测试和集成测试的区别是什么?为什么测试"不能真连数据库、真发短信"?
- mock 是什么?
mockgen给一个接口生成 mock 后,使用时的三条铁律是什么? - 装饰器模式和适配器模式的区别是什么?为什么装饰短信服务不能"嵌入接口"?
- 为什么验证码的频率控制要用 Redis Lua 脚本而不是应用层代码?
wire是运行时反射还是代码生成?它和go generate是什么关系?- 服务商挂了有哪些 failover 策略?如何判断"服务商出问题了"?
动手练习(建议真做一遍):
- 加一层装饰器:在
RatelimitSMSService之外再写一个TimeoutSMSService(用context.WithTimeout),并补一个"超时返回 error"的单测。 - Redis Lua 单测:把"发送太频繁返回 -1"的 Lua 脚本用真实 Redis(或
miniredis)写集成测试,验证 1 分钟内第二次发送被拒。 - Table-Driven 补齐分支:给
CodeService.Verify写三个用例——验证通过(删 key)、失败 3 次作废、验证码不存在,跑绿并看覆盖率。 - 固化 mockgen 指令:在
sms.Service接口文件里加//go:generate mockgen ...,运行go generate ./...重新生成;再故意改接口签名、重新生成,对比 mock 变化。 - 体验 stringer:给一个
iota枚举加//go:generate stringer -type=Status,生成String()并在日志里打印枚举值,体会"生成代码也是代码"。
七、模拟面试问答
这一节模拟真实面试。每题按基础 / 进阶 / 追问三档,且每题固定四段:一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点。
一句话结论:压测是用数据暴露性能瓶颈、避免凭感觉优化;QPS 是吞吐、延迟是单请求耗时、P99 是长尾延迟,面试和排障最该盯 P99。
展开:压测即模拟大量并发访问看系统撑不撑得住。QPS(Requests/sec)表示每秒处理请求数,代表吞吐能力;延迟(Latency)是单个请求从发出到收到响应的时间,代表用户体感;P99 是把所有请求延迟排序后第 99% 位置的值,意味着 99% 的请求都比它快、只有最慢 1% 比它慢。平均值会被大量快速请求拉低、掩盖最慢用户,所以排障和优化看 P99 比看平均更有价值。
面试官想听的关键词:压测定位瓶颈、QPS 吞吐、延迟体感、P99 长尾、平均值会掩盖问题。
常见失分点:把 P99 说成"99% 的请求延迟"(搞反了,应是"99% 比它快");或只知道看平均值不知道 P99。
UserCache 而不是直接用 redis.Client?一句话结论:缓存属于"数据怎么存",应当在 Repository 层集成、Service 无感知;封装 UserCache 是为了屏蔽 key 结构、过期时间、序列化协议这三件事。
展开:缓存本质是存储细节,而 Service 只关心业务流程,不该操心数据从 Redis 还是 MySQL 来。把缓存放进 Repository,配合依赖注入把 DAO 和 Cache 都注入进来,业务方只说"给我 id=123 的用户"。直接写 redis.Client 会让 key 拼接、TTL、JSON 序列化散落各处,将来换序列化协议或加 key 前缀要满工程找。封装成 UserCache 后,这三个"怎么存"的细节被关进组件内部,对外只暴露"存什么",改策略只动一处。
面试官想听的关键词:Repository 层集成、Service 无感知、屏蔽 TTL/key/序列化、依赖注入、单一职责。
常见失分点:说"缓存放 Service 层更方便"(错,污染了业务层职责);或说不清封装 UserCache 到底屏蔽了哪三件事。
一句话结论:三层对应两个变化点——验证码逻辑被多业务复用、短信供应商可能更换;分层把每个变化点关进各自的笼子,换供应商不用动登录代码。
展开:从需求里能识别变化点:① 验证码不止登录用,找回密码等也会用(一层复用);② 短信供应商可能从腾讯换阿里(另一层复用)。所以底层是"短信发送服务"(可换供应商),中间是"验证码服务"(可复用),顶层才是"登录功能"。如果写成一个函数,换供应商就要改登录代码,违反开闭原则。分层就是"把会变的地方隔离出来"。
面试官想听的关键词:识别变化点、两层复用、开闭原则、可替换、面向接口。
常见失分点:说"分三层是为了代码更清晰"太空泛;或答不出到底有哪两个变化点。
一句话结论:单元测试盯单个方法、不依赖第三方、毫秒级;集成测试盯多组件协作、可连真实中间件、较慢。测试不能真连外部,是为了快、稳定、可重复、不花真钱。
展开:单元测试以代码内部分支逻辑为依据设计用例,不依赖 MySQL/Redis 等第三方,跑得极快、随写随跑;集成测试以业务流程为依据,可以连真实中间件验证协作。如果单测真连数据库,会慢、要准备数据、DB 挂了测试也挂、还可能真发出短信花真钱。所以单测用 mock 替换依赖。两者互补:单测保证零件合格,集成测试保证整车能跑。
面试官想听的关键词:范围/依赖/速度差异、mock 替换、用例设计依据(分支 vs 流程)、互补。
常见失分点:把两者说成"一个测函数一个测模块"就完了,漏掉"依赖"和"用例设计依据"这两个最关键的区分维度。
一句话结论:依赖注入 = 依赖从外部传入而非自己 new;IoC 是"控制权交给外部"的思想总称;DI 让依赖以接口形式从外部传入,正是 mock 能成立的前提。
展开:非依赖注入时 Repository 自己 initDB、initRedis,深度耦合且测试不友好——想单测它只能连真库。依赖注入时 NewUserRepository(dao, cache) 只声明"我需要什么",至于哪来的不管。这带来两个好处:可替换实现(腾讯/本地/阿里)、可测试(测试时传入 mock 实现)。所以"面向接口 + 依赖注入"是单元测试能成立的根基,没有它 mock 无处安放。
面试官想听的关键词:外部传入、IoC 思想、解耦、可替换、可测试、mock 前提。
常见失分点:把 DI 和 IoC 混为一谈(应说 IoC 是思想、DI 是其中一种实现);或答不出"DI 与测试的关系"这条最关键的因果链。
wire 是什么?它是代码生成还是运行时反射?有什么局限?一句话结论:wire 是 Google 的依赖注入代码生成器,编译前按类型把构造函数连起来生成普通 Go 代码(wire_gen.go),不是运行时反射;局限是不支持按环境切换实现、不能按接口查实现。
展开:wire 属于编译期代码生成,生成的 wire_gen.go 就是普通 Go 源码,可读、出错编译期就能发现,比运行时反射容器轻得多。你写 injector 列出所有构造函数,wire.Build(...) 后跑 wire 命令就生成装配代码。但它纯静态,不理解运行期概念:本地用本地短信、线上用腾讯这种"按环境选实现"它做不到(要手写 InitSmsService 切换);也不能按接口找实现、按类型找所有实例(路由注册手写)。另外它不支持单例,多次调用 InitApp 会生成多个 *gorm.DB。
面试官想听的关键词:代码生成非反射、wire_gen.go 普通代码、编译期、环境切换局限、不支持单例。
常见失分点:误以为 wire 用反射(它是生成代码);或不知道"不支持按环境切换实现"这个最实际的局限。
sync.Mutex?一句话结论:因为"查-写"是 check-then-act,分布式下多进程各自的 sync.Mutex 锁不住共享的 Redis;Lua 脚本在 Redis 单线程里原子执行,天然解决并发。
展开:业务逻辑里"先查有没有验证码、再决定要不要写"是典型的 check-then-act。如果在 Go 里加 sync.Mutex,多实例部署时每个进程各有一把锁,锁不住跨进程的共享 Redis;即使单机加锁也不行,因为 Redis 是共享的。正确做法是把"检查+写入"写成一段 Lua 发给 Redis,Redis 单线程执行期间不被打断,天然原子。等价方案还有 SET NX。
面试官想听的关键词:check-then-act、应用层锁锁不住共享存储、Redis 单线程、Lua 原子、SET NX。
常见失分点:直觉地答"加个锁就行",没意识到分布式下应用层锁失效;或答不出"Lua 为什么原子"(单线程执行不被打断)。
mockgen 生成 mock 后,使用时的三条铁律是什么?一句话结论:mock 是用"假的实现"替换真实依赖(如数据库)让单测不依赖外部;打桩是给这个替身设定"被调用时返回什么"。mockgen 使用后铁律:设计的 mock 调用不能多、不能少、不能乱(次序也要对上)。
展开:单测"不依赖第三方",但 Service 依赖 Repository、Repository 依赖 Redis。直接连库会让测试又慢又难准备、DB 挂了测试也挂。mock 就是给依赖造个替身,接口一样、内部是写死的假数据。用 uber-go/mock 的 mockgen 给接口生成 mock 后,套路是 ctrl := gomock.NewController(t) → m := NewMockXxx(ctrl) → m.EXPECT().方法(...).Return(...)。关键铁律:你 EXPECT 了几个调用,运行时就必须恰好用上几个、且次序一致,多了少了乱了都会报错。
面试官想听的关键词:替身、gomock、EXPECT、不能多/少/乱、单测不依赖外部。
常见失分点:把 mock 和桩混淆(mock 还可验证调用行为,桩只提供假数据);或不知道"顺序也要对上"这一条。
一句话结论:装饰器是"同一接口上加新特性"(不改接口),适配器是"把 A 接口适配到 B 接口"(不同接口)。装饰器不能用"嵌入接口"写法,因为调用方拿到结构体后可直连 .Service.Send() 绕过限流/超时/重试。
展开:装饰器模式(大明版定义)是"不改变原有实现而增加新特性",层层叠加:限流 → 超时 → 重试 → 真正发送,每层都实现同一个 Service 接口、持有被装饰者、只做一件事。如果采用"嵌入接口"方式一,结构体嵌入 Service 接口后,调用方可以绕过装饰器直接调底层 svc.Service.Send(),安全网形同虚设。生产用方式二(只持有 svc 字段),强制所有调用都过装饰器。适配器则解决接口不匹配问题(如把三方 SDK 接口适配成自己的接口)。
面试官想听的关键词:同接口加特性 vs 跨接口适配、嵌入接口可绕过、方式二持有字段、强制过装饰器。
常见失分点:把装饰器和适配器说成一样;或知道别嵌入接口却说不清"为什么嵌入会失效"(调用方直连底层)。
一句话结论:服务商挂了用 failover(故障切换):轮询尝试一组服务商,第一个失败换下一个;更优的是动态起点轮询 + 区分错误 + 基于健康状态切换(连续超时判定、临时摘除、试探法恢复)。
展开:轮询 failover 准备一组实现,逐个尝试直到成功或全部失败——但缺点是所有请求大概率在第一个成功,负载不均且服务商多时慢。改进版用原子递增的 idx 做"起点轮询"让请求分散,并区分 context.DeadlineExceeded/Canceled(这类换一家也没用直接返回,不重试)。再进一步是健康状态判定:连续 N 次超时就把主力/备用互换、临时摘除,并周期性发探测请求试探恢复。判断"服务商出问题"只能靠推测:频繁超时、EOF/UnexpectedEOF、响应突然变长。
面试官想听的关键词:轮询 failover、起点轮询、区分错误、健康状态、临时摘除、试探恢复。
常见失分点:只答"换一个服务商重试"漏掉"负载不均"和"区分错误"(超时类不该重试);或不提健康状态判定。
//go:generate 指令什么时候会被执行?它和 go build 是什么关系?为什么改了接口要重新 generate?一句话结论://go:generate 永远不会被自动执行——go build/go test 都不会触发它,必须手动 go generate ./...;它是开发期工具,产出参与编译的普通 .go 文件(mock/wire_gen/stringer)。改了接口不重新 generate,mock 里还是旧签名,编译报一堆 undefined。
展开://go:generate 是写在 .go 注释里的生成器调用指令(注意 // 和 go 间不能有空格)。go generate ./... 递归执行各包指令,每条指令的工作目录是"该指令所在文件所在包目录"。它和编译完全无关:go generate 调 mockgen/wire/stringer 产出源码,go build 编译这些已生成的普通文件。常见坑:① 改接口忘了重新生成,mock 方法签名对不上;② -package 写错导致包名冲突。
面试官想听的关键词:不自动执行、手动 go generate、开发期工具、产出普通源码、改接口要重生成。
常见失分点:以为 go build 会自动跑 go generate(实际不会);或不知道 // go:generate 多一个空格就被当成普通注释忽略。
一句话结论:核心两句——“尽量不冲垮第三方"和"第三方崩了你还能稳”。限流保护对方也保护自己,超时防被慢响应拖垮,重试应对偶发失败(要幂等),熔断防故障扩散。
展开:一切不在你控制范围内的都是不定时炸弹,治理前提是"不信任外部"。客户端限流(如腾讯 3000/s)是本地先限住别触发对方自我保护;超时是必须设,否则一个慢调用卡死整条链路;重试针对偶发网络抖动,但要注意幂等(重试不能重复下单/重复发短信,用唯一索引或 token 去重);熔断是连续失败后暂时"拉闸",避免无意义请求雪崩式压垮自己。这些用装饰器无侵入叠加。
面试官想听的关键词:不信任外部、限流/超时/重试/熔断、幂等、装饰器叠加、故障隔离。
常见失分点:把"客户端限流"和"服务端限流"混淆(本题是保护第三方/自己的客户端侧);或说重试却漏掉"幂等性"这个必答点。
八、本章小结
学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。
可勾选清单(面试前 5 分钟扫一遍)
- 压测闭环:先压测暴露瓶颈(QPS / 延迟 / P99),再定位(CPU 还是 IO),引入缓存后再压验证——不凭感觉优化。
- 缓存接入:
UserCache封装屏蔽 TTL/key/序列化三件事;在 Repository 层接入,Service 无感知;只放高频读场景(如 Profile)。 - 三层抽象:短信服务 → 验证码服务 → 登录功能,对应"多业务复用"和"换供应商"两个变化点。
- 并发安全:分布式 check-then-act 用 Redis Lua(或 SET NX)保证原子,绝不用应用层
sync.Mutex。 - 面向接口 + 依赖注入:核心逻辑一定面向接口;依赖从外部传入(点外卖 vs 自己做饭);只在"有变化/要 mock"处抽接口。
- wire:编译期代码生成(非反射),生成
wire_gen.go;局限是不能按环境切换实现、不支持单例。 - 测试两种粒度:单测不依赖第三方(以分支为依据)、集成测连真实中间件(以流程为依据);互补。
- mock 铁律:
mockgen生成 mock 后,调用不能多、不能少、不能乱(次序也要对)。 - 各层测试:Handler 用 httptest、Service/Repository 用 gomock、Cache 用 redis mock、DAO 用 sqlmock。
- 装饰器治理:限流/超时/重试/熔断用装饰器无侵入叠加(别用嵌入接口版);开闭原则、拒绝侵入式修改。
- go:generate:只手动执行、不参与编译;改接口必须重新生成,否则 mock 报 undefined。
- failover:轮询切换 + 基于健康状态切换(连续超时判定、临时摘除、试探恢复)。
下一章我们将进入 SSO 与微信扫码登录(第 05 章)。你会用到本章的接口抽象与装饰器思想,把"多种登录方式"也抽象成可替换的接口;而本章首次引入的缓存,会在第 06 章"发帖功能与缓存"里正式展开缓存三大问题(穿透 / 击穿 / 雪崩)与一致性方案——建议带着这个疑问进入第 06 章。
FindById 加了 UserCache,但还没认真处理"数据库更新后缓存怎么失效"“缓存和数据库数据不一致怎么办”。这正好是第 06 章 Cache Aside 模式(先更新库、再删缓存)要解决的硬骨头。另外,本章短信服务的"发送渠道"(邮件/语音)抽象我们刻意留到了将来——第 05 章的微信扫码登录会让你再练一次"识别变化点、超前设计不超前实现"。