学习目标
- 掌握用
wrk压测接口的基本流程,能够定位 Web 服务的性能瓶颈,并知道何时该引入缓存 - 学会从需求中识别"变化点",独立设计出短信服务、验证码服务、登录服务三层抽象,并理解为什么要这么分层
- 理解面向接口编程与依赖注入(DI)的本质,能够使用
wire简化依赖组装,掌握控制反转(IoC)的概念 - 能独立为 Handler / Service / Repository / Cache / DAO 各层编写单元测试,熟练使用
mockgen与sqlmock - 能够编写基于真实 Redis/MySQL 的集成测试,理解单元测试与集成测试的边界
- 掌握第三方服务调用的治理思路:客户端限流、超时、重试、熔断,以及装饰器模式在其中的应用
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 GORM + MySQL、Redis:知道 DAO 怎么写、连接怎么建。
- 第03章 JWT / Redis Session:知道登录态怎么维护,方便理解"登录服务"这一层。
- Go 接口与
gomock基础:知道接口、mock 是什么。 - 基本并发常识:知道
check-then-act在并发下会出问题。
本章你会动手做的事:
- 用
wrk把本地注册/Profile 接口压一遍,亲手看到 DB/加密是瓶颈。 - 把"短信 → 验证码 → 登录"三层抽象各写一个接口与实现,用
wire串起来。 - 用
mockgen给CodeService生成 mock,写一个 Table-Driven 单测,跑绿。
一、优化登录性能
1.1 用 wrk 压测接口
在工程实践中,“性能好不好"不能靠猜,要靠压测。wrk 是一款现代化的 HTTP 压测工具,能够用很少的资源打出很高的并发。
安装:
# Mac
brew install wrk
# Ubuntu/Debian
apt install wrk
# 源码安装
git clone https://github.com/wg/wrk.git
cd wrk && make
# 把生成的 wrk 可执行文件加入 PATH
压测注册接口(POST 写接口):
# -t 线程数, -d 持续时间, -c 并发连接数, -s lua 脚本
wrk -t1 -d1s -c2 -s ./scripts/wrk/signup.lua \
http://localhost:8080/users/signup
参数说明:
-t:线程数,一般不超过 CPU 核数-d:持续时间,如1s、1m-c:并发连接数-s:Lua 脚本,用来构造 POST body 等动态请求
压测前的准备:
- 临时启用 JWT(比 Session 容易测)
- 把登录态有效期改为 30 分钟,确保压测 profile 时 token 没过期
- 去掉限流中间件,否则压测会被自己的限流挡住
压测 Profile 接口(读接口,需要登录态):
wrk -t1 -d1s -c2 -s ./scripts/wrk/profile.lua \
http://localhost:8080/users/profile
profile.lua 中要修改 User-Agent 和 Authorization 头,模拟已登录用户。
flowchart LR
W[wrk 压测] --> T{瓶颈在哪?}
T -- 加密算法 bcrypt --> C[CPU 瓶颈]
T -- 数据库查询 --> I[IO 瓶颈]
C --> RC[引入缓存/优化算法]
I --> RC1.2 性能瓶颈分析与引入缓存
压测后通常会看到瓶颈在两个地方:
- 加密算法(如 bcrypt)耗费 CPU
- 数据库查询耗费 IO
针对数据库查询,引入 Redis 缓存是常见手段。但不要直接在业务代码里写 Redis 调用,而要为业务封装一个专属的 UserCache。
类比:你不会在每道菜里直接操作冰箱,而是有一个"食材管理员"统一存取。
UserCache就是那个管理员——业务方只说"给我 id=123 的用户”,不用管数据在 Redis 还是 MySQL、key 怎么拼。
1.3 业务专属缓存抽象的作用
为什么需要一个 UserCache,而不是直接用 redis.Client?三个原因:
- 屏蔽过期时间:调用方不需要关心 key 的 TTL 是多久
- 屏蔽 key 结构:调用方不用知道
user:info:123这种 key 是怎么拼的 - 屏蔽序列化协议:调用方不用关心结构体是怎么转成
[]byte存进 Redis 的
序列化(Marshal):把结构体转成
[]byte;反序列化(Unmarshal):把[]byte转回结构体。Go 中常用 JSON。
1.4 集成 UserCache:在 Repository 层接入
缓存属于"如何存储数据",应该在 Repository 层集成,Service 层无感知。继续保持依赖注入:把 DAO 和 Cache 都注入到 Repository。
// CachedUserRepository 在 Repository 层组合 DAO 与 Cache
type CachedUserRepository struct {
dao dao.UserDAO // 操作数据库
cache cache.UserCache // 操作 Redis
}
func NewUserRepository(dao dao.UserDAO, cache cache.UserCache) *CachedUserRepository {
return &CachedUserRepository{dao: dao, cache: cache}
}
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
}
两种写法对比:
| 写法 | 行为 | 优缺点 |
|---|---|---|
| 缓存出错也查 DB(推荐) | Redis 挂了业务依然可用 | 数据库可能被压垮 |
| 缓存出错不查 DB | Redis 挂了业务也不可用 | 保护数据库,但牺牲可用性 |
flowchart TD
Q[FindById] --> C{缓存命中?}
C -- 是 --> R[直接返回]
C -- 否/出错 --> D[查数据库]
D --> W[回写缓存]
W --> R1.5 登录要不要也缓存?
不要。登录是非常低频的操作,正常网站好几天才让你登录一次。即使按 email 缓存用户信息,命中率也极低,收益不大。缓存要放在高频读的场景,比如 Profile。
1.6 Redis 数据结构速览
| 类型 | 用途 |
|---|---|
| string | 简单 KV,如缓存用户 JSON |
| list | 链表,如消息队列 |
| set | 集合,如标签 |
| sorted set | 有序集合,如排行榜 |
| hash | 字典,如存储对象多个字段 |
不常用的:bitmaps、JSON、streams、bitfields、time series。
二、短信验证码登录
2.1 需求分析
国内互联网公司需求描述往往很粗略,工程师要掌握快速需求分析的技巧:
- 参考竞品:大部分功能都是"你抄我我抄你"
- 从功能/非功能两个角度分析:
- 功能角度:明确做到哪些功能,做到什么程度
- 非功能角度:安全性 > 扩展性 > 性能(中小型公司)
- 从正常/异常流程两个角度思考:区分业务异常和技术异常
2.2 参考竞品得到的功能特性
- 发送验证码前通常要先用拼图验证,防止恶意触发短信
- 发送验证码后不能连续发送,需间隔一段时间
- 手机号第一次使用时直接注册新账号
- 验证码有有效期(10 分钟)
- 不同业务的验证码互相独立(A 业务触发的验证码,B 业务可立即触发新的)
- 验证码只能使用一次
2.3 功能与非功能需求
功能:
- 用户用手机号收验证码登录,新号码直接注册新账号
- 验证码有效期 10 分钟
- 同一手机号 1 分钟内只能发送 1 次
- 登录/注册成功后跳转首页
非功能:
- 保护系统,防止攻击者恶意发短信
2.4 深入分析:服务划分
“手机验证码登录"包含两件事:验证码和登录。它们强耦合吗?别的业务也会用验证码吗?是的。
所以验证码应该是独立功能。再进一步,验证码通过短信发送,短信会不会换供应商?会。
于是有两个变化点:
- 不同业务都要用短信和验证码
- 可能换短信供应商
服务划分(叠床架屋式设计):
- 独立的短信发送服务
- 在短信服务上封装验证码服务
- 在验证码服务上封装登录功能
这就是"业务上的超前半步设计”——只留出接口,不提前实现。
flowchart TD
L[登录服务] --> C[验证码服务]
C --> S[短信服务]
S --> T[(腾讯/阿里/本地
短信供应商)]为什么是三层而不是一层?因为"变化点"在两处:验证码逻辑可能被登录/找回密码等多个业务复用(一层复用),短信供应商可能更换(另一层复用)。分层就是把每个变化点关进各自的笼子里,换供应商不用动登录代码。
2.5 短信服务接口抽象
从特定供应商(腾讯)的 API 开始抽象。腾讯短信 API 要求:
- 初始化客户端时传入鉴权参数(SecretId、SecretKey)
- 发送短信时传入:手机号、appId、签名、模板 ID、模板参数
其中 appId 和签名在我们系统里是固定的,可以在初始化时指定,发送时不再传递。于是抽象出接口:
// sms/service.go
package sms
// Service 是短信服务的抽象接口
// 任何短信供应商实现这个接口,都可以被注入到验证码服务中
type Service interface {
// Send 发送一条短信
// ctx: 用于超时与取消
// tplId: 短信模板 ID(需在服务商后台提前配置)
// args: 模板参数,如验证码本身
// numbers: 目标手机号列表(支持群发)
Send(ctx context.Context, tplId string, args []string, numbers ...string) error
}
2.6 腾讯实现
// 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 {
// 步骤 1:构造请求,填入 appId/签名/模板/手机号/参数
req := sms.NewSendSmsRequest()
req.SmsSdkAppId = &s.appId
req.SignName = &s.signName
req.TemplateId = &tplId
req.PhoneNumberSet = toStringPtrSlice(numbers) // 手机号切片转指针切片
req.TemplateParamSet = toStringPtrSlice(args) // 模板参数转指针切片
// 步骤 2:发起调用
resp, err := s.client.SendSmsWithContext(ctx, req)
if err != nil {
// 把底层错误信息封装好,方便排查
return fmt.Errorf("腾讯短信发送失败: %w", err)
}
// 步骤 3:检查每个手机号的发送状态
for _, status := range resp.Response.SendStatusSet {
if status.Code != nil && *status.Code != "Ok" {
return fmt.Errorf("发送失败 %s: %s",
*status.PhoneNumber, *status.Message)
}
}
return nil
}
为什么用依赖注入传入 client? 理论上你也可以在
NewService里自己初始化 client,但这样测试时无法替换。依赖注入是工程化的基本功。
2.7 本地实现(用于本地调试)
// sms/localsms/service.go
package localsms
import "context"
// 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
}
2.8 验证码服务
2.8.1 验证码的安全问题
验证码是 6 位数字,只有 10 万种可能,所以:
- 控制发送频率:同一手机号 1 分钟内只能发 1 次,有效期 10 分钟
- 防止暴力破解:
- 验证通过后立即失效,不能再用
- 3 次验证失败后该验证码作废(但只提示"验证码不对",不提示"过于频繁失败",避免信息泄露)
这是业务复杂度,不是技术复杂度。换业务场景这些规则就全废了。
2.8.2 验证码服务接口
// 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)
}
2.8.3 并发问题:为什么不能用普通 Go 代码
考虑这个伪代码:
// 错误示范:有并发问题
func (s *CodeService) Send(ctx context.Context, biz, phone string) error {
// 1. 查 Redis 看有没有验证码
code, ttl := s.cache.Get(ctx, biz, phone)
if code != "" && ttl > 9*time.Minute {
return errors.New("发送太频繁")
}
// 2. 生成新验证码
newCode := generateCode()
// 3. 写回 Redis <-- 这里可能有并发:两个请求同时走到这里
return s.cache.Set(ctx, biz, phone, newCode, 10*time.Minute)
}
这是典型的"check-then-act"问题。在分布式环境下,不能用 sync.Mutex 或 channel,因为多个进程同时跑。即使单机用锁也不行——Redis 是共享的。
sequenceDiagram
participant A as 请求A
participant B as 请求B
participant R as Redis
A->>R: 查验证码(不存在)
B->>R: 查验证码(不存在)
A->>R: 写新验证码
B->>R: 写新验证码(覆盖A)
Note over A,B: 两个请求都认为发送成功, 但 Redis 值被覆盖, 频率控制失效解决方案:用 Redis Lua 脚本。Redis 是单线程的,Lua 脚本执行期间不会被其他命令打断,天然原子。
2.8.4 Lua 脚本:发送验证码
-- 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
-- 不存在或已过 1 分钟,可以发送
redis.call('SET', key, ARGV[1]..'&'..'0', 'EX', ARGV[2])
return 0
这里把验证码和验证次数
cnt拼成code&cnt一起存,方便原子更新。
⚠️ 新手必踩的坑:分布式下别用应用层锁。很多人直觉地给
Send加sync.Mutex,但多实例部署时每个进程各有一把锁,根本锁不住共享的 Redis。原子性要下沉到 Redis 本身的 Lua 脚本(或SET NX)。
2.8.5 Send 实现
func (s *CodeRedisCache) Set(ctx context.Context, biz, phone, code string) error {
// 步骤 1:调用 Lua 脚本,原子地检查频率并写入
res, err := s.client.Eval(ctx, luaSetCode,
[]string{s.key(biz, phone)}, code, 600).Result()
if err != nil {
return err
}
// 步骤 2:返回 -1 说明发送太频繁
if res.(int64) == -1 {
return ErrCodeSendTooMany
}
return nil
}
// 真正的 Send:先调用 Lua 脚本判断频率并存储,再调用短信服务发送
func (s *CodeService) Send(ctx context.Context, biz, phone string) error {
// 步骤 1:生成 6 位验证码
code := s.generateCode()
// 步骤 2:写 Redis(含频率控制)
err := s.cache.Set(ctx, biz, phone, code)
if err != nil {
return err
}
// 步骤 3:真正发送短信
return s.sms.Send(ctx, s.tplId, []string{code}, phone)
}
2.8.6 Verify 实现
-- 验证码验证 Lua 脚本
local key = KEYS[1]
local input = ARGV[1]
local val = redis.call('GET', key)
if not val then
-- 验证码不存在,还没发送
return -1
end
local code, cnt = unpack(split(val, '&'))
if tonumber(cnt) >= 3 then
-- 失败次数已超 3 次,作废
return -2
end
if code == input then
-- 验证通过,删除验证码(防止重放)
redis.call('DEL', key)
return 0
end
-- 验证失败,失败次数 +1
cnt = tonumber(cnt) + 1
redis.call('SET', key, code..'&'..cnt, 'EX', redis.call('TTL', key))
return -3
2.9 用户验证码登录
2.9.1 FindOrCreate 的并发问题
“查找-创建"是典型的不一致场景:两个人同时用同一手机号注册,可能同时查到"不存在”,于是同时创建。
解决方案:在数据库中给手机号加唯一索引。这样即使并发插入,也只有一个成功。
// FindOrCreate 兼顾性能的写法
func (r *CachedUserRepository) FindOrCreate(ctx context.Context, phone string) (domain.User, error) {
// 步骤 1:先查,存在直接返回
u, err := r.dao.FindByPhone(ctx, phone)
if err != ErrUserNotFound {
return u.toDomain(), err
}
// 步骤 2:不存在,尝试创建
err = r.dao.Insert(ctx, dao.User{Phone: sql.NullString{String: phone, Valid: true}})
// 步骤 3:创建成功,再查一次拿到 ID
if err == nil {
return r.dao.FindByPhone(ctx, phone)
}
// 步骤 4:创建失败,可能是并发导致唯一索引冲突,再查一次
return r.dao.FindByPhone(ctx, phone)
}
2.9.2 sql.NullString 处理 NULL
数据库里手机号或邮箱可能为 NULL,Go 中用 sql.NullString 表示可空字符串:
type User struct {
Id int64
Email sql.NullString // 可空
Phone sql.NullString // 可空
}
它实现了 sql.Scanner 和 driver.Valuer 接口,所以能配合 ORM 使用。
三、面向接口编程与依赖注入
3.1 已有的初始化代码:层层组装
// main.go 伪代码
db := initDB()
redis := initRedis()
userDAO := dao.NewUserDAO(db)
userCache := cache.NewUserCache(redis)
userRepo := repo.NewUserRepository(userDAO, userCache)
userService := service.NewUserService(userRepo)
userHandler := web.NewUserHandler(userService)
server := gin.New()
userHandler.RegisterRoutes(server)
server.Run()
这个过程就是依赖注入:A 依赖 B,A 初始化时传入一个已构造好的 B。
flowchart TD
H[UserHandler] --> SVC[UserService]
SVC --> R[UserRepository]
R --> DAO[UserDAO]
R --> C[UserCache]
DAO --> DB[(MySQL)]
C --> RD[(Redis)]3.2 依赖注入 vs 非依赖注入
// 非依赖注入:Repository 自己初始化 DAO 和 Cache
type UserRepository struct{}
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}
}
非依赖注入的缺点:
- 深度耦合依赖的初始化过程
- 需要定义额外的 Config 类型
- 一旦依赖增加配置或改初始化过程,都要跟着改
- 缺乏扩展性
- 测试不友好(无法 mock 依赖)
- 难以复用公共组件
3.3 wire:依赖注入代码生成工具
手写依赖注入链很长没技术含量,于是有了 wire。
go install github.com/google/wire/cmd/wire@latest
使用步骤:
- 在
wire.go中注册各种初始化方法(用wire.Build) - 运行
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,
web.NewUserHandler,
ioc.InitWebServer,
)
return nil
}
执行 wire 后会生成 wire_gen.go,里面有真正初始化逻辑。
3.4 IoC:控制反转
依赖注入是 IoC 的一种实现形式。另一种叫依赖查找(Dependency Lookup)。
- IoC:调用方不再自己创建依赖,而是由外部容器控制
- 依赖注入:外部主动把依赖传进来(如
wire、Spring) - 依赖查找:调用方主动从容器中查找依赖(如
service locator)
3.5 wire 的缺陷
- 不支持根据环境使用不同实现(需要手写
InitSmsService切换) - 不能根据接口查找实现(需要手写)
- 不能根据类型查找所有实例(注册路由要手写)
即便如此,wire 的好处是清晰、可控性强,比手写好。
3.6 面向接口编程
定义:组件与组件之间的通信必须通过接口。A 调用 B 时,B 必须是接口。
// 非面向接口编程:CodeCache 是结构体,直接依赖
type CodeService struct {
cache *CodeCache // 具体类型,难以替换
}
// 面向接口编程:依赖接口
type CodeService struct {
sms sms.Service // 接口,可注入不同实现
}
为什么要面向接口编程? 为了扩展性。它本身不提高性能或可靠性,但能让你的代码随时切换实现:腾讯短信→阿里短信、Redis 缓存→本地缓存→Memcache。
3.7 改造已有代码
从最底层的 Cache 和 DAO 开始改造:
// cache/user.go
type UserCache interface {
Get(ctx context.Context, id int64) (domain.User, error)
Set(ctx context.Context, u domain.User) error
}
// 唯一实现:基于 Redis
type RedisUserCache struct { /* ... */ }
将来可以提供:
- 本地缓存实现
- Memcache 实现
- 本地 + Memcache 双重缓存实现
结合 wire 的注意点:
NewXXX最好返回接口,这样 wire 可以直接类型匹配。但 Go 推荐做法是返回具体类型,这与 wire 冲突。实践中常用返回接口的方式。
3.8 超前设计,最小化实现
- 超前设计:识别变化点,留出接口
- 不超前实现:只提供当下需要的实现,不写额外的实现
考虑有限的几种情况来抽象,不要考虑得非常长远。多写几行接口代码,连十分钟都不要。
3.9 如何识别业务变化点
- 任何第三方工具都有替换可能(如短信、对象存储)。MySQL/Redis 这种主流的可以忽略
- 业务流程中不合理的地方,将来产品经理也会让你改
- 违背用户使用习惯的设计
- 用户的"骚操作"
- 核心逻辑一定面向接口编程
四、单元测试
4.1 单元测试 vs 集成测试
| 维度 | 单元测试 | 集成测试 |
|---|---|---|
| 范围 | 单个方法 | 多个组件协作 |
| 依赖 | 不能依赖第三方(MySQL/Redis) | 可以依赖 |
| 速度 | 极快,毫秒级 | 较慢 |
| 用例设计依据 | 实现逻辑(分支覆盖) | 业务流程 |
| 修复速度 | 快速发现、快速修复 | 较慢 |
4.2 Go 单元测试基础
- 文件名以
_test.go结尾 - 测试方法以
Test开头 - 方法签名:
func TestXxx(t *testing.T)
# 运行单个文件
go test your_test.go
# 运行当前包
go test .
# 运行所有子包
go test ./...
4.3 Table Driven 模式
Go 惯用的测试组织方式,把测试用例像表格一样组织:
func TestSignup(t *testing.T) {
// 步骤 1:定义用例表
testCases := []struct {
name string
mock func(ctrl *gomock.Controller) (service.UserService, service.CodeService)
reqBody string
wantCode int
wantRes string
}{
{
name: "注册成功",
mock: func(ctrl *gomock.Controller) (service.UserService, service.CodeService) {
usersvc := svcmocks.NewMockUserService(ctrl)
// 期望 Signup 被调用一次,返回 nil
usersvc.EXPECT().Signup(gomock.Any(), gomock.Any()).Return(nil)
codesvc := svcmocks.NewMockCodeService(ctrl)
return usersvc, codesvc
},
reqBody: `{"email":"test@test.com","password":"123456","confirmPassword":"123456"}`,
wantCode: 200,
wantRes: "注册成功",
},
// ... 更多用例
}
// 步骤 2:遍历执行每个用例
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
usersvc, codesvc := tc.mock(ctrl)
handler := web.NewUserHandler(usersvc, codesvc)
server := gin.Default()
handler.RegisterRoutes(server)
req := httptest.NewRequest(http.MethodPost, "/users/signup",
strings.NewReader(tc.reqBody))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, req) // 发起调用
assert.Equal(t, tc.wantCode, recorder.Code)
assert.Contains(t, recorder.Body.String(), tc.wantRes)
})
}
}
4.4 测试用例字段完整模板
最完整的测试用例定义应包含:
name:测试场景名(建议中文)预期输入:方法输入预期输出:期望返回值mock:单元测试的 mock 状态before:集成测试的数据准备after:集成测试的验证与清理
最完整的意思是:方法简单的话用不上全部字段。
4.5 mock 工具:mockgen
Go 团队原 golang/mock 已停止维护,改用 uber-go/mock:
go install go.uber.org/mock/mockgen@latest
为接口生成 mock 实现:
mockgen -source=./webook/internal/service/user.go \
-package=svcmocks \
-destination=./webook/internal/service/mocks/user.mock.go
4.6 使用 mock
ctrl := gomock.NewController(t)
defer ctrl.Finish()
usersvc := svcmocks.NewMockUserService(ctrl)
// 期望 Signup 被调用一次,返回 nil
usersvc.EXPECT().Signup(gomock.Any(), gomock.Any()).Return(nil)
// 使用 usersvc 测试
重要原则:设计了几个 mock 调用,使用时就要全部用上,顺序也要对上!不能多、不能少、不能乱。 后续但凡 mock 报错,就检查多了没、少了没、漏了没、条件对上了没。
4.7 测试 Handler:httptest
Handler 测试的难点是构造 HTTP 请求和验证响应。Go 标准库 httptest 提供了:
// 构造请求(不需要真的走网络)
req := httptest.NewRequest(http.MethodPost, "/users/signup",
strings.NewReader(`{"email":"x@y.com"}`))
req.Header.Set("Content-Type", "application/json")
// 准备 Recorder 记录响应
recorder := httptest.NewRecorder()
// 调用,等价于从网络收到请求
server.ServeHTTP(recorder, req)
// 验证
assert.Equal(t, 200, recorder.Code)
4.8 测试 Service
为 Service 依赖的 Repository 生成 mock 即可。例如 Login 方法有三个分支:
{
name: "登录成功",
mock: func(ctrl *gomock.Controller) repository.UserRepository {
repo := repomocks.NewMockUserRepository(ctrl)
// mock 返回一个用户,密码是 bcrypt 加密后的 hello#world123
repo.EXPECT().FindByEmail(gomock.Any(), "test@test.com").
Return(domain.User{Email: "test@test.com",
Password: "$2a$10$xxxxx"}, nil)
return repo
},
email: "test@test.com",
password: "hello#world123",
wantErr: nil,
}
4.9 测试 Repository:mock Cache 和 DAO
Repository 同时依赖 Cache 和 DAO,所以要 mock 两个:
{
name: "查找成功,缓存未命中",
mock: func(ctrl *gomock.Controller) (cache.UserCache, dao.UserDAO) {
c := cachemocks.NewMockUserCache(ctrl)
d := daomocks.NewMockUserDAO(ctrl)
// 缓存未命中
c.EXPECT().Get(gomock.Any(), int64(123)).Return(domain.User{}, cache.ErrKeyNotExist)
// DAO 返回用户
d.EXPECT().FindById(gomock.Any(), int64(123)).Return(entity.User{Id: 123}, nil)
// 期望回写缓存
c.EXPECT().Set(gomock.Any(), domain.User{Id: 123}).Return(nil)
return c, d
},
}
4.10 测试 Cache:mock Redis.Cmdable
redis.Cmdable 本身是接口,可以直接 mock:
mockgen -package=redismocks \
-destination=./webook/internal/repository/cache/redismocks/cmd.mock.go \
github.com/redis/go-redis/v9 Cmdable
4.11 测试 DAO:sqlmock
GORM 本身没有接口,无法直接 mock。可以用 sqlmock 拦截 SQL:
//go get github.com/DATA-DOG/go-sqlmock
db, mock, err := sqlmock.New()
// 用 sqlmock 创建 GORM
gdb, err := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true, // 不调用 show version
}), &gorm.Config{
DisableAutomaticPing: true, // 不 ping 数据库
SkipDefaultTransaction: true, // 不自动开事务
})
// 期望 INSERT 语句
mock.ExpectExec("INSERT INTO `users`").
WithArgs("x@y.com", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
// 测试
err := userDAO.Insert(ctx, user)
assert.NoError(t, err)
为什么不用 sqlite 内存数据库? 因为只要用真数据库就要准备数据,效率太低。
4.12 不是所有场景都能测
bcrypt.Generate 这种只有超时才返回 error 的分支基本测不了。没测到的代码,必须认真 review。
五、集成测试
5.1 集成测试的特点
集成测试验证多个组件组合后的效果。和单元测试比:
- 单元测试用例以实现逻辑为依据
- 集成测试用例以业务流程为依据
- 系统级错误(程序员编码造成的,用户触发不了)很难测
- 并发相关的异常流程也很难测,依赖人工测试
5.2 真·集成测试:以发送验证码为例
困难点:我们希望验证码真的发到手机上,但不可能通过手机来验证。所以用内存短信实现 + 测试人员手工发送双层验证。
func TestCodeSend(t *testing.T) {
// 步骤 1:定义用例表(含 before/after 钩子)
testCases := []struct {
name string
before func(t *testing.T) // 准备数据
after func(t *testing.T) // 验证并清理
phone string
wantCode int
wantResult string
}{
{
name: "发送成功",
before: func(t *testing.T) {},
after: func(t *testing.T) {
// 验证 Redis 中确实有数据
// 验证过期时间符合预期
// 删除测试数据
},
phone: "13800000000",
wantCode: 200,
wantResult: "发送成功",
},
{
name: "未输入手机号",
before: func(t *testing.T) {},
after: func(t *testing.T) {},
phone: "",
wantCode: 400,
},
{
name: "发送太频繁",
before: func(t *testing.T) {
// 提前在 Redis 中塞入一个"刚发过"的验证码
},
after: func(t *testing.T) {
// 验证没有产生新验证码
// 删除测试数据
},
},
}
// 步骤 2:遍历执行(略)
}
5.3 集成测试原则
- 需要手动准备数据(before)
- 测试后要验证数据是否符合预期(after)
- 直接使用的第三方依赖(Redis/MySQL)要验证;调用别人的接口只需验证响应
- 绝对不要让测试用例互相依赖! 一旦依赖,重构就各种崩溃
5.4 单独测试与第三方的复杂交互:Redis Lua 脚本
Lua 脚本封装了复杂逻辑,应该单独测试。它在集成测试之前就测,但不算正规集成测试,只算"集成 Redis 的测试"。
六、第三方服务调用治理
6.1 核心思路
一切不在你控制范围内的东西都是不定时炸弹。不要相信公司外的人,不要相信别的部门的人,不要相信同组的人,最后不要相信昨天的自己。
核心两点:
- 尽量不要搞崩第三方
- 第三方崩了,你的系统还能稳定运行
6.2 客户端限流
腾讯短信 API 限流阈值是 3000/s。与其等被拒绝,不如本地先限制住。
6.3 限流器抽象
把 Gin 的限流逻辑抽取成通用限流器:
// ratelimit/limiter.go
type Limiter interface {
// Limit 返回 true 表示限流了
Limit(ctx context.Context, key string) (bool, error)
}
6.4 在 Send 方法中加限流(直接集成)
type TencentService struct {
svc Service // 被装饰的真正发送者
limiter Limiter // 限流器
}
问题: 这种写法扩展性差。如果将来还要加超时、加熔断,岂不是每次都要改 Send 方法?屎山就是这么改出来的。
6.5 用装饰器模式改进
装饰器模式(大明版定义):不改变原有实现而增加新特性。 装饰器可以层层叠加。
// 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 {
// 步骤 1:先判断限流
limited, err := r.limiter.Limit(ctx, "sms:tencent")
if err != nil {
// 限流器出错,可以选择 fail-open(继续发)或 fail-close(直接返回错误)
return fmt.Errorf("限流器异常: %w", err)
}
if limited {
return errors.New("触发了限流")
}
// 步骤 2:转交给被装饰者执行
return r.svc.Send(ctx, tplId, args, numbers...)
}
装饰器的关键:
- 保持面向接口和依赖注入
svc是被装饰者- 真正业务逻辑转交给
svc执行 - 装饰器只做一件事(这里:判断限流)
flowchart LR
A[调用方] --> R[RatelimitSMSService
限流]
R --> T[TimeoutSMSService
超时]
T --> Re[RetrySMSService
重试]
Re --> S[TencentService
真正发送]6.6 装饰器可以层层叠加
// 限流 → 超时 → 重试 → 真正发送
svc := tencent.NewService(client, appId, sign)
svc = NewTimeoutSMSService(svc, 3*time.Second)
svc = NewRetrySMSService(svc, 3)
svc = NewRatelimitSMSService(svc, limiter)
// 最终调用方拿到的是层层包装后的 svc
6.7 装饰器的两种实现
// 方式一:组合 + 嵌入接口(用户可以绕开装饰器)
type RatelimitSMSServiceV1 struct {
Service // 嵌入接口,可以只实现部分方法
limiter Limiter
}
// 缺点:用户可以直接调用 svc.Service.Send() 绕开限流
// 方式二:不嵌入接口(必须实现全部方法)
type RatelimitSMSService struct {
svc Service
limiter Limiter
}
// 优点:有效阻止绕开;缺点:必须实现全部方法
6.8 装饰器 vs 适配器模式
| 模式 | 接口 | 用途 |
|---|---|---|
| 装饰器 | 同一个接口 | 不改变接口加新特性 |
| 适配器 | 不同接口 | 把 A 接口适配到 B 接口 |
6.9 单元测试装饰器
由于 Limiter 和 Service 都是接口,都可以 mock,所以测试装饰器很容易:
{
name: "限流触发",
mock: func(ctrl *gomock.Controller) (sms.Service, ratelimit.Limiter) {
svc := smsmocks.NewMockService(ctrl)
// 期望 Send 不被调用
limiter := ratelimitmocks.NewMockLimiter(ctrl)
limiter.EXPECT().Limit(gomock.Any(), gomock.Any()).
Return(true, nil) // 返回 true 表示限流
return svc, limiter
},
wantErr: errors.New("触发了限流"),
}
⚠️ 新手必踩的坑:装饰器别用"嵌入接口"那版。方式一嵌入
Service接口后,调用方拿到结构体可以直接.Service.Send()绕过限流/超时/重试,等于安全网形同虚设。生产上用方式二(只持有svc字段),强制所有调用都过装饰器。
七、工程实践要点
7.1 性能优化
- 先压测,再优化。不要凭感觉
- 性能瓶颈通常在:加密(CPU)、数据库查询(IO)
- 缓存只放在高频读场景
- 缓存出错也查 DB(业务可用优先),或缓存出错不查 DB(保护 DB 优先),视业务而定
7.2 接口与依赖
- 核心逻辑一定面向接口编程
- 超前设计,最小化实现——只留接口,不写多余实现
- 依赖注入风格贯穿始终
NewXXX在配合 wire 时最好返回接口
7.3 测试
- 单元测试覆盖率目标 80% 以上
- 优先用 Table Driven
- mock 调用:不能多、不能少、不能乱
- 没测到的代码必须 review
- 集成测试用例之间绝对不能依赖
7.4 第三方治理
- 客户端限流(保护第三方,也保护自己)
- 超时(必须设置,否则会被慢响应拖垮)
- 重试(注意幂等性)
- 熔断(防止故障扩散)
- 用装饰器模式叠加这些特性,不要侵入业务代码
7.5 升职加薪:提高已有系统的单元测试覆盖率
为添加必要测试花费的精力完全值得:
- 联调很快
- 后续变更容易发现问题
- 方便重构
别人启动各种服务器部署联调、从界面点点点找 BUG 的时候,你已经下班回家了。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 压测发现瓶颈通常在哪两类?为什么引入缓存要在 Repository 层而非 Service 层接入?
- 短信 / 验证码 / 登录为什么分成三层?分别对应的"变化点"是什么?
- 分布式环境下"查验证码 → 写验证码"为什么不能用
sync.Mutex解决?正确的原子方案是什么? - 依赖注入和非依赖注入的核心区别是什么?
wire解决的痛点是什么、又有什么局限? - 装饰器模式和适配器模式的区别是什么?为什么装饰短信服务不能"嵌入接口"?
动手练习(建议真做一遍):
- 加一层装饰器:在
RatelimitSMSService之外再写一个TimeoutSMSService(超时控制),用context.WithTimeout实现,并补一个"超时返回 error"的单测。 - Redis Lua 单测:把"发送太频繁返回 -1"的 Lua 脚本用真实 Redis(或
miniredis)写个集成测试,验证 1 分钟内第二次发送被拒。 - Table-Driven 补齐分支:给
CodeService.Verify写三个用例——验证通过(删 key)、失败 3 次作废、验证码不存在,跑绿并看覆盖率。
本章小结
第4章内容密度很高,从性能优化、需求分析到接口抽象、依赖注入、单元测试、集成测试、第三方治理,覆盖了一个 Go 后端工程师日常工程化的核心技能。
关键回顾:
- 压测 → 找瓶颈 → 引入缓存:用 wrk 压测,瓶颈一般在加密和数据库。引入 UserCache 时在 Repository 层集成
- 业务分层:短信服务 → 验证码服务 → 登录服务,每一层都是独立可复用的模块
- 并发安全:分布式环境下的"check-then-act"用 Redis Lua 脚本解决
- 面向接口 + 依赖注入:留出扩展点,让代码可替换、可测试
- wire:用代码生成简化依赖组装
- 单元测试:Table Driven + mockgen + sqlmock + httptest
- 集成测试:以业务流程为依据,测试前后准备/清理数据,用例之间不能依赖
- 第三方治理:限流、超时、重试、熔断,用装饰器模式无侵入叠加
掌握这些内容,你已经具备了从 0 到 1 写出可测试、可维护、可扩展的 Go 后端代码的能力。下一章(第05章)进入 SSO 与微信扫码登录,会用到这里的接口抽象与装饰器思想。