Files
hotime/.cursor/plans/logging_system_redesign_4a33ad38.plan.md
T
hoteas 9a9b9c83ff refactor(logging): 迁移日志记录到 Zerolog 并优化错误处理
- 将日志记录库从 Logrus 替换为 Zerolog,提升性能和灵活性
- 更新各个模块的日志记录方式,确保一致性
- 优化错误处理逻辑,确保在发生错误时能够正确记录并传递错误信息
- 移除不再使用的错误处理字段,简化代码结构
- 更新相关文档以反映新的日志记录和错误处理机制
2026-04-13 00:38:50 +08:00

16 KiB
Raw Blame History

name, overview, todos, isProject
name overview todos isProject
Logging System Redesign Replace logrus with zerolog, remove mode config, redesign logLevel/logFile, simplify Error to pure storage, add DBState for atomic DB state access, and provide a clean application-layer logging API.
id content status
add-zerolog-dep 添加 zerolog 依赖到 go.mod,移除 logrusvendor 中 wechat 等三方库自带的 logrus 不影响) completed
id content status
rewrite-log-package 重写 log/ 包:基于 zerolog 实现 Logger 结构体、NewLogger 工厂、CallerMarshalFunc 智能调用者过滤、TemplateFileWriter 带缓冲和模板路径切换 completed
id content status
delete-error-add-dbstate 删除 common/error.goError 类型不再需要)、新增 common/dbstate.goDBState 包装 Query+Data+Error)、Logger 内置 ERROR 级别环形缓冲 completed
id content status
update-application 改造 application.go:删除 mode 配置、Application.Log 类型变更、SetConfig 中日志初始化用 logLevel 控制一切、DB 缓存始终开启、Cache-Control 跟 logLevel 联动 completed
id content status
update-db 改造 db/db.go 和 db/query.go:用 DBState 替换 LastQuery+LastData+LastErr、HoTimeDB.Log 类型变更、SQL 日志格式优化、消除重复打印 completed
id content status
update-var-config 更新 var.go 默认配置(删 mode、logLevel 默认 3)和 ConfigNote、example 配置文件 completed
id content status
update-codegen 适配 code/makecode.go 中的 Error 使用(移除 Logger 相关) completed
id content status
update-tests 适配 testing_helper.go 和 db/transaction_test.go 中的日志创建 completed
false

HoTime 日志系统全面重写方案

一、现状问题分析

1. logrus 已不适合继续使用

  • logrus 已进入 维护模式,不再开发新功能,仅修复安全问题
  • 性能差:依赖 mutex 锁和反射,在高并发下显著慢于现代替代品
  • 当前使用版本 v1.8.1go.mod line 13

2. 现有日志配置形同虚设

  • logLevelvar.go line 59 有定义和注释,但 从未在代码中接入 logrus
  • mode 承担了过多职责(0/1/2/3 四种),但代码中实际只区分 == 0!= 0,且 mode 2/3 无独立分支

3. Error 类型与日志耦合导致重复打印

  • SetError 内部隐式 Warn 打印 + query.go defer 再打一次 = 同一错误打印两次
  • LastQuery/LastData/LastErr 用不同的锁保护,存在读到不一致状态的风险

二、日志库选型:zerolog

推荐 zerolog

  • 零内存分配,性能远超 logrus(快 5-10 倍)
  • 内置 Caller()ConsoleWriter(彩色)、MultiLevelWriter(多输出)
  • 无 Go 版本要求(go.mod 保持 go 1.16
  • 不选 log/slog:需 Go 1.21+,升级成本太大

三、配置参数重新设计

删除 mode

mode 原来控制的行为全部转移:

  • DB 缓存mode 的拦截是多余的,cache 本身已是"配置即启用"设计(cache.memory.db/cache.redis.db 等控制)。删除 modeSetCache 中始终挂载 that.Db.HoTimeCache,是否真正缓存由 cache 配置决定
  • Cache-Control:跟 logLevel 联动 -- logLevel > 0 时设 no-cachelogLevel == 0public
  • SQL 日志打印:完全由 logLevel 控制(logLevel > 0 时打印 SQL
  • 代码生成:本来就由 codeConfig[].mode 独立控制,不受影响

logLevel -- 保留,真正接管日志系统

  • 0 = 仅 error(生产环境推荐)
  • >=1 = 全部打印(info/warn/error/debug/SQL 全开)
  • 默认值 1

简单二分:要么只看错误,要么全看。Cache-Control 同理:logLevel==0public>0no-cache

logFile -- 保留

路径模板不变(a/b/c/20060102150405.txt),应用日志写入此文件。

logHistory -- 新增

Logger 错误历史缓冲条数,默认 100。设为 0 关闭历史记录。可根据设备内存情况调小。

webConnectLogShow / webConnectLogFile -- 保留独立

保留访问日志独立的理由(业务角度):

  • 访问日志量远大于应用日志,混在一起文件膨胀、查找困难
  • 生产环境需要不同的轮转策略
  • Nginx/Apache 都是 access.log 和 error.log 分开的行业惯例

配置保持不变:

  • webConnectLogShow:默认 true
  • webConnectLogFile:独立文件路径

四、新日志包设计 (log/)

核心结构

package log

type Logger struct {
    zl zerolog.Logger
}

func NewLogger(logLevel int, logFile string, showCaller bool) *Logger

func (l *Logger) Debug() *zerolog.Event
func (l *Logger) Info() *zerolog.Event
func (l *Logger) Warn() *zerolog.Event
func (l *Logger) Error() *zerolog.Event

func (l *Logger) Debugf(format string, v ...interface{})
func (l *Logger) Infof(format string, v ...interface{})
func (l *Logger) Warnf(format string, v ...interface{})
func (l *Logger) Errorf(format string, v ...interface{})

控制台输出格式

2026-04-12 15:04:05 |INFO|  用户登录成功                          [app/admin/user.go:45]
2026-04-12 15:04:05 |WARN|  数据库连接超时                        [app/admin/user.go:78]
2026-04-12 15:04:05 |ERROR| 支付回调失败 order_id=456              [app/payment/callback.go:32]

格式:时间 | 类型标志 | 日志内容 | [代码位置],用 [file:line] 而非 logrus 的 line="file:line"

文件输出格式(JSON

{"time":"2026-04-12T15:04:05+08:00","level":"info","caller":"app/admin/user.go:45","message":"用户登录成功"}

调用者智能过滤

复用并优化现有 log/logrus.gofindCaller / isHoTimeFrameworkFile 逻辑,实现为 zerolog 的 CallerMarshalFunc

文件写入优化

TemplateFileWriter:带缓冲的 Writer,按时间模板自动切换文件路径,保持 time.Now().Format(path) 语义。


五、删除 Error 类型 + DBState + Logger 错误历史

删除 common.Error

common.Error 完全删除,不再需要。原因:

  • Error 存在的意义是"存错误 + 自动打印",打印已移到 Log,只剩存储
  • 审查全部用法后,每个 SetError 调用点都可以直接用 Log.Error() 替代
  • InitDb 返回 *Error 从未被使用(调用方都是 _ = that.InitDb(...)
  • ConnectFunc(err ...*Error) 可简化为返回 error
  • 需要"查最近的错误"由 Logger 错误历史替代

Logger 错误历史(环形缓冲)

Logger 内置 ERROR 级别记录的环形缓冲(默认 100 条),打印即存储,零额外操作:

type ErrorRecord struct {
    Err    error
    Msg    string
    Time   time.Time
    Caller string
}

// Logger 内部
type Logger struct {
    zl        zerolog.Logger
    errors    []ErrorRecord  // 环形缓冲
    errorsMu  sync.RWMutex
    maxErrors int            // 默认 100
}

// 每次 Log.Error() 自动存入缓冲
// 获取最近 N 条错误(不传则返回全部已存储的,不超过 maxErrors)
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord

缓冲大小通过配置项 logHistory 控制,默认 100,可根据设备内存调整。

应用层使用:

// 正常打印,自动存入历史
that.Log.Error().Err(err).Msg("支付失败")

// 获取最近 10 条错误
for _, e := range that.Log.GetRecentErrors(10) {
    fmt.Printf("[%s] %s: %v  %s\n", e.Time.Format("15:04:05"), e.Msg, e.Err, e.Caller)
}

// 获取全部已存储的错误
all := that.Log.GetRecentErrors()

DBState -- DB 操作状态原子包装(新增)

新增 common/dbstate.go,将 LastQuery + LastData + LastErr 合为一体,一把锁保护原子读写。

核心语义:记录最后一次 DB 操作的完整状态。每次 SQL 执行后更新,用于判断"SQL 执行失败"还是"执行成功但结果为空"(如 UPDATE 影响 0 行)。

// DBSnapshot 操作状态快照(只读,安全传递)
type DBSnapshot struct {
    Query string
    Data  []interface{}
    Err   error
}

type DBState struct {
    query string
    data  []interface{}
    err   error
    mu    sync.RWMutex  // 零值即可用
}

// Set 原子更新完整状态(每次 Query/Exec 执行后调用)
func (s *DBState) Set(query string, data []interface{}, err error) {
    s.mu.Lock()
    s.query = query
    s.data = data
    s.err = err
    s.mu.Unlock()
}

// Get 返回一致性快照(query/data/err 保证是同一次操作的)
func (s *DBState) Get() DBSnapshot {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return DBSnapshot{Query: s.query, Data: s.data, Err: s.err}
}

// SetError 只更新错误(Ping 等无 SQL 的场景)
func (s *DBState) SetError(err error) {
    s.mu.Lock()
    s.err = err
    s.mu.Unlock()
}

// GetError 快速判断最后操作是否出错
func (s *DBState) GetError() error {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.err
}

使用场景

db.Exec("UPDATE stats SET count=count+1 WHERE id=?", id)
rows, _ := result.RowsAffected()
if rows == 0 {
    if state := db.GetLastState(); state != nil {
        // SQL 执行失败,state.Err 有具体错误
    } else {
        // SQL 成功,只是没有匹配的行
    }
}

HoTimeDB 结构体变更

HoTimeDB 中原来的三个独立字段合并为私有 lastState,通过方法暴露,同时删除 Mode

type HoTimeDB struct {
    *sql.DB
    // ...
    Log       *log.Logger  // 从 *logrus.Logger 改为新 Logger
    lastState DBState      // 私有,替换 LastQuery + LastData + LastErr
    // 删除: Mode int
    // 删除: LastQuery string
    // 删除: LastData []interface{}
    // 删除: LastErr *Error
}

// 唯一对外方法:nil = 成功,非 nil = 失败(附带完整上下文)
func (db *HoTimeDB) GetLastState() *DBSnapshot

内部逻辑:lastState 始终记录 query/data/err(供日志使用),但 GetLastState() 只在有错误时返回非 nil

func (db *HoTimeDB) GetLastState() *DBSnapshot {
    db.lastState.mu.RLock()
    defer db.lastState.mu.RUnlock()
    if db.lastState.err == nil {
        return nil  // 没错误,不需要关心细节
    }
    return &DBSnapshot{
        Query: db.lastState.query,
        Data:  db.lastState.data,
        Err:   db.lastState.err,
    }
}

应用层使用:

// 一行判断 + 取详情
if state := db.GetLastState(); state != nil {
    // SQL 出错了,state.Err / state.Query / state.Data 全有
    that.Log.Error().Err(state.Err).Str("sql", state.Query).Msg("SQL 执行失败")
}
// nil = SQL 执行成功,不用管

六、DB 日志格式优化

修复重复打印

SetError 不再打印日志,query.go defer 统一负责 SQL 日志 -- 问题根除。

输出格式

无错误时(DEBUG 级别):

2026-04-12 15:04:05 |DEBUG| SQL: SELECT id,worker_id FROM `dingtalk` WHERE `state`=?  DATA: [0]  [dd/sync.go:63]

有错误时(自动升为 ERROR 级别):

2026-04-12 15:04:05 |ERROR| SQL: SELECT id,worker_id FROM `dingtalk` WHERE `state`=?  DATA: [0]  ERROR: connection refused  [dd/sync.go:63]

关键改动:

  • 无错误时不显示 ERROR:<nil>
  • 有错误时日志级别自动升为 ERROR(而非一律 INFO)
  • SQL 日志在 logLevel > 0 时输出,logLevel == 0 时只有 SQL 出错才打印

测试模式下 SQL 错误自动 fail

测试框架中 SQL 出错让测试用例直接失败报错。


七、需要修改的文件清单

核心改动

  • log/logrus.go -- 全部重写为 zerolog 实现,重命名为 log/logger.go
  • common/error.go -- 删除整个文件
  • 新增 common/dbstate.go -- DBState + DBSnapshot 类型
  • application.go -- 删除 Error 嵌入和所有 Error{} 初始化、删除 mode 逻辑、Application.Log 类型变更、SetCache 去掉 mode 判断、Cache-Control 改跟 logLevel、删除 Db.Mode 赋值(3 处)、ConnectFunc 签名简化、logLevel 接入
  • var.go -- 删除 "mode": 2 默认值、ConfigNote 删除 mode 说明、logLevel 默认值改为 3 并更新说明
  • db/db.go -- 删除 Mode int / LastQuery / LastData / LastErr 字段,改为 LastState DBState + Log *log.Logger,移除 logrus import
  • db/query.go -- 用 LastState.Set() 原子写入替换分散的赋值、SQL 日志格式优化(无错不显示 ERROR、有错升 ERROR 级别)、Mode != 0 改为 Logger 级别过滤
  • go.mod -- 添加 zerolog 依赖

适配改动


八、应用层使用示例

// 基本使用
that.Log.Infof("用户 %s 登录成功", name)
that.Log.Errorf("订单 %d 支付失败: %v", orderId, err)

// 链式调用(附加结构化字段)
that.Log.Info().Str("user", name).Int("age", 25).Msg("用户登录成功")
that.Log.Error().Err(err).Str("order", orderId).Msg("支付回调失败")

// 打印即存储(Log.Error 自动存入错误历史)
that.Log.Error().Err(err).Msg("数据库连接失败")

// DB 状态:nil = 成功,非 nil = 失败
if state := that.Db.GetLastState(); state != nil {
    that.Log.Error().Err(state.Err).Str("sql", state.Query).Msg("SQL 失败")
}

// 调阅最近 5 条错误(调试用,包括 DB 错误在内的所有 ERROR 级别日志)
errors := that.Log.GetRecentErrors(5)

// SQL 日志由框架自动处理,logLevel>0 时输出
// 无错误: |INFO| SQL: SELECT... DATA: [1,2]  [app/user.go:45]
// 有错误: |ERROR| SQL: SELECT... DATA: [1,2] ERROR: xxx  [app/user.go:45]

九、向后兼容与影响范围

编译级 breaking changes

  • common.Error 类型完全删除:所有嵌入 Error 或传递 *Error 的地方改为 *Loggererror
  • Application.Log*logrus.Logger 改为 *log.Logger
  • Application.Error 字段删除
  • ConnectFunc 签名简化(不再传递 *Error
  • HoTimeDB.LastQuery/LastData/LastErr/Mode 删除:
    • 应用层通过 db.GetLastState() 获取(nil=成功)
    • 框架内部通过 db.lastState.SetError(e) 更新
  • 删除 mode 配置

不受影响

  • codeConfig[].mode(代码生成模式)完全不受影响
  • modeRouterStrict(路由严格模式)完全不受影响
  • cache 配置结构不受影响(cache.db.mode 等是缓存自己的 mode,与全局 mode 无关)
  • dri/mongodbLastErr error 是独立的普通 error,不受影响