Files
loggerx/loggerx.go
T
2026-09-14 00:14:30 +08:00

446 lines
13 KiB
Go
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.
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 而不是只监听 ctxctx 为 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
}