盯 上来先盯着自己的 handler 盯了一会,越看越不对劲。

写到这一步,前面的 handler 基本能跑了,但越看越觉得别扭:日志全是 log.Fatalfmt.Println 混着来,错误常量到处散,handler 里又写参数校验又发邮件又操作 Redis。这一篇记录一次小重构,主题是三件事:日志、错误、分层

1. 引入 zlog:别再 log.Fatal 到处飞#

第二篇已经念叨过为什么要换日志库,这一篇是真正动手的版本。选型没犹豫,直接 go.uber.org/zap,但只用它的 SugaredLogger,再在外面包一层 internal/zlog,对外只暴露一组简洁的接口。

// internal/zlog/zlog.go
func Info(msg string, kv ...any)  { S.Infow(msg, kv...) }
func Warn(msg string, kv ...any)  { S.Warnw(msg, kv...) }
func Error(msg string, kv ...any) { S.Errorw(msg, kv...) }
func Fatal(msg string, kv ...any) { S.Fatalw(msg, kv...) }

调用风格统一成 key-value 可变参,和 log/slog 的用法一致:

zlog.Info("服务启动中", "env", env, "port", port)
zlog.Error("Redis 写入失败", "key", key, "ttl", expire, "err", err)

一开始我是用原生 zap.Field 的写法,调用点长这样:

zlog.Info("服务启动中",
    zap.String("env", config.CFG.Server.Env),
    zap.Int("port", config.CFG.Server.Port))

写两行就嫌啰嗦了。Sugared 版本虽然损失了一点点类型安全,但换来的书写体验好太多,项目内部日志点其实根本不在乎那一点点性能差。

配置上在 config.toml 加了 [log] 段:

[log]
level  = "debug"
format = "console"
file   = ""

format = "console" 是开发时的彩色输出,上线切 json 就行。file 留空只打 stdout,非空则同时写文件(JSON 格式)。main.go 里只要 zlog.Init()defer zlog.Sync() 一把梭。

2. Redis 包:顺手修了一个会退出进程的 bug#

Redis 代码原本在 internal/dao/redis.go,和 MySQL 的 DAO 堆在一起。越看越觉得它俩不是一个层级的东西 —— 一个是持久化,一个更像是跨请求的短期状态仓库,放一起总是怪怪的,索性挪到独立包 internal/redis/

搬的过程中顺手看了眼 GetValueByKey,被自己惊到了:

// 原版
func GetValueByKey(key string) (string, error) {
    value, err := RDB.Get(ctx, key).Result()
    if err != nil {
        if errors.Is(err, redis.Nil) {
            log.Fatal("这个Key不存在")   // ← ???
            return "", nil
        }
    }
    return value, err
}

redis.Nil业务上非常正常的情况,key 不存在嘛,怎么就能把进程 log.Fatal 掉?这要是上线了,一次缓存未命中整个服务就 OOM 自杀,想想都后怕。

顺带把所有 log.Fatal / log.Println 全换成 zlog,然后补齐工具方法:

func DelValueByKey(key string)                                                   // 删
func ExistsKey(key string) bool                                                   // 判断存在
func ExpireKey(key string, expire time.Duration) error                            // 续期
func SetNXValueByKeyExpire(key, value string, expire time.Duration) (bool, error) // 原子不存在才写

最后那个 SetNX 有故事,单独说。

3. SetNX 的竞态,和一个被弃用的 API#

写完邮箱验证码的「一分钟只能申请一次」之后,GetEmailCode 里有这么一段:

// 问题版本
if redis.ExistsKey(key) {
    response.Err(c, 500, consts.SystemEmailBusy)
    return
}
code := utils.SixUUID()
err := redis.SetValueByKeyExpire(key, code, time.Minute)

经典 check-then-act:两个请求同时进来,都过了 ExistsKey 那道门,然后都去 Set,限流直接形同虚设。改法也是教科书级 ——用 Redis 的 SET key value NX EX ttl,一次原子命令搞定「不存在才写 + 顺便设过期」。

本来打算写 RDB.SetNX(...),IDE 立刻划上一道删除线:

Deprecated: Use Set with NX option instead as of Redis 2.6.12.

也就是说自 Redis 2.6.12 起 SETNX 命令就应该被 SET ... NX EX ttl 替代了,go-redis 跟进把方法名也废了。改用 SetArgs

func SetNXValueByKeyExpire(key string, value string, expire time.Duration) (bool, error) {
    _, err := RDB.SetArgs(ctx, key, value, redis.SetArgs{
        Mode: "NX",
        TTL:  expire,
    }).Result()
    if err != nil {
        if errors.Is(err, redis.Nil) {
            return false, nil // key 已存在,未写入
        }
        zlog.Error("Redis SetArgs NX 失败", "key", key, "ttl", expire, "err", err)
        return false, err
    }
    return true, nil
}

key 已存在时 Redis 返回 nil,对应 go-redis 的 redis.Nil,所以用 errors.Is 判一下就能区分「没抢到」和「真错误」。

4. 错误枚举集中到 pkg/errs#

最早的 sentinel error 是散在 service/auth.go 里的:

var (
    ErrEmailTaken         = errors.New("邮箱已被注册")
    ErrInvalidCredentials = errors.New("邮箱或密码错误")
    ErrInvalidRefresh     = errors.New("refresh token 无效或已过期")
)

handler 里要 errors.Is(err, service.ErrEmailTaken) 去匹配。问题来了:如果将来别的 service 也想共用同一个「邮箱已被注册」的语义怎么办?再复制一份肯定不行,这会让 errors.Is 失效(两个不同的 errors.New 是两个不同的指针)。

所以先把它们挪到 internal/service/errors.go,后来又意识到错误字典本身不应该属于 service 层,它更像是一份公共契约 —— handler 要看它、service 要抛它、将来 middleware 可能也要用。最终搬到 pkg/errs/errs.go

// pkg/errs/errs.go
package errs

import "errors"

var (
    ErrEmailTaken         = errors.New("邮箱已被注册")
    ErrInvalidCredentials = errors.New("邮箱或密码错误")
    ErrInvalidRefresh     = errors.New("refresh token 无效或已过期")
    ErrEmailCodeBusy      = errors.New("验证码发送过于频繁")
    ErrSendMail           = errors.New("邮件发送失败")
)

大家统一 errors.Is(err, errs.ErrXxx),没有歧义,以后再加也只改这一个文件。

5. handler 真正只做 handler 应该做的事#

这是这次最大头的重构。先贴原版的 GetEmailCode

func GetEmailCode(c *gin.Context) {
    var req requests.RegisterEmailCodeReq
    if err := c.ShouldBindJSON(&req); err != nil {
        response.Err(c, 400, consts.SystemError)
        return
    }
    key := consts.RedisSendEmailCodeKey + req.Email
    if redis.ExistsKey(key) {
        response.Err(c, 500, consts.SystemEmailBusy)
        return
    }
    code := utils.SixUUID()
    err := redis.SetValueByKeyExpire(key, code, time.Minute)
    if err != nil {
        response.Err(c, 500, consts.SystemError)
        return
    }
    err = utils.SentMail(req.Email, code)
    if err != nil {
        redis.DelValueByKey(key)
        response.Err(c, 500, consts.SystemError)
        return
    }
    response.OK(c, consts.SystemSendSuccess)
}

问题清单一数一长串:

  • 违反分层。CLAUDE.md 里明明白白写着 handler → service → dao,这里 handler 直接伸手去操作 Redis、调发邮件工具,service 层被跳过了。
  • check-then-set 有竞态。第 3 节已经聊过。
  • 错误码是 magic number400500 写死,未来换成 http.StatusBadRequest 之类会更易读。
  • 冷却期返回 500 不对。那是客户端频率超限,应该是 429 Too Many Requests。返回 500 意味着「我服务坏了」,但其实是「你手太快了」。
  • response.Err 的签名也怪怪的。原实现居然是 errors.New(errMsg).Error(),把字符串包成 error 再立刻拆回字符串,绕了一圈完全没意义。而且和 ErrMsg 重复。
  • Me 里把 401 伪装成 404c.Get(CtxUserID) 没取到就让 uid 为 0,然后去查库返回「用户不存在」。未登录和用户不存在是两件事。

一条条修:

首先把业务逻辑拆出去,新建 internal/service/email.go

func SendEmailCode(ctx context.Context, email string) error {
    key := consts.RedisSendEmailCodeKey + email
    code := utils.SixUUID()

    ok, err := redis.SetNXValueByKeyExpire(key, code, consts.EmailCodeTTL)
    if err != nil {
        return err
    }
    if !ok {
        return errs.ErrEmailCodeBusy
    }

    if err := utils.SentMail(email, code); err != nil {
        redis.DelValueByKey(key)
        zlog.Error("发送验证码邮件失败", "email", email, "err", err)
        return errs.ErrSendMail
    }
    return nil
}

response.ErrErrMsg 合并成 response.Fail(c, status, msg)。handler 变得很清爽:

func GetEmailCode(c *gin.Context) {
    var req requests.RegisterEmailCodeReq
    if err := c.ShouldBindJSON(&req); err != nil {
        response.Fail(c, http.StatusBadRequest, err.Error())
        return
    }
    if err := service.SendEmailCode(c.Request.Context(), req.Email); err != nil {
        switch {
        case errors.Is(err, errs.ErrEmailCodeBusy):
            response.Fail(c, http.StatusTooManyRequests, consts.SystemEmailBusy)
        case errors.Is(err, errs.ErrSendMail):
            response.Fail(c, http.StatusBadGateway, consts.SystemMailFail)
        default:
            response.Fail(c, http.StatusInternalServerError, consts.SystemError)
        }
        return
    }
    response.OK(c, consts.SystemSendSuccess)
}

handler 现在只做三件事:绑参数、调 service、把 sentinel error 映射成 HTTP 状态码。魔法数字全换成 http.StatusXxx。邮件失败用的是 502 Bad Gateway,因为它本质是上游依赖(SMTP)出了问题,不是我自己坏了。

Me 也顺手修:

func Me(c *gin.Context) {
    uid, exists := c.Get(consts.CtxUserID)
    id, ok := uid.(uint64)
    if !exists || !ok || id == 0 {
        response.Fail(c, http.StatusUnauthorized, "未登录")
        return
    }
    // ...
}

6. 顺带做的一些小事#

  • consts 补了 EmailCodeTTL = time.Minute,之前那个魔法数字直接写在了 service 调用里。
  • consts 补了 SystemMailFail 文案,和新加的 502 对应。
  • 原本 handler 里跨层 import "BlahajChatServer/internal/redis" 的两行也一并删掉了,handler 现在只认 service。

小结#

自豪 这一轮下来整个项目清爽了不少,虽然还有很多坑。

回头看这次改的东西,其实没有任何新功能上线,全是内部收拾。但确实舒服了很多 —— 日志能看、错误能 errors.Is、handler 不再堆业务、竞态也封上了。接下来会回去补 Register / Login / Refresh / Logout 的真正实现,有了这一轮铺垫,写起来会顺手得多。

还有几个留待下一次:邮件同步发送阻塞请求线程(应该异步)、pkg/errs 里的错误没有错误码映射(可以加一层 HTTP status 映射表)、response.Fail 收字符串其实更想收 error 自动解析。一次改一点就好。

本站总访问量  ·  访客数
你的IP 获取中…