233 lines
10 KiB
Markdown
233 lines
10 KiB
Markdown
# loggerx
|
||
|
||
基于 Go 原生 `log` 封装的日志库:支持按时间切分文件、同步/异步落盘、channel 分目录、Gin 中间件。
|
||
|
||
```go
|
||
log := loggerx.NewLogger(ctx, loggerx.SetDir("./log"), loggerx.SetToConsole())
|
||
defer log.Close() // 退出前务必调用,否则最后一批日志(最多 32KB)不会落盘
|
||
|
||
log.Info(ctx, "hello")
|
||
log.Infof(ctx, "hello %s", "world")
|
||
```
|
||
|
||
## 用法
|
||
|
||
### 创建与选项
|
||
|
||
```go
|
||
log := loggerx.NewLogger(ctx,
|
||
loggerx.SetDir("./log"), // 日志目录,默认 ./log
|
||
loggerx.SetToConsole(), // 同时输出到控制台
|
||
loggerx.SetDays(7), // 保留天数,默认 7;<=0 表示不删除
|
||
loggerx.SetTimeZone(time.FixedZone("CST", 8*3600)), // 时区,默认 time.Local
|
||
loggerx.SetFileSplit(loggerx.FileSplitTimeE), // 时间切割方式,默认按天
|
||
loggerx.SetSizeSplit(64<<20), // 单文件上限 64MB,超过则滚动归档
|
||
loggerx.SetCompress(true), // 归档是否压成 .gz,默认 true
|
||
loggerx.SetCompressLevel(gzip.BestCompression), // 压缩级别 1~9,默认 BestSpeed
|
||
loggerx.SetFlushInterval(200*time.Millisecond), // 定时刷盘,默认关闭
|
||
loggerx.SetEscapeHTML(false), // 是否转义 HTML,默认 true
|
||
loggerx.SetGID(false), // 是否记录 goroutine id,默认 true
|
||
loggerx.SetFormat(loggerx.FormatJSON), // 输出格式:FormatJSON(默认) / FormatText
|
||
loggerx.SetPrefix("[order-svc] "), // 每行日志前缀,便于区分来源
|
||
loggerx.SetMinLevel(loggerx.LevelInfo), // 最低输出级别,默认 LevelDebug(全输出)
|
||
loggerx.SetErrorHandler(onLogError), // 日志库自身故障回调(磁盘满/句柄失效等)
|
||
loggerx.SetTraceField("trace_id"), // trace 字段名,默认 trace_id
|
||
loggerx.SetErrorToInfo(), // error 是否同时写入 info 日志
|
||
loggerx.SetExpandData("app", "order"), // 每条日志追加固定字段
|
||
loggerx.SetExtraDriver(f, hooks), // 额外落盘驱动(实现 io.Writer 即可)
|
||
loggerx.SetPrintFile(false), // 不写文件,只走驱动
|
||
)
|
||
```
|
||
|
||
### 定时刷盘:把崩溃丢失窗口压到最小
|
||
|
||
默认不刷盘时,日志攒在 32KB 内存缓冲里,进程被 `kill -9` 最多丢 32KB。
|
||
开启定时刷盘后,丢失量收敛为「一个间隔内产生的日志量」:
|
||
|
||
```go
|
||
log := loggerx.NewLogger(ctx,
|
||
loggerx.SetDir("./log"),
|
||
loggerx.SetFlushInterval(200*time.Millisecond), // 每 200ms 刷一次
|
||
)
|
||
```
|
||
|
||
刷盘由后台协程完成,失败只记日志、不会中断写入;`Close()` 会等它退出。
|
||
|
||
### 按大小切割 + 自动压缩归档
|
||
|
||
```go
|
||
log := loggerx.NewLogger(ctx,
|
||
loggerx.SetDir("./log"),
|
||
loggerx.SetSizeSplit(64<<20), // 每个文件最多 64MB
|
||
loggerx.SetCompress(true), // 滚动后压成 .gz(默认开)
|
||
)
|
||
```
|
||
|
||
滚动过程(不阻塞写入):
|
||
|
||
1. 关掉当前句柄(关句柄会先清空缓冲,归档内容因此完整)
|
||
2. 改名成 `2026-09-13_info_3.log`(先改名,任何时刻文件都存在,崩溃也不丢)
|
||
3. 后台协程压缩成 `2026-09-13_info_3.log.gz`,成功后删掉未压缩文件
|
||
|
||
细节保证:
|
||
|
||
- **序号全局递增**,且跳过已存在的文件,不覆盖历史归档;跨进程重启也从目录里续号
|
||
- **单条日志超长**(比上限还大)不会死循环,最坏就是该文件超限
|
||
- **归档失败**(如权限问题)时继续写原文件,宁可文件大一点也不丢日志
|
||
- `Close()` 会等压缩协程收尾,返回后读归档不会读到半截 `.gz`
|
||
- 过期清理(`SetDays`)同时作用于 `.log` 与 `.gz`
|
||
|
||
### 文件切割
|
||
|
||
| 取值 | 目录/文件名形态 |
|
||
| --- | --- |
|
||
| `FileSplitNone` | `info.log` |
|
||
| `FileSplitTimeA` | `2026/09/13/15_info.log` |
|
||
| `FileSplitTimeB` | `2026/09/13_info.log` |
|
||
| `FileSplitTimeC` | `2026/09-13_info.log` |
|
||
| `FileSplitTimeD` | `2026-09-13-15_info.log` |
|
||
| `FileSplitTimeE` | `2026-09-13_info.log`(默认) |
|
||
|
||
### channel 分目录
|
||
|
||
```go
|
||
log.Channel("order").Info(ctx, "下单") // 落到 ./log/order/2026-09-13_info.log
|
||
log.Channel("pay").Error(ctx, "支付失败")
|
||
```
|
||
|
||
### 同步 / 异步
|
||
|
||
```go
|
||
log.Info(ctx, "默认同步") // 写入内存缓冲(32KB 满才落盘)
|
||
log.WriteAsync().Info(ctx, "这条异步") // 交给后台协程消费
|
||
loggerx.SetWriteAsync() // 全局异步
|
||
```
|
||
|
||
异步是**有界阻塞队列**(容量 1000):队列满时写入方会等待,不会丢日志。
|
||
`Close()` 会等队列排空并把缓冲刷盘。
|
||
|
||
### Gin 中间件
|
||
|
||
```go
|
||
log := loggerx.NewLogger(context.Background(), loggerx.SetToConsole())
|
||
defer log.Close()
|
||
|
||
g := gin.New()
|
||
g.Use(gin.Recovery())
|
||
g.Use(middleware.SetGinTraceId(log)) // 读取/生成 trace id,并写回响应头 X-Trace-Id
|
||
g.Use(middleware.SetGinParams(log)) // 记录请求与响应(含 body,截断到 1000 字节)
|
||
|
||
g.GET("/ping", func(c *gin.Context) {
|
||
// trace id 同时注入 gin.Context 与 request context,两种写法日志里都能带上
|
||
log.Info(c, "via gin ctx")
|
||
log.Info(c.Request.Context(), "via request ctx")
|
||
c.JSON(200, gin.H{"trace_id": middleware.GetTraceId(c.Request.Context(), log.GetTraceField())})
|
||
})
|
||
```
|
||
|
||
trace id 的行为:
|
||
|
||
- 优先取请求头 `X-Trace-Id`(上游透传,跨服务串起同一条链路),没有才生成
|
||
- 生成后写回响应头,客户端/下游能拿到同一个 id
|
||
- 上下文里的 key 用自定义类型(不是裸 string),避免与第三方库撞键
|
||
- 需要自定义头名时用 `middleware.SetGinTraceIdByKey("trace_id", "X-Request-Id")`
|
||
|
||
### 与标准库 log 互通
|
||
|
||
默认**不接管**全局 `log`(只 import 本包不会产生任何副作用)。
|
||
需要把老代码里的 `log.Printf` 也收进日志文件时,显式打开:
|
||
|
||
```go
|
||
log := loggerx.NewLogger(ctx, loggerx.SetDir("./log"), loggerx.SetAsGlobalLog())
|
||
defer log.Close() // Close 会把全局 log 还原成接管前的 writer
|
||
```
|
||
|
||
`*Logger` 本身实现 `io.Writer`,也可以直接塞给任何需要 writer 的地方:
|
||
|
||
```go
|
||
log.SetOutput(loggerx.NewLogger(ctx, loggerx.SetDir("./log")))
|
||
```
|
||
|
||
### 输出格式与前缀
|
||
|
||
```go
|
||
// JSON(默认):每行一条合法 JSON,带 level 字段,推荐给采集端,也是性能最好的一种
|
||
{"level":"info","time":"2026-09-13 14:07:39.731954","file":"/main.go:20","func":"main","gid":"7","content":["hello"]}
|
||
|
||
// FormatText:单行紧凑文本,适合人直接看
|
||
level=info time=2026-09-13 14:07:39.731954 file=/main.go:20 func=main gid=7 content=hello
|
||
```
|
||
|
||
两者都支持 `SetPrefix`,前缀加在行首(`[order-svc] level=info ...`)。
|
||
text 格式下值里含空格/引号时会用 `%q` 包起来,保证一行一条且不歧义。
|
||
|
||
实测(本机 16 核):默认 JSON 约 10.2 µs/条,text 约 12.1 µs/条。
|
||
text 并非更快 —— 它走的是逐字段拼接,而 JSON 走的是已高度优化的 `encoding/json`。
|
||
选 text 的理由是「人读着方便」,不是性能。
|
||
|
||
### 级别过滤
|
||
|
||
```go
|
||
loggerx.SetMinLevel(loggerx.LevelInfo) // 生产常用:Debug 直接丢弃,连 JSON 都不序列化
|
||
loggerx.SetMinLevel(loggerx.LevelOff) // 全关
|
||
```
|
||
|
||
### 把日志库自身的故障暴露出来
|
||
|
||
磁盘满、句柄失效、归档失败、刷盘超时这类问题默认只体现在返回值里,而调用方通常忽略返回值。
|
||
注册回调后可以接到告警或一个「不会失败」的输出:
|
||
|
||
```go
|
||
loggerx.NewLogger(ctx,
|
||
loggerx.SetDir("./log"),
|
||
loggerx.SetErrorHandler(func(err error) {
|
||
fmt.Fprintln(os.Stderr, "loggerx:", err)
|
||
}),
|
||
)
|
||
```
|
||
|
||
回调在写日志的调用栈上同步执行,务必保持轻量,且不要在里面再调用同一实例的日志方法。
|
||
|
||
## 落盘行为与保证
|
||
|
||
- **缓冲**:每条日志先写进 32KB 内存缓冲,写满才 `write` 一次系统调用。
|
||
- **完整性**:以下四种方式都会把缓冲落盘 —— 缓冲写满、`Close()`、`MustSync()`、`SetFlushInterval` 定时器。
|
||
- **崩溃语义**:进程被 `kill -9`、panic 未恢复、断电时,**最多丢失最后 32KB** 未落盘的日志;
|
||
开启 `SetFlushInterval` 后,丢失量收敛到「一个间隔内产生的日志量」。
|
||
- **关闭语义**:`Close()` 之后该实例不再接受写入(返回 `loggerx: 日志已关闭`);
|
||
会等异步队列排空、等压缩归档收尾;重复调用安全;不泄漏文件句柄。
|
||
- **写失败**:写文件失败会重试一次(重开句柄);磁盘满等持续失败时该条日志会丢,
|
||
错误通过 `io.Writer` 语义返回给调用方,建议对 `Write`/`MustSync`/`Close` 的返回值做检查。
|
||
- **channel 隔离**:`Channel("x")` 的日志落在 `<dir>/x/` 子目录,同步与异步模式都成立。
|
||
|
||
## 性能
|
||
|
||
本机(Windows / 16 核)实测,Go 1.26:
|
||
|
||
| 场景 | 单条耗时 | 分配 |
|
||
| --- | --- | --- |
|
||
| `SetGID(false)` + 只走驱动 | 3.4 µs | 14 |
|
||
| 默认(含 gid,写文件) | 10.8 µs | 15 |
|
||
| 并行写入(16 goroutine) | 19.6 µs | 17 |
|
||
|
||
单条 10.8 µs 中约 4.3 µs 花在采集 goroutine id 上。对延迟敏感、日志量大的场景建议
|
||
`SetGID(false)`,可省掉约 40% 开销。开启 `SetSizeSplit` 后每条日志会多一次
|
||
`Stat`(约 0.6 µs)用于判断是否该滚动;不配置大小切割则没有这笔开销。
|
||
|
||
## 开发计划
|
||
|
||
1. [X] 自动清除过期的日志文件(`.log` 与 `.gz` 都清)
|
||
2. [X] 支持日志文件压缩(滚动归档自动 gzip)
|
||
3. [X] 支持日志文件切割
|
||
4. [ ] 支持日志文件归档到对象存储
|
||
5. 支持多种文件分割类型
|
||
1. [X] 按照时间分割
|
||
2. [X] 按照文件大小分割(`SetSizeSplit`)
|
||
3. [ ] 按照日志行数分割
|
||
6. [X] 支持日志级别过滤(`SetMinLevel`)
|
||
7. [X] 异步落盘(按实例隔离,不再是全局队列)
|
||
8. [X] 支持是否转义 HTML
|
||
9. [X] 支持定时刷盘(`SetFlushInterval`)
|
||
10. [X] 支持 text / json 两种输出格式(`SetFormat`)与行前缀(`SetPrefix`)
|
||
11. [ ] 支持采样(高频日志降采样)
|