package loggerx // author:黄新云 // lastTime:2024年4月3日21:18:05 // desc: 日志封装类 import ( "context" "fmt" "io" "log" "os" "sync" "sync/atomic" "time" // "sync_log/global" "github.com/gin-gonic/gin" ) // 需要实现io.Writer接口 type Logger struct { ctx context.Context filePath map[fileKey]*logFile // 每个 (channel,event) 一个句柄 mu *sync.RWMutex // 保护 filePath writeMu *sync.Mutex // 串行化文件写入(bufio 不是并发安全的) driverMu *sync.Mutex // 串行化额外驱动:用户 driver 往往不是并发安全的 option loggerOption channel string writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里 closeOnce *sync.Once closed *atomic.Bool // 是否已经关闭:关闭后再写不再新开句柄 done chan struct{} // 通用关闭信号(delete / ctx 相关 goroutine 用) workerDone chan struct{} // 异步消费 goroutine 退出信号 // 异步队列相关的整块状态都挂在指针后面:Logger 会被 Channel()/WriteAsync() // 浅拷贝,如果状态直接放在 Logger 里,拷贝出的实例就会各看各的 // (曾经因此让 Close 看不到真正的队列,白等一场、最后一条日志丢失) async *asyncCtl // basePath 缓存:值是共享指针,拷贝出的实例才能看到同一个基路径 basePathVal *string // 归档序号状态:必须整块共享,否则 Channel()/WriteAsync() 拷贝出来的实例 // 会各自从 0 开始数,选出已被占用的序号,把已有归档覆盖掉 idxState *idxCtl // 后台压缩协程计数:Close 需要等它们收尾,否则读归档会读到半截 .gz compWg *sync.WaitGroup } // idxCtl 归档序号的共享状态 type idxCtl struct { once *sync.Once mu *sync.Mutex n int } // nextIndex 返回下一个归档序号(首次调用时从目录里扫描续号) func (l *Logger) nextIndex() int { l.idxState.once.Do(func() { l.idxState.n = l.maxIndexOnDisk() }) l.idxState.mu.Lock() defer l.idxState.mu.Unlock() l.idxState.n++ return l.idxState.n } // 全局 stdlib log / gin 写手的接管登记信息 var ( globalLogOwner atomic.Pointer[Logger] // 当前接管全局 log 的实例 origLogWriter atomic.Value // 进程最初(首次接管前)的 writer,只写一次 globalLogMu sync.Mutex ginOwner atomic.Pointer[Logger] origGinOut atomic.Value // 首次接管前的 gin.DefaultWriter origGinErr atomic.Value // 首次接管前的 gin.DefaultErrorWriter globalGinMu sync.Mutex ) // logWriterAdapter 让 io.Writer 能被安全地存进 atomic.Value type logWriterAdapter struct{ w io.Writer } // takeOverGlobalLog 接管全局 stdlib log,只记录一次「最初」的 writer func (l *Logger) takeOverGlobalLog() { globalLogMu.Lock() defer globalLogMu.Unlock() // 无论中间被接管几次,origLogWriter 永远是进程最初那个 writer。 // 旧实现记的是「上一个持有者」,于是 L1->L2->Close(L2) 会把全局 log // 还原成 L1,而 L1 也可能已关闭 —— 那正是这套机制要避免的丢日志场景 if t := origLogWriter.Load(); t == nil { origLogWriter.Store(logWriterAdapter{w: log.Writer()}) } log.SetOutput(l) log.SetFlags(log.LstdFlags | log.Llongfile | log.Lmicroseconds) globalLogOwner.Store(l) } // releaseGlobalLog 如果全局 log 还指向自己,就还原成最初的 writer // // Close 时必须做:否则 Close 之后的 stdlib log 会写进已关闭实例, // 被 ErrClosed 拒绝后静默丢失,运维完全看不到 func (l *Logger) releaseGlobalLog() { globalLogMu.Lock() defer globalLogMu.Unlock() if cur := globalLogOwner.Load(); cur == nil || cur != l { return } if v := origLogWriter.Load(); v != nil { log.SetOutput(v.(logWriterAdapter).w) } globalLogOwner.Store(nil) } // takeOverGinWriters 接管 gin 的默认写手 // 把 os.Stdout 放前面:MultiWriter 遇到第一个错误就短路, // 若先写 l 且 l 出错(磁盘满 / 已关闭),gin 连控制台输出和 Recovery 堆栈都会一起消失 func (l *Logger) takeOverGinWriters() { globalGinMu.Lock() defer globalGinMu.Unlock() if v := origGinOut.Load(); v == nil { origGinOut.Store(logWriterAdapter{w: gin.DefaultWriter}) origGinErr.Store(logWriterAdapter{w: gin.DefaultErrorWriter}) } gin.DefaultWriter = io.MultiWriter(os.Stdout, l) gin.DefaultErrorWriter = io.MultiWriter(os.Stdout, l) ginOwner.Store(l) } // releaseGinWriters 还原 gin 的默认写手 func (l *Logger) releaseGinWriters() { globalGinMu.Lock() defer globalGinMu.Unlock() if cur := ginOwner.Load(); cur == nil || cur != l { return } if v := origGinOut.Load(); v != nil { gin.DefaultWriter = v.(logWriterAdapter).w } if v := origGinErr.Load(); v != nil { gin.DefaultErrorWriter = v.(logWriterAdapter).w } ginOwner.Store(nil) } // asyncCtl 异步队列的共享控制块:所有拷贝共享同一份 type asyncCtl struct { mu sync.Mutex ch chan cacheData closed bool // 只能读写于 mu 保护下 wg sync.WaitGroup } // pendingAsync 记录「已通过投递检查、正在往队列里放」的写入数量。 // 调用方必须持有 mu,Done 必须紧跟在 mu 解锁之后(见 toAsync) func (c *asyncCtl) begin() { c.wg.Add(1) } func (c *asyncCtl) end() { c.wg.Done() } // fileKey 文件缓存的键 // 必须同时包含 channel 与 event:文件名由两者共同决定,只按 channel 缓存会 // 导致 info/error 互相顶掉句柄,每次写入都 close+open 一次文件 type fileKey struct { channel string event string } func NewLogger(ctx context.Context, opts ...Option) *Logger { opt := defaultOptions() for _, apply := range opts { apply(&opt) } // 验证文件夹权限 if !checkDir(opt.dir) { panic("文件夹权限不足") } // 构造函数允许 ctx 为 nil(调用方常把可选 ctx 直接传进来), // 但后台 goroutine 会调 ctx.Done(),nil 接口会 panic,这里统一兜底。 // 另外提醒:这个 ctx 是「实例生命周期」句柄,应当传进程级 ctx, // 不要传请求级 ctx —— 请求结束会让清理协程提前退出 if ctx == nil { ctx = context.Background() } l := &Logger{ ctx: ctx, filePath: make(map[fileKey]*logFile), mu: &sync.RWMutex{}, writeMu: &sync.Mutex{}, driverMu: &sync.Mutex{}, option: opt, writeType: writeTypeDefault, closeOnce: &sync.Once{}, closed: &atomic.Bool{}, done: make(chan struct{}), workerDone: make(chan struct{}), async: &asyncCtl{}, basePathVal: new(string), idxState: &idxCtl{once: &sync.Once{}, mu: &sync.Mutex{}}, compWg: &sync.WaitGroup{}, } *l.basePathVal = resolveBasePath() // 把全局 stdlib log 的输出接管到本实例(可选,默认不接管) // 生产建议:日志库不应该在 import 或 NewLogger 时静默劫持宿主的全局 log, // 多实例场景下后建的会把先建的顶掉,Close 之后全局 log 还会指向已关闭实例 if opt.asGlobalLog { l.takeOverGlobalLog() } // 保存Gin日志写入到文件+控制台 if opt.isGinLog { l.takeOverGinWriters() } // 日志删除 go l.delete() // 定时刷盘 if opt.flushEvery > 0 { go l.flushLoop() } // 强制刷盘 // 用 done 而不是只监听 ctx:ctx 为 Background 时 Close 也要能把 goroutine 收回去 go func() { select { case <-ctx.Done(): l.MustSync() case <-l.done: } }() return l } // flushTick 定时刷盘的检查频率 // 实际刷盘间隔由 SetFlushInterval 决定;这里用固定的小 tick 做检查, // 这样即使调用方设了很小的间隔,也不会因为「每个 tick 都抢全局写锁」而拖垮吞吐。 // 写锁是全局的,高频抢锁会直接顶住所有写入方 const flushTick = 50 * time.Millisecond // flushLoop 定时把内存缓冲刷到磁盘 // 作用是把「进程崩溃时可能丢的数据量」从「最多一整个 32KB 缓冲」 // 缩小到「一个刷盘间隔内产生的日志量」 func (l *Logger) flushLoop() { tick := time.NewTicker(flushTick) defer tick.Stop() last := time.Now() for { select { case <-tick.C: interval := l.option.flushEvery if interval <= 0 { continue } if now := time.Now(); now.Sub(last) >= interval { last = now // 单个文件刷盘失败不应该让这个循环退出 if err := l.MustSync(); err != nil { log.Println("loggerx: 定时刷盘失败:", err) } } case <-l.done: return } } } // 关闭日志:关闭异步队列、落盘并关闭所有文件句柄 // 重复调用安全,调用后本实例的后台 goroutine 会全部退出 func (l *Logger) Close() error { var err error l.closeOnce.Do(func() { // 1. 先停投递 + 等消费协程把队列里已入队的任务全部写完。 // 这一步必须在「封盘」之前:队列里可能还有别的 channel / event 的任务, // 它们需要新开自己的文件句柄。如果提前封盘,这些任务会直接写失败被丢掉 l.drainAsync() // 2. 封盘:此后不再新开句柄,避免 Close 扫完句柄表之后又冒出没人负责关的句柄 l.closed.Store(true) // 3. 在写锁内完成最后一次刷盘并关闭所有句柄。此时无锁等待,不会和谁抢锁 l.writeMu.Lock() err = joinErrors([]error{l.mustSyncLocked(), l.close()}) l.writeMu.Unlock() // 4. 等后台压缩协程收尾:不等的话 Close 返回后去读归档会读到半截 .gz l.compWg.Wait() // 5. 如果全局 log / gin 写手还指向自己,还原成最初的那个 // 否则 Close 之后 stdlib log 会写进已关闭实例并被静默丢弃 l.releaseGlobalLog() l.releaseGinWriters() // 6. 最后再发通用关闭信号,让 delete / ctx 相关 goroutine 退出 close(l.done) }) return err } // reportError 上报日志库自身的内部错误 // // 这是运维唯一能看到「磁盘满 / 句柄失效 / 归档失败 / 刷盘超时」的通道: // 注册了 SetErrorHandler 就交给它,否则退回 stderr,至少不会完全静默。 // 回调自身 panic 会被吞掉,绝不能让错误处理把调用方带崩。 func (l *Logger) reportError(err error) { if err == nil { return } fn := l.option.onError if fn == nil { fmt.Fprintln(os.Stderr, err) return } func() { defer func() { _ = recover() }() fn(err) }() } // 强制刷盘:只 flush 缓存数据,文件继续可写 // 与写入并发调用是安全的(内部会先拿到写锁) func (l *Logger) MustSync() error { l.writeMu.Lock() defer l.writeMu.Unlock() return l.mustSyncLocked() } // mustSyncLocked 调用方必须持有 writeMu // 缓冲的 pending 与 buf 只有在写锁内访问才安全,否则会和 store 并发改动同一块内存 func (l *Logger) mustSyncLocked() error { l.mu.RLock() files := make([]*logFile, 0, len(l.filePath)) for _, f := range l.filePath { files = append(files, f) } l.mu.RUnlock() var errs []error for _, f := range files { // 没有待落盘数据的文件直接跳过:省掉一次 fsync。 // Close 仍然安全:logFile.Close 自己会再 Flush 一次并关句柄 if !f.hasPending() { continue } errs = append(errs, f.Sync()) } return joinErrors(errs) } func (l *Logger) Channel(ch string) (r *Logger) { rr := *l rr.channel = ch return &rr } func (l *Logger) WriteAsync() (r *Logger) { rr := *l rr.writeType = writeTypeAsync return &rr } func (l *Logger) WriteSync() (r *Logger) { rr := *l rr.writeType = writeTypeSync return &rr } // 获取TraceField的字段 func (l *Logger) GetTraceField() string { return l.option.traceField } // 实现io.Writer接口 func (l *Logger) Write(b []byte) (n int, err error) { return l.write("info", b) } func (l *Logger) Info(ctx context.Context, v ...any) { l.logger(ctx, "info", v...) } func (l *Logger) Infof(ctx context.Context, format string, v ...any) { s := fmt.Sprintf(format, v...) l.logger(ctx, "info", s) } func (l *Logger) Error(ctx context.Context, v ...any) { l.logger(ctx, "error", v...) } func (l *Logger) Errorf(ctx context.Context, format string, v ...any) { s := fmt.Sprintf(format, v...) l.logger(ctx, "error", s) } func (l *Logger) Debug(ctx context.Context, v ...any) { l.logger(ctx, "debug", v...) } func (l *Logger) Debugf(ctx context.Context, format string, v ...any) { s := fmt.Sprintf(format, v...) l.logger(ctx, "debug", s) } func (l *Logger) Warn(ctx context.Context, v ...any) { l.logger(ctx, "warn", v...) } func (l *Logger) Warnf(ctx context.Context, format string, v ...any) { s := fmt.Sprintf(format, v...) l.logger(ctx, "warn", s) } // 验证文件夹权限 // 根文件夹如果不存在则创建 func checkDir(dir string) bool { if _, err := os.Stat(dir); err != nil { if os.IsNotExist(err) { if err := os.MkdirAll(dir, 0755); err != nil { log.Println("创建文件夹失败", err) return false } return true } // 目标存在但不可访问(权限不足等),此时返回 false 才是这个函数的本意 log.Println("日志文件夹不可用", dir, err) return false } return true }