接口抽象、短信服务与测试

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

@

学习目标

  1. 掌握用 wrk 压测接口的基本流程,能够定位 Web 服务的性能瓶颈,并知道何时该引入缓存
  2. 学会从需求中识别"变化点",独立设计出短信服务、验证码服务、登录服务三层抽象,并理解为什么要这么分层
  3. 理解面向接口编程与依赖注入(DI)的本质,能够使用 wire 简化依赖组装,掌握控制反转(IoC)的概念
  4. 能独立为 Handler / Service / Repository / Cache / DAO 各层编写单元测试,熟练使用 mockgensqlmock
  5. 能够编写基于真实 Redis/MySQL 的集成测试,理解单元测试与集成测试的边界
  6. 掌握第三方服务调用的治理思路:客户端限流、超时、重试、熔断,以及装饰器模式在其中的应用

前置知识(如果下面任意一点生疏,先回看对应章):

  • 第02章 GORM + MySQL、Redis:知道 DAO 怎么写、连接怎么建。
  • 第03章 JWT / Redis Session:知道登录态怎么维护,方便理解"登录服务"这一层。
  • Go 接口与 gomock 基础:知道接口、mock 是什么。
  • 基本并发常识:知道 check-then-act 在并发下会出问题。

本章你会动手做的事

  • wrk 把本地注册/Profile 接口压一遍,亲手看到 DB/加密是瓶颈。
  • 把"短信 → 验证码 → 登录"三层抽象各写一个接口与实现,用 wire 串起来。
  • mockgenCodeService 生成 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:持续时间,如 1s1m
  • -c:并发连接数
  • -s:Lua 脚本,用来构造 POST body 等动态请求

压测前的准备:

  1. 临时启用 JWT(比 Session 容易测)
  2. 把登录态有效期改为 30 分钟,确保压测 profile 时 token 没过期
  3. 去掉限流中间件,否则压测会被自己的限流挡住

压测 Profile 接口(读接口,需要登录态):

wrk -t1 -d1s -c2 -s ./scripts/wrk/profile.lua \
    http://localhost:8080/users/profile

profile.lua 中要修改 User-AgentAuthorization 头,模拟已登录用户。

flowchart LR
    W[wrk 压测] --> T{瓶颈在哪?}
    T -- 加密算法 bcrypt --> C[CPU 瓶颈]
    T -- 数据库查询 --> I[IO 瓶颈]
    C --> RC[引入缓存/优化算法]
    I --> RC

1.2 性能瓶颈分析与引入缓存

压测后通常会看到瓶颈在两个地方:

  • 加密算法(如 bcrypt)耗费 CPU
  • 数据库查询耗费 IO

针对数据库查询,引入 Redis 缓存是常见手段。但不要直接在业务代码里写 Redis 调用,而要为业务封装一个专属的 UserCache

类比:你不会在每道菜里直接操作冰箱,而是有一个"食材管理员"统一存取。UserCache 就是那个管理员——业务方只说"给我 id=123 的用户”,不用管数据在 Redis 还是 MySQL、key 怎么拼。

1.3 业务专属缓存抽象的作用

为什么需要一个 UserCache,而不是直接用 redis.Client?三个原因:

  1. 屏蔽过期时间:调用方不需要关心 key 的 TTL 是多久
  2. 屏蔽 key 结构:调用方不用知道 user:info:123 这种 key 是怎么拼的
  3. 屏蔽序列化协议:调用方不用关心结构体是怎么转成 []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 挂了业务依然可用数据库可能被压垮
缓存出错不查 DBRedis 挂了业务也不可用保护数据库,但牺牲可用性
flowchart TD
    Q[FindById] --> C{缓存命中?}
    C -- 是 --> R[直接返回]
    C -- 否/出错 --> D[查数据库]
    D --> W[回写缓存]
    W --> R

1.5 登录要不要也缓存?

不要。登录是非常低频的操作,正常网站好几天才让你登录一次。即使按 email 缓存用户信息,命中率也极低,收益不大。缓存要放在高频读的场景,比如 Profile。

1.6 Redis 数据结构速览

类型用途
string简单 KV,如缓存用户 JSON
list链表,如消息队列
set集合,如标签
sorted set有序集合,如排行榜
hash字典,如存储对象多个字段

不常用的:bitmaps、JSON、streams、bitfields、time series。


二、短信验证码登录

2.1 需求分析

国内互联网公司需求描述往往很粗略,工程师要掌握快速需求分析的技巧:

  1. 参考竞品:大部分功能都是"你抄我我抄你"
  2. 从功能/非功能两个角度分析
    • 功能角度:明确做到哪些功能,做到什么程度
    • 非功能角度:安全性 > 扩展性 > 性能(中小型公司)
  3. 从正常/异常流程两个角度思考:区分业务异常和技术异常

2.2 参考竞品得到的功能特性

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

2.3 功能与非功能需求

功能:

  • 用户用手机号收验证码登录,新号码直接注册新账号
  • 验证码有效期 10 分钟
  • 同一手机号 1 分钟内只能发送 1 次
  • 登录/注册成功后跳转首页

非功能:

  • 保护系统,防止攻击者恶意发短信

2.4 深入分析:服务划分

“手机验证码登录"包含两件事:验证码登录。它们强耦合吗?别的业务也会用验证码吗?是的。

所以验证码应该是独立功能。再进一步,验证码通过短信发送,短信会不会换供应商?会。

于是有两个变化点

  1. 不同业务都要用短信和验证码
  2. 可能换短信供应商

服务划分(叠床架屋式设计):

  • 独立的短信发送服务
  • 在短信服务上封装验证码服务
  • 在验证码服务上封装登录功能

这就是"业务上的超前半步设计”——只留出接口,不提前实现。

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 分钟内只能发 1 次,有效期 10 分钟
  2. 防止暴力破解
    • 验证通过后立即失效,不能再用
    • 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 一起存,方便原子更新。

⚠️ 新手必踩的坑:分布式下别用应用层锁。很多人直觉地给 Sendsync.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.Scannerdriver.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

使用步骤:

  1. wire.go 中注册各种初始化方法(用 wire.Build
  2. 运行 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 核心思路

一切不在你控制范围内的东西都是不定时炸弹。不要相信公司外的人,不要相信别的部门的人,不要相信同组的人,最后不要相信昨天的自己。

核心两点:

  1. 尽量不要搞崩第三方
  2. 第三方崩了,你的系统还能稳定运行

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 单元测试装饰器

由于 LimiterService 都是接口,都可以 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 的时候,你已经下班回家了。


自测题与动手练习

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

  1. 压测发现瓶颈通常在哪两类?为什么引入缓存要在 Repository 层而非 Service 层接入?
  2. 短信 / 验证码 / 登录为什么分成三层?分别对应的"变化点"是什么?
  3. 分布式环境下"查验证码 → 写验证码"为什么不能用 sync.Mutex 解决?正确的原子方案是什么?
  4. 依赖注入和非依赖注入的核心区别是什么?wire 解决的痛点是什么、又有什么局限?
  5. 装饰器模式和适配器模式的区别是什么?为什么装饰短信服务不能"嵌入接口"?

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

  1. 加一层装饰器:在 RatelimitSMSService 之外再写一个 TimeoutSMSService(超时控制),用 context.WithTimeout 实现,并补一个"超时返回 error"的单测。
  2. Redis Lua 单测:把"发送太频繁返回 -1"的 Lua 脚本用真实 Redis(或 miniredis)写个集成测试,验证 1 分钟内第二次发送被拒。
  3. Table-Driven 补齐分支:给 CodeService.Verify 写三个用例——验证通过(删 key)、失败 3 次作废、验证码不存在,跑绿并看覆盖率。

本章小结

第4章内容密度很高,从性能优化、需求分析到接口抽象、依赖注入、单元测试、集成测试、第三方治理,覆盖了一个 Go 后端工程师日常工程化的核心技能。

关键回顾:

  1. 压测 → 找瓶颈 → 引入缓存:用 wrk 压测,瓶颈一般在加密和数据库。引入 UserCache 时在 Repository 层集成
  2. 业务分层:短信服务 → 验证码服务 → 登录服务,每一层都是独立可复用的模块
  3. 并发安全:分布式环境下的"check-then-act"用 Redis Lua 脚本解决
  4. 面向接口 + 依赖注入:留出扩展点,让代码可替换、可测试
  5. wire:用代码生成简化依赖组装
  6. 单元测试:Table Driven + mockgen + sqlmock + httptest
  7. 集成测试:以业务流程为依据,测试前后准备/清理数据,用例之间不能依赖
  8. 第三方治理:限流、超时、重试、熔断,用装饰器模式无侵入叠加

掌握这些内容,你已经具备了从 0 到 1 写出可测试、可维护、可扩展的 Go 后端代码的能力。下一章(第05章)进入 SSO 与微信扫码登录,会用到这里的接口抽象与装饰器思想。

About Me

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

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

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

目标

学AI,加油!加油!