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

425 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: Logging System Redesign
overview: 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.
todos:
- id: add-zerolog-dep
content: 添加 zerolog 依赖到 go.mod,移除 logrusvendor 中 wechat 等三方库自带的 logrus 不影响)
status: completed
- id: rewrite-log-package
content: 重写 log/ 包:基于 zerolog 实现 Logger 结构体、NewLogger 工厂、CallerMarshalFunc 智能调用者过滤、TemplateFileWriter 带缓冲和模板路径切换
status: completed
- id: delete-error-add-dbstate
content: 删除 common/error.goError 类型不再需要)、新增 common/dbstate.goDBState 包装 Query+Data+Error)、Logger 内置 ERROR 级别环形缓冲
status: completed
- id: update-application
content: 改造 application.go:删除 mode 配置、Application.Log 类型变更、SetConfig 中日志初始化用 logLevel 控制一切、DB 缓存始终开启、Cache-Control 跟 logLevel 联动
status: completed
- id: update-db
content: 改造 db/db.go 和 db/query.go:用 DBState 替换 LastQuery+LastData+LastErr、HoTimeDB.Log 类型变更、SQL 日志格式优化、消除重复打印
status: completed
- id: update-var-config
content: 更新 var.go 默认配置(删 mode、logLevel 默认 3)和 ConfigNote、example 配置文件
status: completed
- id: update-codegen
content: 适配 code/makecode.go 中的 Error 使用(移除 Logger 相关)
status: completed
- id: update-tests
content: 适配 testing_helper.go 和 db/transaction_test.go 中的日志创建
status: completed
isProject: false
---
# HoTime 日志系统全面重写方案
## 一、现状问题分析
### 1. logrus 已不适合继续使用
- logrus 已进入 **维护模式**,不再开发新功能,仅修复安全问题
- 性能差:依赖 mutex 锁和反射,在高并发下显著慢于现代替代品
- 当前使用版本 `v1.8.1`[go.mod](go.mod) line 13
### 2. 现有日志配置形同虚设
- `logLevel` 在 [var.go](var.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](https://github.com/rs/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` 等控制)。删除 `mode``SetCache` 中始终挂载 `that.Db.HoTimeCache`,是否真正缓存由 cache 配置决定
- **Cache-Control**:跟 `logLevel` 联动 -- `logLevel > 0` 时设 `no-cache``logLevel == 0``public`
- **SQL 日志打印**:完全由 `logLevel` 控制(`logLevel > 0` 时打印 SQL
- **代码生成**:本来就由 `codeConfig[].mode` 独立控制,不受影响
### `logLevel` -- 保留,真正接管日志系统
- `0` = 仅 error(生产环境推荐)
- `>=1` = 全部打印(info/warn/error/debug/SQL 全开)
- **默认值 `1`**
简单二分:要么只看错误,要么全看。Cache-Control 同理:`logLevel==0``public``>0``no-cache`
### `logFile` -- 保留
路径模板不变(`a/b/c/20060102150405.txt`),应用日志写入此文件。
### `logHistory` -- 新增
Logger 错误历史缓冲条数,默认 `100`。设为 `0` 关闭历史记录。可根据设备内存情况调小。
### `webConnectLogShow` / `webConnectLogFile` -- 保留独立
保留访问日志独立的理由(业务角度):
- 访问日志量远大于应用日志,混在一起文件膨胀、查找困难
- 生产环境需要不同的轮转策略
- Nginx/Apache 都是 access.log 和 error.log 分开的行业惯例
配置保持不变:
- `webConnectLogShow`:默认 true
- `webConnectLogFile`:独立文件路径
---
## 四、新日志包设计 (`log/`)
### 核心结构
```go
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
```json
{"time":"2026-04-12T15:04:05+08:00","level":"info","caller":"app/admin/user.go:45","message":"用户登录成功"}
```
### 调用者智能过滤
复用并优化现有 [log/logrus.go](log/logrus.go) 中 `findCaller` / `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 条),打印即存储,零额外操作:
```go
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,可根据设备内存调整。
应用层使用:
```go
// 正常打印,自动存入历史
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](common/dbstate.go),将 `LastQuery` + `LastData` + `LastErr` 合为一体,一把锁保护原子读写。
**核心语义**:记录最后一次 DB 操作的完整状态。每次 SQL 执行后更新,用于判断"SQL 执行失败"还是"执行成功但结果为空"(如 UPDATE 影响 0 行)。
```go
// 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
}
```
**使用场景**
```go
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`
```go
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
```go
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,
}
}
```
应用层使用:
```go
// 一行判断 + 取详情
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](log/logrus.go) -- 全部重写为 zerolog 实现,重命名为 `log/logger.go`
- [common/error.go](common/error.go) -- **删除整个文件**
- 新增 [common/dbstate.go](common/dbstate.go) -- DBState + DBSnapshot 类型
- [application.go](application.go) -- 删除 `Error` 嵌入和所有 `Error{}` 初始化、删除 mode 逻辑、`Application.Log` 类型变更、`SetCache` 去掉 mode 判断、Cache-Control 改跟 logLevel、删除 `Db.Mode` 赋值(3 处)、`ConnectFunc` 签名简化、logLevel 接入
- [var.go](var.go) -- 删除 `"mode": 2` 默认值、ConfigNote 删除 mode 说明、logLevel 默认值改为 3 并更新说明
- [db/db.go](db/db.go) -- 删除 `Mode int` / `LastQuery` / `LastData` / `LastErr` 字段,改为 `LastState DBState` + `Log *log.Logger`,移除 logrus import
- [db/query.go](db/query.go) -- 用 `LastState.Set()` 原子写入替换分散的赋值、SQL 日志格式优化(无错不显示 ERROR、有错升 ERROR 级别)、`Mode != 0` 改为 Logger 级别过滤
- [go.mod](go.mod) -- 添加 zerolog 依赖
### 适配改动
- [db/crud.go](db/crud.go) -- `that.LastErr.SetError(e)` 改为 `that.lastState.SetError(e)`
- [db/transaction.go](db/transaction.go) -- 事务拷贝中 `LastQuery`/`LastData`/`LastErr` 改为 `lastState`
- [db/transaction_test.go](db/transaction_test.go) -- 删除 `Error{Logger: logger}`、logrus 替换
- [code/makecode.go](code/makecode.go) -- 删除嵌入的 `Error`,改用 `*Logger` 打印错误
- [cache/cache.go](cache/cache.go) -- `Init` 参数删除 `*Error`,改传 `*Logger`
- [cache/cache_redis.go](cache/cache_redis.go) -- `that.Error.SetError(err)` 全部改为 `that.Log.Error().Err(err).Msg(...)`
- [testing_helper.go](testing_helper.go) -- `app.Db.Mode` 删除、Logger 创建适配
- [context.go](context.go) -- `that.Error.SetError(...)` 改为 `that.Log.Error().Msg(...)`
- [example/app/test.go](example/app/test.go) -- `that.Db.LastQuery` 改为 `that.Db.GetLastState()` 相关访问
- [example/app/mysql.go](example/app/mysql.go) -- 同上
- [example/batch_cache_tester.go](example/batch_cache_tester.go) -- `&Error{}` 删除
- [example/config/configNote.json](example/config/configNote.json) -- 删除 mode 说明、更新 logLevel 说明
- [example/config/config.json](example/config/config.json) -- 删除 mode
---
## 八、应用层使用示例
```go
// 基本使用
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` 的地方改为 `*Logger``error`
- `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/mongodb``LastErr error` 是独立的普通 error,不受影响