第04章 · 接口抽象、短信服务与测试(正式版)

2022-12-23T14:11:02+08:00 | 64分钟阅读 | 更新于 2022-12-23T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 04 章。它假设你刚做完第 02、03 章——你已经会用 Gin 写接口、用 GORM 连 MySQL、用 JWT 做登录态、用 Redis 做限流和 Session。换句话说,Web 工程的"骨头"你已经摸过了。

但这一章有三个你从没碰过的东西:

  1. 测试——你之前写的代码从没跑过 go test,也没给任何函数写过测试。
  2. 依赖注入(DI)/ 控制反转(IoC)——你之前是"在哪用就在哪 new",从没想过"把依赖从外面传进来"。
  3. 接第三方服务——你之前只连过自己的 MySQL/Redis,从没对接过腾讯短信、从没处理过"对方挂了我怎么办"。

这一版做了三件事:

  1. 每一步都能跑。所有代码都标了文件路径、给了运行命令、写了预期输出。你照着敲,看到的应该和我写的一模一样。
  2. 术语不跳过。凡是出现你没见过的词(比如"压测"“缓存"“mock"“依赖注入"“控制反转"“wire"“装饰器模式”),当场就解释,不会让你自己去搜。
  3. 结尾有面试问答。每章最后 12 道题,按"基础 → 进阶 → 追问"三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。

如果你想要"高密度速查版”,可以看原版第 04 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

第 02、03 章我们把手脚架搭起来了:用户能注册、能登录、有 JWT 认证,限流兜住了恶意流量,服务也部署进了 K8s。但"能跑"和"工程质量好"是两回事——你心里其实没底:登录接口到底快不快?换个短信供应商要不要改一大片代码?你写的代码敢不敢随便重构?

这一章,我们关注点从"功能能跑"转向"工程质量”,主线是三件事:

  1. 性能优化:用 wrk 压测把"慢在哪"用数据暴露出来,发现瓶颈在数据库,于是引入 Redis 缓存,再压一遍验证效果。这是一条完整的"发现问题 → 定位 → 解决 → 验证"闭环。
  2. 短信服务抽象:以"手机号收验证码登录"为载体,完整走一遍"识别变化点 → 设计三层抽象 → 用依赖注入组装"的流程,体会面向接口编程为什么值钱。
  3. 测试:从什么叫测试讲起,到表驱动单测、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 组装→各层测试"的行文顺序一一对应。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用 wrk 对一个 HTTP 接口做基础压测,能看懂输出里的 QPS、延迟、P99 这些数字代表什么。
  2. 在 Repository 层用 UserCache 给"查用户"套一层 Redis 缓存,写出"先查缓存、未命中查库、回写缓存"的 FindById。
  3. 把"短信 → 验证码 → 登录"拆成三层接口,各写至少一个实现,并用依赖注入把它们串起来。
  4. 用 mockgen 给一个接口生成 mock,写出一个**表驱动(Table-Driven)**单元测试并跑绿。
  5. 用 httptest 测一个 Gin Handler,用 sqlmock 测一个 GORM 的 DAO。

理解(能讲清原理和取舍,不要求背代码)

  1. 依赖注入(DI)和控制反转(IoC)是什么、为什么测试离不开它、wire 是怎么用"代码生成"而非"运行时反射"来组装依赖的。
  2. 为什么要"面向接口编程”——哪些地方该留接口、哪些地方暂时不用留,以及"超前设计、最小化实现"的分寸。
  3. 单元测试 vs 集成测试的区别、各自的适用边界、为什么测试不能真连数据库真发短信(mock / 打桩的意义)。
  4. 分布式环境下"check-then-act"为什么不能用 sync.Mutex 解决,为什么要用 Redis Lua 脚本保证原子性。
  5. 装饰器模式如何无侵入地给第三方调用叠加限流、超时、重试、熔断等能力。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. 压测工具 wrk、指标 QPS / 延迟 / P99 的含义;测试金字塔的概念。
  2. go generate 机制,以及它和 go build 完全无关(只负责调用 mockgen / wire / stringer 这类生成器)。
  3. 第三方服务治理的高可用思路:客户端限流、超时、重试、熔断、failover 切换。
  4. 缓存的三大经典问题(穿透 / 击穿 / 雪崩)的雏形——本章首次引入缓存,先建立直觉,第 06 章会专门展开。

前置依赖与补课清单

本章用到几个前文从未系统讲过的技术名词。下表列出它们,并在正文里就地补「补课胶囊」,不用你去别处查:

本章用到的"新词”在哪节首次出现补课胶囊位置
压测 / wrk / QPS / 延迟 / P993.1 节见 3.1 节「补课胶囊 · 压测」
缓存(Cache)3.3 节见 3.3 节「补课胶囊 · 缓存」
单元测试 vs 集成测试3.8 节见 3.8 节「补课胶囊 · 测试的两种粒度」
mock / 打桩3.9 节见 3.9 节「补课胶囊 · mock 与打桩」
依赖注入 DI / 控制反转 IoC3.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、可测的前提。

接口·短信·测试(新手版)性能与缓存接口与 DI测试与治理wrk 压测 / QPS / P99UserCache 业务缓存Repository 层接入短信/验证码/登录三层依赖注入 DI / IoCwire 代码生成单测+集成测试mockgen / sqlmock装饰器治理第三方
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。如果你只想快速过一遍,可以只读每节的"抛问题"和"小收口";如果要真学会,就把每个代码块都敲一遍。本章代码块较多,建议每敲完一个就 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 里的 p9999% 的请求都比这个值快长尾用户的最差体验,比平均值更值得看
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..."}}

如果这里都通不过,先回去把服务跑起来,再谈压测。

常见坑:压测前忘了"松绑"

压测结果不准,往往不是工具的问题,而是环境没准备好:

复习提示:

⚠️ 压测前三个必做动作:

  1. 临时启用 JWT 且把登录态有效期调长(比如 30 分钟),否则压测 Profile 接口时 token 频繁过期,测到的全是"重新登录"的开销。
  2. 去掉限流中间件,否则你压的不是业务接口,而是被自己的限流挡住了(第 03 章的限流会拒绝高频请求)。
  3. 关掉或调低日志级别,疯狂打日志本身会拖慢接口,污染数据。

小收口

压测不是"秀肌肉",而是"找病根"。记住一句话:先有数据,再谈优化;凭感觉优化是工程大忌。 下一节我们真正用 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?

三个"屏蔽"指向同一个思想——把"怎么存"的细节关进缓存组件里,对外只暴露"存什么":

  1. 屏蔽过期时间:调用方不需要关心 key 的 TTL(存活时间)是多久。
  2. 屏蔽 key 结构:调用方不用知道 user:info:123 这种 key 是怎么拼出来的。
  3. 屏蔽序列化协议:调用方不用关心结构体是怎么转成 []byte 存进 Redis 的。

如果你在业务代码里到处写 key 拼接和 JSON 序列化,将来想调整缓存策略——比如换一种序列化协议、给 key 加统一前缀——就得满工程找代码改;而有了这层抽象,只需要改 UserCache 的实现这一处。

复习提示:序列化(Marshal)/ 反序列化(Unmarshal):把结构体转成 []byte(存进 Redis)叫序列化;把 []byte 转回结构体叫反序列化。Go 里常用 JSON。这是缓存组件内部干的事,业务方完全不需要知道。

压测前后对比(示意)

引入 UserCache 后,我们再压一遍 Profile 接口(高频读场景),理想结果是这样(以下数值为示意,实际取决于你的机器和数据量):

指标优化前(直连 DB)优化后(加 UserCache)说明
QPS~412~2100命中缓存后不再查库,吞吐大幅提升
Avg 延迟~118ms~22ms读内存比读库快一个数量级
P99 延迟~295ms~60ms长尾明显收窄
数据库 QPS与接口 QPS 持平显著下降大部分读被缓存挡住

注意:上面是"示意"数据,不是承诺。真实数值由你的机器配置、数据量、Redis 部署方式决定。你动手跑出来的结果可能更好也可能更差,但趋势一定是"加缓存后读接口更快、数据库压力更小"。这正是"先压测、再优化、再压测验证"闭环的价值。

常见坑:为了"缓存而缓存"

复习提示:⚠️ 新手必踩:不是所有接口都该加缓存。登录接口就是反例——登录是低频操作,正常网站好几天才让你登录一次,按 email 缓存用户命中率极低,收益几乎为零,反而徒增一致性复杂度。缓存要放在高频读的场景,比如 Profile(每次刷新都查用户信息)。

小收口

我们走完了性能优化的完整闭环:压测暴露瓶颈(数据库 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-14NewUserRepository(dao, cache)依赖从外面传入,这就是依赖注入,后面 3.10 节专门讲
19-22缓存命中直接返回命中就没必要碰数据库,省下最贵的 IO
25-28缓存未命中/出错都查库关键取舍:缓存出错(如 Redis 挂了)也继续查库,业务不因缓存故障而挂
30-32回写缓存,忽略错误缓存写失败只影响下次命中率,不该让本次请求失败

两种写法的取舍

写法行为优缺点
缓存出错也查 DB(推荐)Redis 挂了业务依然可用数据库可能被压垮(缓存雪崩时)
缓存出错不查 DBRedis 挂了业务也不可用保护数据库,但牺牲可用性

选哪种取决于业务优先级:用户可用优先还是数据库保护优先。中小项目一般用"缓存出错也查 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

复习提示:💡 Redis 面试四连,提前想好答案:① 你用 Redis 解决过什么问题(本章的缓存、限流、验证码存储都是现成例子);② Redis 支持哪些数据结构、你用过哪些、分别解决什么问题;③ 各数据结构的底层实现;④ 更新数据时先更新数据库还是先更新缓存?有没有一致性问题、怎么解决(可答 Cache Aside 模式:先更新数据库、再删除缓存,配合过期时间兜底)。第 06 章会专门展开缓存一致性,这里先留个印象。

小收口

性能优化主线至此闭环:压测 → 定位 DB 瓶颈 → 引入 UserCache → 接入 Repository → 再压测验证。你已经完成了本章第一条主线。接下来进入第二条主线:短信验证码登录的三层抽象,这会引出"面向接口编程"和"依赖注入"这两个贯穿全书的工程基本功。

3.6 短信验证码登录:从需求里识别"变化点”

先抛问题

除了账号密码,让用户也能用手机号收验证码登录。这个功能本身不复杂,但它是个绝佳的载体——能练出一套通用设计方法:怎么从模糊的需求里识别出"会变的部分",把它封装成一层层独立的抽象。

给个直觉:乐高积木

好的设计像乐高:每块积木(抽象层)只干一件事,拼错一块换一块,不会牵一发动全身。“会变的地方"就单独做成一块积木,将来换积木即可。

需求分析:功能 vs 非功能

业界做需求分析有两招:一是参考竞品(大部分功能都是"你抄我我抄你”);二是从功能 / 非功能两个角度分析,再从正常 / 异常流程两个角度思考。

把主流 App 的短信登录流程走几遍,会得到这份特性清单(注意:大部分是防护措施,因为短信是按条真金白银付费的):

  • 发送验证码前通常要先用拼图验证,防止恶意触发短信
  • 发送验证码后不能连续发送,需间隔一段时间
  • 手机号第一次使用时直接注册新账号
  • 验证码有有效期(10 分钟)
  • 不同业务的验证码互相独立(A 业务触发的,B 业务可立即触发新的)
  • 验证码只能使用一次

功能需求:手机号收验证码登录,新号码直接注册;验证码有效期 10 分钟;同一手机号 1 分钟内只能发 1 次;登录成功跳转首页。 非功能需求:保护系统,防止攻击者恶意发短信。

服务划分:为什么要三层?

“手机验证码登录"包含两件事:验证码和登录。它们强耦合吗?别的业务(比如"找回密码”)也会用验证码吗?会。所以验证码应该是独立功能。再进一步,验证码通过短信发送,短信会不会换供应商?会。于是有两个变化点:

  1. 不同业务都要用短信和验证码(一层复用);
  2. 可能换短信供应商(另一层复用)。

这就是"业务上的超前半步设计"——只留出接口,不提前实现。把每个变化点关进各自的笼子,换供应商时不用动登录代码。

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
}
复习提示:为什么用依赖注入传入 client? 理论上你也可以在 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)
复习提示:⚠️ 时间断言对不上:数据库存的是毫秒数(纳秒被丢弃),从 Repository 拿回的时间也没有纳秒。断言时要么把期望值截断到毫秒,要么只比较 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 两个坑:① 装完运行 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,两种方案:

  1. 在 Send 方法里加一个 token 参数,服务端校验这个 token 有没有权限用这个 tpl;
  2. 直接用 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 及以下岗位)。开场白先介绍架构和功能,埋下"可用性"这个鱼饵,引导面试官来问:

我在公司负责的一个核心模块是短信服务,分短信服务本身和管理后台两部分。短信服务负责发短信;管理后台提供短信的增删改查、手动补发/单发/群发、定时发送,以及按运营筛选圈目标用户发营销短信。公司相当多业务依赖它(如二次验证的手机验证码),所以维护核心目标是保障可用性。

面试官追问"你做了什么保证可用性"时,分条作答(每条都对应本节内容):

  1. 保证发送成功:收到请求立刻落库,随即尝试发送;发送失败有异步补偿——定时扫描未发送成功的记录补发。
  2. 去重:用 biz_type + biz_id 唯一索引去重,收到重复请求查是否已发送成功,避免重复发送。
  3. 重试策略:允许不同业务方使用不同策略(是否跨进程、间隔、次数可配);最终失败后业务方可通过消息队列订阅并配告警。
  4. 平台容错:平台负载高时降级——同步转异步,先回"已接收",业务方通过查询/监听得知结果;平台自身限流触发的请求同样转异步。
  5. 服务商容错:客户端限流 + 轮询 failover + 基于健康状态的 failover(连续超时判定、临时标记、试探恢复)。
  6. 鉴权:业务方带从管理后台申请的 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 可达)。

复习提示:集成测试铁律:用例之间绝对不能互相依赖! 某个用例的成败不能依赖另一个用例先跑过(比如 B 依赖 A 插入的数据)。一旦形成链,单独跑 B 会挂、调顺序会挂、删 A 连锁崩溃。每个用例用 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 的时候,你已经下班回家了。


六、自测题与动手练习

自测题(合上书能答出来,才算懂):

  1. 压测发现瓶颈通常在哪两类?为什么引入缓存要在 Repository 层而非 Service 层?
  2. 短信 / 验证码 / 登录为什么分成三层?分别对应的"变化点"是什么?
  3. 分布式环境下"查验证码 → 写验证码"为什么不能用 sync.Mutex 解决?正确的原子方案是什么?
  4. 依赖注入和非依赖注入的核心区别是什么?wire 解决什么痛点、又有什么局限?
  5. 单元测试和集成测试的区别是什么?为什么测试"不能真连数据库、真发短信"?
  6. mock 是什么?mockgen 给一个接口生成 mock 后,使用时的三条铁律是什么?
  7. 装饰器模式和适配器模式的区别是什么?为什么装饰短信服务不能"嵌入接口"?
  8. 为什么验证码的频率控制要用 Redis Lua 脚本而不是应用层代码?
  9. wire 是运行时反射还是代码生成?它和 go generate 是什么关系?
  10. 服务商挂了有哪些 failover 策略?如何判断"服务商出问题了"?

动手练习(建议真做一遍):

  1. 加一层装饰器:在 RatelimitSMSService 之外再写一个 TimeoutSMSService(用 context.WithTimeout),并补一个"超时返回 error"的单测。
  2. Redis Lua 单测:把"发送太频繁返回 -1"的 Lua 脚本用真实 Redis(或 miniredis)写集成测试,验证 1 分钟内第二次发送被拒。
  3. Table-Driven 补齐分支:给 CodeService.Verify 写三个用例——验证通过(删 key)、失败 3 次作废、验证码不存在,跑绿并看覆盖率。
  4. 固化 mockgen 指令:在 sms.Service 接口文件里加 //go:generate mockgen ...,运行 go generate ./... 重新生成;再故意改接口签名、重新生成,对比 mock 变化。
  5. 体验 stringer:给一个 iota 枚举加 //go:generate stringer -type=Status,生成 String() 并在日志里打印枚举值,体会"生成代码也是代码"。

七、模拟面试问答

这一节模拟真实面试。每题按基础 / 进阶 / 追问三档,且每题固定四段:一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点。

面试官
【基础】 为什么要做压测?压测报告里 QPS、延迟、P99 分别代表什么,你最该盯哪个?
候选人

一句话结论:压测是用数据暴露性能瓶颈、避免凭感觉优化;QPS 是吞吐、延迟是单请求耗时、P99 是长尾延迟,面试和排障最该盯 P99。

展开:压测即模拟大量并发访问看系统撑不撑得住。QPS(Requests/sec)表示每秒处理请求数,代表吞吐能力;延迟(Latency)是单个请求从发出到收到响应的时间,代表用户体感;P99 是把所有请求延迟排序后第 99% 位置的值,意味着 99% 的请求都比它快、只有最慢 1% 比它慢。平均值会被大量快速请求拉低、掩盖最慢用户,所以排障和优化看 P99 比看平均更有价值。

面试官想听的关键词:压测定位瓶颈、QPS 吞吐、延迟体感、P99 长尾、平均值会掩盖问题。

常见失分点:把 P99 说成"99% 的请求延迟"(搞反了,应是"99% 比它快");或只知道看平均值不知道 P99。

面试官
【基础】 为什么引入缓存要在 Repository 层接入,而不是 Service 层?为什么要封装一个 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 流程)、互补。

常见失分点:把两者说成"一个测函数一个测模块"就完了,漏掉"依赖"和"用例设计依据"这两个最关键的区分维度。

面试官
【进阶】 什么是依赖注入(DI)和控制反转(IoC)?它们和测试有什么关系?
候选人

一句话结论:依赖注入 = 依赖从外部传入而非自己 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 用反射(它是生成代码);或不知道"不支持按环境切换实现"这个最实际的局限。

面试官
【进阶】 为什么验证码的频率控制要用 Redis Lua 脚本,而不是在 Go 代码里用 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 为什么原子"(单线程执行不被打断)。

面试官
【进阶】 什么是 mock / 打桩?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(故障切换):轮询尝试一组服务商,第一个失败换下一个;更优的是动态起点轮询 + 区分错误 + 基于健康状态切换(连续超时判定、临时摘除、试探法恢复)。

展开:轮询 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 章的微信扫码登录会让你再练一次"识别变化点、超前设计不超前实现"。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!