上来先盯着自己的 handler 盯了一会,越看越不对劲。
写到这一步,前面的 handler 基本能跑了,但越看越觉得别扭:日志全是 log.Fatal 跟 fmt.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 number。
400、500写死,未来换成http.StatusBadRequest之类会更易读。 - 冷却期返回 500 不对。那是客户端频率超限,应该是
429 Too Many Requests。返回 500 意味着「我服务坏了」,但其实是「你手太快了」。 response.Err的签名也怪怪的。原实现居然是errors.New(errMsg).Error(),把字符串包成 error 再立刻拆回字符串,绕了一圈完全没意义。而且和ErrMsg重复。Me里把 401 伪装成 404。c.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.Err 和 ErrMsg 合并成 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 自动解析。一次改一点就好。