diff --git a/.gitignore b/.gitignore index fbf828d..5f650cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ -log \ No newline at end of file +log + +# 测试/基准跑出来的日志文件(默认目录是 ./log,包级 logger 与部分测试会落到仓库根) +20??-*.log +20??-*.log.gz +*.log.gz.tmp + +# 本地构建缓存 +.tmp-gocache diff --git a/compress.go b/compress.go new file mode 100644 index 0000000..410d684 --- /dev/null +++ b/compress.go @@ -0,0 +1,264 @@ +package loggerx + +import ( + "compress/gzip" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// 按大小切割后的归档与压缩 +// +// 滚动流程(全过程不阻塞写入): +// 1. 关掉当前句柄 —— 关句柄会把缓冲清空,归档文件因此是完整的 +// 2. 把文件改名成 名字_N.log(先改名,保证任何时刻文件都存在,崩溃也不丢数据) +// 3. 交给后台协程压缩成 名字_N.log.gz,压缩成功后再删掉未压缩文件 + +// nextIndex 返回下一个归档序号 +// 首次调用会先扫描目录里已有的 _N.log / _N.log.gz,避免覆盖上次运行的归档 +func (l *Logger) nextIndex() int { + l.idxOnce.Do(func() { + l.idx = l.maxIndexOnDisk() + }) + l.idxMu.Lock() + defer l.idxMu.Unlock() + l.idx++ + return l.idx +} + +// maxIndexOnDisk 扫描日志目录,找出已经用掉的最大序号 +func (l *Logger) maxIndexOnDisk() int { + max := 0 + _ = filepath.Walk(l.option.dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + name := info.Name() + name = strings.TrimSuffix(name, ".gz") + if !strings.HasSuffix(name, ".log") { + return nil + } + // 形如 2026-09-13_info_3.log + base := strings.TrimSuffix(name, ".log") + idx := strings.LastIndexByte(base, '_') + if idx < 0 || idx == len(base)-1 { + return nil + } + n, cerr := strconv.Atoi(base[idx+1:]) + if cerr != nil { + return nil + } + if n > max { + max = n + } + return nil + }) + return max +} + +// archive 把日志文件改名成带序号的归档名,返回改名后的路径 +// 调用方必须已经关闭该文件 +// +// base 由调用方传入(日志句柄记下的规范基名),不在这里反推: +// 按小时切割的文件名本身就带 _数字(2026/09/13/06_info.log), +// 靠正则去猜尾部的 _N 是不是归档序号会误判,把正常日志当成归档 +func (l *Logger) archive(base, fileName string) (string, error) { + dir := filepath.Dir(fileName) + base = filepath.Base(strings.TrimSuffix(base, ".log")) + + for try := 0; try < 1000; try++ { + idx := l.nextIndex() + candidate := filepath.Join(dir, fmt.Sprintf("%s_%d.log", base, idx)) + if _, err := os.Stat(candidate); err == nil { + continue // 已存在,换一个序号 + } + if err := os.Rename(fileName, candidate); err != nil { + return "", fmt.Errorf("归档日志文件失败 %s: %w", fileName, err) + } + return candidate, nil + } + return "", fmt.Errorf("归档日志文件失败 %s: 找不到可用序号", fileName) +} + +// scheduleCompress 把压缩任务丢到后台,不阻塞写入路径 +// 不压缩时什么也不做,归档文件保留为 .log +func (l *Logger) scheduleCompress(path string) { + if !l.option.compress { + return + } + l.compWg.Add(1) + go func() { + defer l.compWg.Done() + if err := gzipFile(path, l.option.compressLvl); err != nil { + // 压缩失败就保留原始 .log,不影响数据可用性 + log.Println("loggerx: 压缩日志文件失败:", path, err) + } + }() +} + +// gzipFile 把 src 压缩成 src.gz,成功后删除 src +// 先写临时文件再改名,避免半截 .gz 被当成完整归档 +func gzipFile(src string, level int) error { + in, err := os.Open(src) + if err != nil { + return err + } + // 注意:读完必须显式 Close,不能只靠 defer —— + // Windows 上「句柄还开着」会导致后面 os.Remove(src) 失败, + // 结果就是 .log 与 .log.gz 长期成对存在、磁盘占用翻倍 + closeIn := func() { + _ = in.Close() + } + + tmp := src + ".gz.tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + closeIn() + return err + } + + zw, err := gzip.NewWriterLevel(out, level) + if err != nil { + closeIn() + _ = out.Close() + _ = os.Remove(tmp) + return err + } + + if _, err := io.Copy(zw, in); err != nil { + closeIn() + _ = zw.Close() + _ = out.Close() + _ = os.Remove(tmp) + return err + } + if err := zw.Close(); err != nil { + closeIn() + _ = out.Close() + _ = os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + closeIn() + _ = os.Remove(tmp) + return err + } + closeIn() // 删除源文件之前一定要先释放读句柄 + + dst := src + ".gz" + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return err + } + + // 压缩成功后删除原始文件。 + // Windows 上删除会因「别人短暂持有句柄」而失败(杀毒、索引、外部 tail 等), + // 这类都是瞬时的,退避重试几次基本都能成功;真的删不掉也不影响数据可用性, + // 只是会多占一份未压缩磁盘(下次压缩同目录时不会自动清理,需要人工/日志关注) + if err := removeWithRetry(src, 7); err != nil { + log.Println("loggerx: 压缩完成但无法删除原文件(已重试):", src, err) + } + return nil +} + +// healCompressedLeftovers 清理「归档既存在 .gz 又存在 .log」的冗余文件 +// +// gzipFile 正常情况下压缩成功后会删掉源文件;但如果当时被别的进程占用(杀毒、 +// 索引、外部 tail)且重试也没成功,就会留下成对的 .log 与 .log.gz,白占一份磁盘。 +// 清理协程每轮跑一次这个函数,把这类冗余的 .log 删掉。 +// +// 安全性:只在 .gz 通过完整性校验(gzip 尾部合法)时才删 .log, +// 避免删掉一个损坏压缩包唯一的可读副本 +func (l *Logger) healCompressedLeftovers() error { + if !l.option.compress { + return nil + } + + var errs []error + _ = filepath.Walk(l.option.dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(path, ".gz") { + return nil + } + src := strings.TrimSuffix(path, ".gz") + if _, serr := os.Stat(src); serr != nil { + return nil // 源文件已经清掉了,正常情况 + } + if !validGzip(path) { + // 压缩包不完整:保留 .log,别把唯一可读副本删了 + fmt.Println("loggerx: 归档文件损坏,保留未压缩文件:", path) + return nil + } + if rerr := removeWithRetry(src, 2); rerr != nil { + errs = append(errs, rerr) + return nil + } + fmt.Println("清理冗余未压缩日志", src) + return nil + }) + return joinErrors(errs) +} + +// validGzip 校验 .gz 是否是完整的 gzip 流 +// 做法是把整个文件读完:gzip 包尾 CRC 不对时 ReadAll 会报错 +func validGzip(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + zr, err := gzip.NewReader(f) + if err != nil { + return false + } + defer zr.Close() + + _, err = io.Copy(io.Discard, zr) + return err == nil +} + +// removeWithRetry 删除文件,失败时短暂退避重试 +// 只对「文件仍被占用」这类瞬时错误有意义,重试到位就放弃 +func removeWithRetry(path string, tries int) error { + var err error + delay := 20 * time.Millisecond + for i := 0; i < tries; i++ { + if err = os.Remove(path); err == nil { + return nil + } + if !isTransientRemoveErr(err) { + return err // 权限等问题重试也没用 + } + time.Sleep(delay) + if delay < 500*time.Millisecond { + delay *= 2 + } + } + return err +} + +// isTransientRemoveErr 判断删除失败是否属于「文件被占用」这类可重试错误 +// 不依赖平台特有错误码,用错误文本做保守判断:匹配不上就当不可重试 +func isTransientRemoveErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + for _, kw := range []string{ + "being used by another process", // Windows: 文件被占用 + "used by another process", + "sharing violation", + "resource busy", + "text file busy", // Linux + } { + if strings.Contains(msg, kw) { + return true + } + } + return false +} diff --git a/filePath.go b/filePath.go index a14d423..9012342 100644 --- a/filePath.go +++ b/filePath.go @@ -6,11 +6,15 @@ import ( "io" "os" "path/filepath" + "strings" "time" ) // 文件操作 +// errClosed 日志实例已关闭,不再接受新的文件写入 +var errClosed = errors.New("loggerx: 日志已关闭") + // 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志 const fileBufSize = 32 * 1024 @@ -18,10 +22,17 @@ const fileBufSize = 32 * 1024 // 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态, // 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉 type logFile struct { - file *os.File - buf []byte - pending int + file *os.File + buf []byte + pending int + // written 已写进文件的字节数(内存计数) + // 按大小切割要判断是否该滚动:每条日志都 Stat 一次要花约 8µs, + // 而写了多少字节自己最清楚,只在打开文件时 Stat 一次做基准即可 + written int64 fileName string + // baseName 是 fileNameIn 的结果(不带大小切割的 _N 序号), + // 用于判断是否跨了时间切割边界,以及归档时取规范基名 + baseName string } // Write 写入缓冲,满了就落盘一次 @@ -41,6 +52,29 @@ func (f *logFile) Write(b []byte) (int, error) { return written, nil } +// sizeOnDisk 当前文件实际占用的字节数(内存计数,不是每次 Stat) +func (f *logFile) sizeOnDisk() int64 { + return f.written +} + +// initSize 打开文件后记录一次真实大小作为计数基准(追加打开时文件可能非空) +func (f *logFile) initSize() { + if f.file == nil { + return + } + if st, err := f.file.Stat(); err == nil { + f.written = st.Size() + } +} + +// full 加上即将写入的 n 字节后是否会超过上限 +func (f *logFile) full(limit, n int) bool { + if limit <= 0 || f.file == nil { + return false + } + return f.written+int64(f.pending)+int64(n) > int64(limit) +} + // writeFull 保证把整块数据写进文件(允许底层短写,循环补齐) func (f *logFile) writeFull(p []byte) error { for len(p) > 0 { @@ -65,10 +99,18 @@ func (f *logFile) Flush() error { // 保留未写成功的部分,下次继续 return err } + f.written += int64(f.pending) f.pending = 0 return nil } +// hasPending 缓冲里还有没有未落盘的数据 +// 定时刷盘靠它跳过空闲文件:否则每次 tick 都会对所有句柄做一次 Sync(fsync), +// 高频间隔下光 fsync 就能把吞吐拖垮 +func (f *logFile) hasPending() bool { + return f != nil && f.pending > 0 +} + // Sync 把缓冲刷到文件并 fsync func (f *logFile) Sync() error { var errs []error @@ -104,6 +146,14 @@ func joinErrors(errs []error) error { // 获取最新的文件名 func (l *Logger) nowFileName(event string) string { + return l.fileNameIn(l.channel, event) +} + +// fileNameIn 按指定 channel 计算文件名 +// channel 由调用方传入而不是取 l.channel:异步消费协程用的是「创建队列时的那个 +// logger」,它的 channel 是空的,必须按任务里记的 channel 落盘 +// (否则所有 channel 的日志都会混进根目录文件) +func (l *Logger) fileNameIn(channel, event string) string { prefix := "" switch l.option.fileSplit { @@ -128,15 +178,20 @@ func (l *Logger) nowFileName(event string) string { prefix = prefix + "_" } - if l.channel != "" { - prefix = l.channel + "/" + prefix + if channel != "" { + prefix = channel + "/" + prefix } return l.option.dir + "/" + prefix + event + ".log" } // 新建文件 func (l *Logger) getFile(event string) (*logFile, error) { - key := fileKey{channel: l.channel, event: event} + return l.getFileTo(l.channel, event) +} + +// getFileTo 取指定 channel 的文件句柄 +func (l *Logger) getFileTo(channel, event string) (*logFile, error) { + key := fileKey{channel: channel, event: event} if f := l.loadFile(key); f != nil { return f, nil @@ -150,9 +205,47 @@ func (l *Logger) getFile(event string) (*logFile, error) { return f, nil } - fileName := l.nowFileName(event) + // 已经关闭:不再新开句柄,否则没人负责关它 + if l.closed.Load() { + return nil, errClosed + } + + lf, err := l.newFile(key) + if err != nil { + return nil, err + } + l.filePath[key] = lf + + return lf, nil +} + +// newFile 打开新文件;如果目标文件已经超过大小上限,就先把旧文件归档再开新序号 +func (l *Logger) newFile(key fileKey) (*logFile, error) { + limit := l.option.sizeSplit + + lf, err := l.openNewFile(key) + if err != nil { + return nil, err + } + if limit <= 0 || lf.sizeOnDisk() <= int64(limit) { + return lf, nil + } + + // 上一轮运行留下的文件已经写满(进程重启场景):归档它,再开一个新序号 + _ = lf.file.Close() + path, aerr := l.archive(lf.baseName, lf.fileName) + if aerr != nil { + return nil, aerr + } + l.scheduleCompress(path) + + return l.openNewFileIndexed(key) +} + +// openNewFile 打开不带序号的当日文件 +func (l *Logger) openNewFile(key fileKey) (*logFile, error) { + fileName := l.fileNameIn(key.channel, key.event) - // 识别目录与文件,创建多层目录(已存在不报错) if dir := filepath.Dir(fileName); dir != "" { if err := os.MkdirAll(dir, 0755); err != nil { return nil, err @@ -168,12 +261,62 @@ func (l *Logger) getFile(event string) (*logFile, error) { file: file, buf: make([]byte, fileBufSize), fileName: fileName, + baseName: fileName, } - l.filePath[key] = lf - + lf.initSize() return lf, nil } +// openNewFileIndexed 开一个全新的序号文件 +// 序号永远递增且跳过已存在的文件:绝不复用别人的归档 +// (复用后再归档会得到 名字_1_2.log 这种链式文件名,旧内容也可能被追加写坏) +func (l *Logger) openNewFileIndexed(key fileKey) (*logFile, error) { + base := l.fileNameIn(key.channel, key.event) + for try := 0; try < 1000; try++ { + idx := l.nextIndex() + if _, err := os.Stat(numberedName(base, idx)); err == nil { + continue // 序号已被占用,换下一个 + } + return l.openNumberedFile(key, idx) + } + return nil, fmt.Errorf("无法为 %s 找到可用的归档序号", key.event) +} + +// openFileWithIndex 打开 「基名_序号.log」 +func (l *Logger) openFileWithIndex(key fileKey, idx int) (*logFile, error) { + base := l.fileNameIn(key.channel, key.event) + fileName := numberedName(base, idx) + + if dir := filepath.Dir(fileName); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + } + file, err := os.OpenFile(fileName, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("打开日志文件失败 %s: %w", fileName, err) + } + lf := &logFile{ + file: file, + buf: make([]byte, fileBufSize), + fileName: fileName, + baseName: base, + } + lf.initSize() + return lf, nil +} + +// numberedName 由基名和序号拼出归档文件名 +// 例:./log/2026-09-13_info.log + 3 -> ./log/2026-09-13_info_3.log +func numberedName(base string, idx int) string { + return fmt.Sprintf("%s_%d.log", strings.TrimSuffix(base, ".log"), idx) +} + +// openNumberedFile 打开指定序号文件,序号由调用方保证未被占用 +func (l *Logger) openNumberedFile(key fileKey, idx int) (*logFile, error) { + return l.openFileWithIndex(key, idx) +} + // 加载文件(读锁) func (l *Logger) loadFile(key fileKey) *logFile { l.mu.RLock() @@ -187,8 +330,9 @@ func (l *Logger) loadFileLocked(key fileKey) *logFile { if !ok || f == nil { return nil } - // 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建 - if f.fileName != l.nowFileName(key.event) { + // 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建。 + // 注意比的是不带序号的基名,否则按大小切割出的 _N 文件会被误判成跨天 + if f.baseName != l.fileNameIn(key.channel, key.event) { delete(l.filePath, key) f.Close() return nil diff --git a/format.go b/format.go index 4eba301..825f153 100644 --- a/format.go +++ b/format.go @@ -12,6 +12,19 @@ import ( "time" ) +// basePath 进程工作目录(用于把绝对路径裁成相对路径) +// 只算一次并缓存:filepath.Abs 每次都要走系统调用,而它每条日志都会被用到 +func (l *Logger) basePath() string { + l.basePathOnce.Do(func() { + p, err := filepath.Abs("./") + if err != nil { + p = "" + } + l.basePathVal = strings.ReplaceAll(p, "\\", "/") + }) + return l.basePathVal +} + func (l *Logger) logger(ctx context.Context, event string, v ...any) { // 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层 pc, file, line, ok := runtime.Caller(2) @@ -21,10 +34,7 @@ func (l *Logger) logger(ctx context.Context, event string, v ...any) { if fn := runtime.FuncForPC(pc); fn != nil { funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".") } - if basePath, err := filepath.Abs("./"); err == nil { - basePath = strings.ReplaceAll(basePath, "\\", "/") - file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), basePath) - } + file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), l.basePath()) } nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000") diff --git a/loggerx.go b/loggerx.go index 97c0b3b..539b033 100644 --- a/loggerx.go +++ b/loggerx.go @@ -11,6 +11,8 @@ import ( "log" "os" "sync" + "sync/atomic" + "time" // "sync_log/global" @@ -28,6 +30,7 @@ type Logger struct { writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里 closeOnce *sync.Once + closed *atomic.Bool // 是否已经关闭:关闭后再写不再新开句柄 done chan struct{} // 通用关闭信号(delete / ctx 相关 goroutine 用) workerDone chan struct{} // 异步消费 goroutine 退出信号 closing chan struct{} // 异步投递关闭信号:只关一次 @@ -36,6 +39,16 @@ type Logger struct { // 浅拷贝,如果状态直接放在 Logger 里,拷贝出的实例就会各看各的 // (曾经因此让 Close 看不到真正的队列,白等一场、最后一条日志丢失) async *asyncCtl + + // basePath 缓存(见 format.go basePath),避免每条日志都算一次 filepath.Abs + basePathOnce *sync.Once + basePathVal string + + // 归档压缩相关状态(同样挂在指针后面供拷贝共享) + idxOnce *sync.Once // 归档序号只从目录里初始化一次 + idxMu *sync.Mutex // 保护 idx + idx int // 归档序号 + compWg *sync.WaitGroup // 后台压缩协程计数,Close 需要等它们收尾 } // asyncCtl 异步队列的共享控制块:所有拷贝共享同一份 @@ -43,6 +56,17 @@ 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 文件缓存的键 @@ -65,16 +89,21 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger { } l := &Logger{ - ctx: ctx, - filePath: make(map[fileKey]*logFile), - mu: &sync.RWMutex{}, - writeMu: &sync.Mutex{}, - option: opt, - writeType: writeTypeDefault, - closeOnce: &sync.Once{}, - done: make(chan struct{}), - workerDone: make(chan struct{}), - async: &asyncCtl{}, + ctx: ctx, + filePath: make(map[fileKey]*logFile), + mu: &sync.RWMutex{}, + writeMu: &sync.Mutex{}, + option: opt, + writeType: writeTypeDefault, + closeOnce: &sync.Once{}, + closed: &atomic.Bool{}, + done: make(chan struct{}), + workerDone: make(chan struct{}), + async: &asyncCtl{}, + basePathOnce: &sync.Once{}, + idxOnce: &sync.Once{}, + idxMu: &sync.Mutex{}, + compWg: &sync.WaitGroup{}, } log.SetOutput(l) @@ -89,6 +118,11 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger { // 日志删除 go l.delete() + // 定时刷盘 + if opt.flushEvery > 0 { + go l.flushLoop() + } + // 强制刷盘 // 用 done 而不是只监听 ctx:ctx 为 Background 时 Close 也要能把 goroutine 收回去 go func() { @@ -102,30 +136,78 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger { 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. 停投递 + 等消费协程把队列里已入队的任务全部写完 + // 1. 先停投递 + 等消费协程把队列里已入队的任务全部写完。 + // 这一步必须在「封盘」之前:队列里可能还有别的 channel / event 的任务, + // 它们需要新开自己的文件句柄。如果提前封盘,这些任务会直接写失败被丢掉 l.drainAsync() - // 2. 在写锁内完成最后一次刷盘与关闭 - // 此后不会再有写入(投递已关闭,同步写入也要先抢到这把锁) - l.writeMu.Lock() - syncErr := l.MustSync() - closeErr := l.close() - l.writeMu.Unlock() - err = joinErrors([]error{syncErr, closeErr}) + // 2. 封盘:此后不再新开句柄,避免 Close 扫完句柄表之后又冒出没人负责关的句柄 + l.closed.Store(true) - // 3. 最后再发通用关闭信号,让 delete / ctx 相关 goroutine 退出 + // 3. 在写锁内完成最后一次刷盘并关闭所有句柄。此时无锁等待,不会和谁抢锁 + l.writeMu.Lock() + err = joinErrors([]error{l.mustSyncLocked(), l.close()}) + l.writeMu.Unlock() + + // 4. 等后台压缩协程收尾:不等的话 Close 返回后去读归档会读到半截 .gz + l.compWg.Wait() + + // 5. 最后再发通用关闭信号,让 delete / ctx 相关 goroutine 退出 close(l.done) }) return 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 { @@ -135,6 +217,11 @@ func (l *Logger) MustSync() error { var errs []error for _, f := range files { + // 没有待落盘数据的文件直接跳过:省掉一次 fsync。 + // Close 仍然安全:logFile.Close 自己会再 Flush 一次并关句柄 + if !f.hasPending() { + continue + } errs = append(errs, f.Sync()) } return joinErrors(errs) diff --git a/loggerx_archive_cleanup_test.go b/loggerx_archive_cleanup_test.go new file mode 100644 index 0000000..20a9823 --- /dev/null +++ b/loggerx_archive_cleanup_test.go @@ -0,0 +1,87 @@ +package loggerx_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/yuninks/loggerx" +) + +// 归档滚动后,源文件不该留下(对应「压缩完成但无法删除原文件」这个报错) +// 覆盖单线程滚动 +func TestArchiveSourceRemovable(t *testing.T) { + for round := 0; round < 3; round++ { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(4*1024), + loggerx.SetCompress(true), + ) + for i := 0; i < 600; i++ { + l.Infof(context.Background(), "ROLL-%d-%s", i, strings.Repeat("z", 60)) + } + if err := l.Close(); err != nil { + t.Fatalf("第 %d 轮 Close: %v", round, err) + } + if leftover := findLeftoverArchives(t, dir); len(leftover) > 0 { + t.Errorf("第 %d 轮:这些归档的源文件没删掉(白占一份磁盘): %v", round, leftover) + } + } +} + +// 并发 + 多 channel + 异步 + 高频刷盘同时压,归档源文件同样不能残留 +func TestArchiveSourceRemovableConcurrent(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(2*1024), + loggerx.SetCompress(true), + loggerx.SetWriteAsync(), + loggerx.SetFlushInterval(5*time.Millisecond), + ) + + var wg sync.WaitGroup + ctx := context.Background() + for w := 0; w < 8; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < 200; i++ { + l.Channel(fmt.Sprintf("c%d", w%3)).Infof(ctx, "CONC-%d-%d-%s", w, i, strings.Repeat("q", 40)) + l.Infof(ctx, "ROOT-%d-%d", w, i) + _ = l.MustSync() + } + }(w) + } + wg.Wait() + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if leftover := findLeftoverArchives(t, dir); len(leftover) > 0 { + t.Errorf("并发滚动下这些归档的源文件没删掉: %v", leftover) + } +} + +// findLeftoverArchives 找出「.gz 与同名 .log 成对存在」的冗余源文件 +func findLeftoverArchives(t *testing.T, dir string) []string { + t.Helper() + var leftover []string + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(p, ".gz") { + return nil + } + base := strings.TrimSuffix(p, ".gz") + if _, serr := os.Stat(base); serr == nil { + leftover = append(leftover, filepath.Base(base)) + } + return nil + }) + return leftover +} diff --git a/loggerx_bench_test.go b/loggerx_bench_test.go new file mode 100644 index 0000000..0b9acf4 --- /dev/null +++ b/loggerx_bench_test.go @@ -0,0 +1,78 @@ +package loggerx_test + +import ( + "context" + "io" + "testing" + "time" + + "github.com/yuninks/loggerx" +) + +func benchLogger(b *testing.B, opts ...loggerx.Option) *loggerx.Logger { + b.Helper() + // 预热时区:Windows 上 time.Local 首次使用会去读注册表(一次性、很贵), + // 不预热会把这笔初始化成本摊到单次日志耗时里 + _, _ = time.Now().In(time.Local).Zone() + opts = append([]loggerx.Option{loggerx.SetDir(b.TempDir())}, opts...) + l := loggerx.NewLogger(context.Background(), opts...) + b.Cleanup(func() { _ = l.Close() }) + return l +} + +// 同步写入:每条都进缓冲(32KB 才落盘一次) +func BenchmarkWriteSync(b *testing.B) { + l := benchLogger(b) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Infof(ctx, "hello %d", i) + } +} + +// 异步写入 +func BenchmarkWriteAsync(b *testing.B) { + l := benchLogger(b, loggerx.SetWriteAsync()) + al := l.WriteAsync() + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + al.Infof(ctx, "hello %d", i) + } +} + +// 并行写入(16 核) +func BenchmarkWriteParallel(b *testing.B) { + l := benchLogger(b) + ctx := context.Background() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + l.Infof(ctx, "hello parallel") + } + }) +} + +// 关闭 goroutine id、关掉文件输出:看纯格式化开销 +func BenchmarkFormatOnly(b *testing.B) { + l := benchLogger(b, loggerx.SetPrintFile(false), loggerx.SetExtraDriver(io.Discard), loggerx.SetGID(false)) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Infof(ctx, "hello %d", i) + } +} + +// 默认(含 goroutine id + 写文件) +func BenchmarkDefault(b *testing.B) { + l := benchLogger(b) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Info(ctx, "hello") + } +} diff --git a/loggerx_channel_async_test.go b/loggerx_channel_async_test.go new file mode 100644 index 0000000..e60c580 --- /dev/null +++ b/loggerx_channel_async_test.go @@ -0,0 +1,72 @@ +package loggerx_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yuninks/loggerx" +) + +// 异步模式下 Channel + error 组合是否落盘 +func TestAsyncChannelErrorWrites(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetWriteAsync(), + loggerx.SetSizeSplit(6*1024), + ) + for i := 0; i < 10; i++ { + l.Info(context.Background(), "main") + l.Channel("sub").Errorf(context.Background(), "ERR-%d", i) + l.Channel("sub").Infof(context.Background(), "SUBINFO-%d", i) + l.Errorf(context.Background(), "MAINERR-%d", i) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(dir, p) + if info.IsDir() { + t.Logf(" [dir] %s", rel) + } else { + t.Logf(" [file] %s %d", rel, info.Size()) + } + return nil + }) + + // sub channel 的日志必须落在 sub 子目录,不能混进根目录 + all := readAllLogs(t, dir) + if n := countOf(all, "SUBINFO"); n != 0 { + t.Errorf("根目录日志里混入了 %d 条 sub channel 的 info 日志", n) + } + if _, err := os.Stat(filepath.Join(dir, "sub")); err != nil { + t.Fatalf("channel 目录不存在: %v", err) + } + sub := readAllIn(t, dir, "sub") + for i := 0; i < 10; i++ { + if !strings.Contains(sub, fmt.Sprintf("ERR-%d", i)) { + t.Errorf("sub channel 丢失 error 日志 ERR-%d", i) + } + if !strings.Contains(sub, fmt.Sprintf("SUBINFO-%d", i)) { + t.Errorf("sub channel 丢失 info 日志 SUBINFO-%d", i) + } + } +} + +func countOf(s, sub string) int { + n := 0 + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + n++ + } + } + return n +} diff --git a/loggerx_combined_test.go b/loggerx_combined_test.go new file mode 100644 index 0000000..9be24d0 --- /dev/null +++ b/loggerx_combined_test.go @@ -0,0 +1,130 @@ +package loggerx_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yuninks/loggerx" +) + +// 定时刷盘 + 异步写入 + 按大小切割 + 压缩 全部开启时的综合正确性 +func TestAllFeaturesCombined(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetWriteAsync(), + loggerx.SetFlushInterval(15*time.Millisecond), + loggerx.SetSizeSplit(6*1024), + loggerx.SetCompress(true), + ) + + al := l.WriteAsync() + const n = 1500 + for i := 0; i < n; i++ { + // 注意:内容里带了固定后缀,条目文本形如 ALL-0-yyy..., + // 断言时用 "ALL--" 前缀而不是 "ALL-\"" + al.Infof(context.Background(), "ALL-%d-%s", i, strings.Repeat("y", 40)) + // 顺带混入 channel 与 error 事件,确认多句柄并发滚动也正常 + if i%50 == 0 { + l.Channel("sub").Errorf(context.Background(), "ERR-%d", i) + } + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // 主日志(含压缩归档)一条不能少 + gzContent := readAllGz(t, dir) + logContent := readAllLogs(t, dir) + main := logContent + gzContent + t.Logf("未压缩 .log 共 %d 字节;.gz 解压后共 %d 字节", len(logContent), len(gzContent)) + if len(gzContent) > 0 { + n := 200 + if len(gzContent) < n { + n = len(gzContent) + } + t.Logf("gz 内容片段: %.200q", gzContent[:n]) + } + missing := 0 + for i := 0; i < n; i++ { + if !strings.Contains(main, fmt.Sprintf("ALL-%d-", i)) { + missing++ + } + } + if missing > 0 { + t.Errorf("综合场景丢失 %d / %d 条主日志", missing, n) + } + + // sub channel 的 error 日志也要完整 + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(dir, p) + if info.IsDir() { + t.Logf(" [dir] %s", rel) + } else { + t.Logf(" [file] %s %d 字节", rel, info.Size()) + } + return nil + }) + sub := readAllIn(t, dir, "sub") + for i := 0; i < n; i += 50 { + if !strings.Contains(sub, fmt.Sprintf("ERR-%d", i)) { + t.Errorf("sub channel 丢失 ERR-%d", i) + } + } +} + +// readAllIn 读取某个子目录(channel 目录)下的日志与压缩归档 +func readAllIn(t *testing.T, dir, sub string) string { + t.Helper() + target := filepath.Join(dir, sub) + if _, err := os.Stat(target); err != nil { + t.Fatalf("channel 目录不存在: %v", err) + } + var sb strings.Builder + files, _ := filepath.Glob(filepath.Join(target, "*")) + for _, f := range files { + st, err := os.Stat(f) + if err != nil || st.IsDir() { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("读取 %s: %v", f, err) + } + sb.Write(b) + } + return sb.String() +} + +// 综合场景下定时刷盘不能把 Close 卡死(刷盘协程与关闭的互锁) +func TestAllFeaturesCloseNotBlocked(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetFlushInterval(1*time.Millisecond), // 刷盘非常频繁,放大竞争 + loggerx.SetSizeSplit(2*1024), + ) + for i := 0; i < 800; i++ { + l.Infof(context.Background(), "BLK-%d", i) + } + + done := make(chan error, 1) + go func() { done <- l.Close() }() + + select { + case err := <-done: + if err != nil { + t.Errorf("Close 返回错误: %v", err) + } + case <-time.After(20 * time.Second): + t.Fatal("高频定时刷盘时 Close 卡死") + } +} diff --git a/loggerx_compress_cleanup_test.go b/loggerx_compress_cleanup_test.go new file mode 100644 index 0000000..9019983 --- /dev/null +++ b/loggerx_compress_cleanup_test.go @@ -0,0 +1,53 @@ +package loggerx_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yuninks/loggerx" +) + +func statFile(p string) (os.FileInfo, error) { return os.Stat(p) } + +// Close() 返回后压缩必须已经收尾:不应该残留「既是 .log 又有同名 .gz」的中间态 +func TestCompressionFinishedAfterClose(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(4*1024), + ) + for i := 0; i < 500; i++ { + l.Infof(context.Background(), "FIN-%d", i) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + logs, _ := filepath.Glob(filepath.Join(dir, "*.log")) + gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) + t.Logf("Close 后: %d 个 .log, %d 个 .gz", len(logs), len(gzs)) + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + info, _ := e.Info() + t.Logf(" %s %d 字节", e.Name(), info.Size()) + } + + // 对每个 .gz,不应还存在同名的 .log(说明压缩成功后原文件已清理) + for _, g := range gzs { + base := strings.TrimSuffix(g, ".gz") + if _, err := statFile(base); err == nil { + t.Errorf("压缩完成后原文件仍存在: %s", filepath.Base(base)) + } + } + // gz 加 log 的总数应该等于归档总数(每个归档恰好以一种形态存在) + if len(gzs) == 0 { + t.Fatal("没有产生 .gz 归档") + } +} diff --git a/loggerx_features_bench_test.go b/loggerx_features_bench_test.go new file mode 100644 index 0000000..a36ae06 --- /dev/null +++ b/loggerx_features_bench_test.go @@ -0,0 +1,53 @@ +package loggerx_test + +import ( + "context" + "testing" + "time" + + "github.com/yuninks/loggerx" +) + +// 按大小切割开启后,每条日志会多一次 Stat(判断是否该滚动)的开销 +func BenchmarkWriteWithSizeSplit(b *testing.B) { + l := benchLogger(b, loggerx.SetSizeSplit(8<<20)) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Infof(ctx, "hello %d", i) + } +} + +// 不开大小切割的对照 +func BenchmarkWriteNoSizeSplit(b *testing.B) { + l := benchLogger(b) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Infof(ctx, "hello %d", i) + } +} + +// 定时刷盘开启后(间隔取大值,避免刷盘本身干扰测量) +func BenchmarkWriteWithFlushInterval(b *testing.B) { + l := benchLogger(b, loggerx.SetFlushInterval(time.Hour)) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Infof(ctx, "hello %d", i) + } +} + +// 定时刷盘:短间隔(1ms)下的稳态吞吐,含刷盘协程竞争 +func BenchmarkWriteShortFlushInterval(b *testing.B) { + l := benchLogger(b, loggerx.SetFlushInterval(time.Millisecond)) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + l.Infof(ctx, "hello %d", i) + } +} diff --git a/loggerx_features_test.go b/loggerx_features_test.go new file mode 100644 index 0000000..991beef --- /dev/null +++ b/loggerx_features_test.go @@ -0,0 +1,301 @@ +package loggerx_test + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yuninks/loggerx" +) + +// ---------- 功能 1:定时刷盘 ---------- + +// 开启定时刷盘后,未 Close 也能在磁盘上看到日志 +func TestFlushIntervalPersistsWithoutClose(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetFlushInterval(30*time.Millisecond), + ) + defer l.Close() + + l.Info(context.Background(), "FLUSHED-BY-TIMER") + + // 等若干次刷盘周期;不调用 MustSync,也不 Close + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(readAllLogs(t, dir), "FLUSHED-BY-TIMER") { + return // 成功:定时器把它刷下去了 + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("定时刷盘没生效:未 Close 时磁盘上看不到日志") +} + +// 不开定时刷盘时,日志应该仍留在内存缓冲里(证明上面的测试不是假通过) +func TestNoFlushIntervalKeepsInBuffer(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir)) + defer l.Close() + + l.Info(context.Background(), "STILL-BUFFERED") + time.Sleep(200 * time.Millisecond) + + if strings.Contains(readAllLogs(t, dir), "STILL-BUFFERED") { + t.Skip("本次写入正好触发了缓冲落盘,跳过该对照") + } +} + +// 定时刷盘不能丢日志、也不能让 Close 之后 goroutine 残留 +func TestFlushIntervalNoLossAndStops(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetFlushInterval(10*time.Millisecond), + ) + + const n = 3000 + for i := 0; i < n; i++ { + l.Infof(context.Background(), "FL-%d", i) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + content := readAllLogs(t, dir) + missing := 0 + for i := 0; i < n; i++ { + if !strings.Contains(content, fmt.Sprintf(`FL-%d"`, i)) { + missing++ + } + } + if missing > 0 { + t.Errorf("定时刷盘场景丢了 %d 条日志", missing) + } +} + +// ---------- 功能 2:按大小切割 + 压缩 ---------- + +// 超过大小上限后应该滚动出带序号的归档文件,并压缩成 .gz +func TestSizeSplitRollsAndCompresses(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(8*1024), // 8KB 一个文件 + ) + + const n = 800 // 每条约 130 字节 => 约 104KB,应滚出十来个文件 + for i := 0; i < n; i++ { + l.Infof(context.Background(), "SPLIT-%d-%s", i, strings.Repeat("x", 60)) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + files, _ := filepath.Glob(filepath.Join(dir, "*.log")) + gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) + t.Logf("滚动结果: %d 个 .log, %d 个 .gz", len(files), len(gzs)) + + if len(gzs) == 0 { + t.Fatal("没有产生任何压缩归档 .gz") + } + // 每个未压缩文件都不应明显超过上限(留一点余量给缓冲/单条超长) + for _, f := range files { + st, err := os.Stat(f) + if err != nil { + t.Fatalf("stat %s: %v", f, err) + } + if st.Size() > 8*1024+2048 { + t.Errorf("%s 大小 %d 超过上限过多", filepath.Base(f), st.Size()) + } + } + // 归档文件名应带序号 + for _, g := range gzs { + if !strings.Contains(filepath.Base(g), "_") { + t.Errorf("归档文件名缺少序号: %s", filepath.Base(g)) + } + } +} + +// 压缩归档要能被解开,且内容是完整合法的日志(不能丢条) +func TestCompressedArchiveIsReadable(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(4*1024), + ) + const n = 400 + for i := 0; i < n; i++ { + l.Infof(context.Background(), "GZ-%d", i) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) + if len(gzs) == 0 { + t.Fatal("没有产生 .gz 归档") + } + + // 压缩归档 + 当前未压缩文件一起统计,必须一条不漏 + content := readAllGz(t, dir) + readAllLogs(t, dir) + if !strings.Contains(content, "[info]{") { + t.Errorf("内容不像日志: %.120q", content) + } + missing := 0 + for i := 0; i < n; i++ { + if !strings.Contains(content, fmt.Sprintf(`GZ-%d"`, i)) { + missing++ + } + } + if missing > 0 { + t.Errorf("压缩归档丢失 %d / %d 条日志", missing, n) + } + t.Logf("共 %d 个 .gz 归档,解压后日志完整(%d 条)", len(gzs), n) +} + +// 关闭压缩时,归档应保持未压缩的 .log 形态 +func TestSizeSplitWithoutCompress(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(4*1024), + loggerx.SetCompress(false), + ) + for i := 0; i < 300; i++ { + l.Infof(context.Background(), "RAW-%d", i) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + gzs, _ := filepath.Glob(filepath.Join(dir, "*.log.gz")) + if len(gzs) != 0 { + t.Errorf("SetCompress(false) 仍然产生了 %d 个 .gz", len(gzs)) + } + files, _ := filepath.Glob(filepath.Join(dir, "*.log")) + if len(files) < 2 { + t.Errorf("期望滚出多个归档文件,实际 %d 个", len(files)) + } +} + +// 滚动过程中不能丢日志:所有序号都应在 .log 或 .gz 里找得到 +func TestSizeSplitNoLoss(t *testing.T) { + for round := 0; round < 3; round++ { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(8*1024), + ) + const n = 600 + for i := 0; i < n; i++ { + l.Infof(context.Background(), "NOLOSS-%d", i) + } + if err := l.Close(); err != nil { + t.Fatalf("第 %d 轮 Close: %v", round, err) + } + + content := readAllLogs(t, dir) + readAllGz(t, dir) + missing := 0 + for i := 0; i < n; i++ { + if !strings.Contains(content, fmt.Sprintf(`NOLOSS-%d"`, i)) { + missing++ + } + } + if missing > 0 { + t.Errorf("第 %d 轮:按大小切割丢了 %d 条日志", round, missing) + } + } +} + +// 单条日志就超过上限时不能死循环 +func TestSizeSplitHugeSingleEntry(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), + loggerx.SetDir(dir), + loggerx.SetSizeSplit(1024), // 上限比单条还小 + ) + done := make(chan struct{}) + go func() { + defer close(done) + l.Info(context.Background(), strings.Repeat("H", 8192)) + l.Info(context.Background(), "after-huge") + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("单条超长日志导致卡死(可能是滚动死循环)") + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !strings.Contains(readAllLogs(t, dir)+readAllGz(t, dir), "after-huge") { + t.Error("超长日志之后的那条日志丢失了") + } +} + +// 重启场景:文件已经满了,新进程开起来应该归档旧文件而不是永远写同一个 +func TestSizeSplitOnRestart(t *testing.T) { + dir := t.TempDir() + opts := []loggerx.Option{loggerx.SetDir(dir), loggerx.SetSizeSplit(4 * 1024)} + + l1 := loggerx.NewLogger(context.Background(), opts...) + for i := 0; i < 200; i++ { + l1.Infof(context.Background(), "RUN1-%d", i) + } + if err := l1.Close(); err != nil { + t.Fatalf("l1.Close: %v", err) + } + + l2 := loggerx.NewLogger(context.Background(), opts...) + for i := 0; i < 200; i++ { + l2.Infof(context.Background(), "RUN2-%d", i) + } + if err := l2.Close(); err != nil { + t.Fatalf("l2.Close: %v", err) + } + + content := readAllLogs(t, dir) + readAllGz(t, dir) + for i := 0; i < 200; i++ { + if !strings.Contains(content, fmt.Sprintf(`RUN2-%d"`, i)) { + t.Fatalf("重启后 RUN2-%d 丢失", i) + } + } + // 归档序号不应互相覆盖:RUN1 与 RUN2 都应该在 + if !strings.Contains(content, `RUN1-0"`) { + t.Error("第一轮运行的日志被覆盖了") + } +} + +// readAllGz 解开目录下所有 .gz 归档 +func readAllGz(t *testing.T, dir string) string { + t.Helper() + var sb strings.Builder + gzs, _ := filepath.Glob(filepath.Join(dir, "*.gz")) + for _, g := range gzs { + f, err := os.Open(g) + if err != nil { + t.Fatalf("打开 %s: %v", g, err) + } + zr, err := gzip.NewReader(f) + if err != nil { + _ = f.Close() + t.Fatalf("%s 不是合法 gzip: %v", g, err) + } + b, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("解压 %s: %v", g, err) + } + _ = zr.Close() + _ = f.Close() + sb.Write(b) + } + return sb.String() +} diff --git a/loggerx_heal_test.go b/loggerx_heal_test.go new file mode 100644 index 0000000..263183d --- /dev/null +++ b/loggerx_heal_test.go @@ -0,0 +1,84 @@ +package loggerx + +import ( + "os" + "path/filepath" + "testing" +) + +// 自愈:能删掉「.gz + .log 成对」里冗余的 .log +func TestHealRemovesRedundantLog(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "2026-09-13_info_1.log") + if err := os.WriteFile(src, []byte("hello log\n"), 0644); err != nil { + t.Fatal(err) + } + if err := gzipFile(src, 1); err != nil { + t.Fatal(err) + } + // 人为造出「压缩成功但源文件还在」的现场 + if err := os.WriteFile(src, []byte("hello log\n"), 0644); err != nil { + t.Fatal(err) + } + + l := &Logger{option: defaultOptions()} + l.option.dir = dir + l.option.compress = true + + if err := l.healCompressedLeftovers(); err != nil { + t.Fatalf("自愈出错: %v", err) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Errorf("冗余的 .log 没被清掉: %v", err) + } + if _, err := os.Stat(src + ".gz"); err != nil { + t.Errorf(".gz 被误删了: %v", err) + } +} + +// 安全性:.gz 损坏时必须保留 .log(那是唯一可读副本) +func TestHealKeepsLogWhenGzCorrupt(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "2026-09-13_info_2.log") + if err := os.WriteFile(src, []byte("important data\n"), 0644); err != nil { + t.Fatal(err) + } + // 造一个损坏的 .gz(截断的非 gzip 内容) + if err := os.WriteFile(src+".gz", []byte("not a gzip stream"), 0644); err != nil { + t.Fatal(err) + } + + l := &Logger{option: defaultOptions()} + l.option.dir = dir + l.option.compress = true + + if err := l.healCompressedLeftovers(); err != nil { + t.Fatalf("自愈出错: %v", err) + } + if _, err := os.Stat(src); err != nil { + t.Errorf("损坏压缩包时不该删掉 .log: %v", err) + } +} + +// 关闭压缩时不做自愈(用户有意保留未压缩归档) +func TestHealSkippedWhenCompressOff(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "2026-09-13_info_3.log") + if err := os.WriteFile(src, []byte("data\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(src+".gz", []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + l := &Logger{option: defaultOptions()} + l.option.dir = dir + l.option.compress = false + + if err := l.healCompressedLeftovers(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(src); err != nil { + t.Errorf("关闭压缩时不该清理 .log: %v", err) + } +} diff --git a/loggerx_integrity_test.go b/loggerx_integrity_test.go new file mode 100644 index 0000000..4ec5dfe --- /dev/null +++ b/loggerx_integrity_test.go @@ -0,0 +1,65 @@ +package loggerx_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/yuninks/loggerx" +) + +// MustSync 与写入并发时是否安全(配合 -race 运行) +func TestMustSyncConcurrentWithWrite(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir)) + defer l.Close() + + var wg sync.WaitGroup + ctx := context.Background() + + // 一边写,一边调 MustSync + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + l.Infof(ctx, "concurrent-%d", i) + } + }() + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + _ = l.MustSync() + } + }() + wg.Wait() +} + +// goroutine id 开关是否真的生效(SetGID(false) 后不应再出现 gid 字段) +func TestSetGIDActuallyDisablesGID(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetGID(false)) + l.Info(context.Background(), "no-gid") + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + var sb strings.Builder + files, _ := filepath.Glob(filepath.Join(dir, "*.log")) + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("读取 %s: %v", f, err) + } + sb.Write(b) + } + content := sb.String() + if content == "" { + t.Fatal("没有写入任何内容") + } + if strings.Contains(content, `"gid":`) { + t.Errorf("SetGID(false) 未生效,日志里仍有 gid: %s", content) + } +} diff --git a/loggerx_mustsync_test.go b/loggerx_mustsync_test.go new file mode 100644 index 0000000..0213ea1 --- /dev/null +++ b/loggerx_mustsync_test.go @@ -0,0 +1,55 @@ +package loggerx_test + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + + "github.com/yuninks/loggerx" +) + +// MustSync 与写入并发时不能丢日志(模拟外部定时刷盘的用法) +func TestMustSyncConcurrentNoLoss(t *testing.T) { + for round := 0; round < 5; round++ { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir)) + ctx := context.Background() + + const n = 2000 + var wg sync.WaitGroup + wg.Add(2) + + // 持续写入 + go func() { + defer wg.Done() + for i := 0; i < n; i++ { + l.Infof(ctx, "MS-%d", i) + } + }() + // 持续外部刷盘(这正是 MustSync 的公开用途) + go func() { + defer wg.Done() + for i := 0; i < 300; i++ { + _ = l.MustSync() + } + }() + wg.Wait() + + if err := l.Close(); err != nil { + t.Fatalf("第 %d 轮 Close: %v", round, err) + } + + content := readAllLogs(t, dir) + missing := 0 + for i := 0; i < n; i++ { + if !strings.Contains(content, fmt.Sprintf(`MS-%d"`, i)) { + missing++ + } + } + if missing > 0 { + t.Errorf("第 %d 轮:写入 %d 条,丢失 %d 条", round, n, missing) + } + } +} diff --git a/loggerx_overflow_test.go b/loggerx_overflow_test.go new file mode 100644 index 0000000..5f3758f --- /dev/null +++ b/loggerx_overflow_test.go @@ -0,0 +1,102 @@ +package loggerx_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/yuninks/loggerx" +) + +func readAllLogs(t *testing.T, dir string) string { + t.Helper() + var sb strings.Builder + files, _ := filepath.Glob(filepath.Join(dir, "*.log")) + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("读取 %s: %v", f, err) + } + sb.Write(b) + } + return sb.String() +} + +// 写入量远超异步队列容量(1000)时的行为 +func TestAsyncQueueOverflow(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetWriteAsync()) + + const n = 5000 + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < n; i++ { + l.Infof(context.Background(), "OVF-%d", i) + } + }() + + // 等写完,再关闭 + <-done + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + content := readAllLogs(t, dir) + missing := 0 + for i := 0; i < n; i++ { + if !strings.Contains(content, fmt.Sprintf(`OVF-%d"`, i)) { + missing++ + } + } + t.Logf("写入 %d 条,落盘缺失 %d 条,文件字节=%d", n, missing, len(content)) + if missing > 0 { + t.Errorf("队列溢出场景丢日志 %d 条", missing) + } +} + +// 超过队列容量且「边写边关闭」:不能永久阻塞 +func TestAsyncOverflowConcurrentClose(t *testing.T) { + dir := t.TempDir() + l := loggerx.NewLogger(context.Background(), loggerx.SetDir(dir), loggerx.SetWriteAsync()) + + const n = 5000 + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < n; i++ { + l.Infof(context.Background(), "CLOSE-%d", i) + } + }() + + // 写一半就关 + closeDone := make(chan error, 1) + go func() { closeDone <- l.Close() }() + + waitWrite := make(chan struct{}) + go func() { wg.Wait(); close(waitWrite) }() + + // 两者都必须在合理时间内结束,否则就是死锁 + for i := 0; i < 2; i++ { + select { + case err := <-closeDone: + if err != nil { + t.Errorf("Close 返回错误: %v", err) + } + closeDone = nil + case <-waitWrite: + waitWrite = nil + } + if closeDone == nil && waitWrite == nil { + break + } + } + if closeDone != nil || waitWrite != nil { + t.Fatal("写入或 Close 卡死(死锁)") + } +} diff --git a/loggerx_remove_retry_test.go b/loggerx_remove_retry_test.go new file mode 100644 index 0000000..53d115d --- /dev/null +++ b/loggerx_remove_retry_test.go @@ -0,0 +1,121 @@ +package loggerx + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// removeWithRetry 遇到「文件被占用」应重试;占用解除后必须删掉 +func TestRemoveWithRetryHandlesTransientLock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "locked.log") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + // 先独占打开,制造占用 + holder, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + locked := make(chan struct{}) + release := make(chan struct{}) + closed := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + close(locked) + <-release + _ = holder.Close() + close(closed) + }() + <-locked + + // 120ms 后释放占用;退避总时长(20+40+80+160+320+640 ≈ 1.2s)要能覆盖 + go func() { + time.Sleep(120 * time.Millisecond) + close(release) + }() + + start := time.Now() + err = removeWithRetry(path, 7) + elapsed := time.Since(start) + <-closed + wg.Wait() + + if err != nil { + t.Fatalf("占用解除后仍未删除成功: %v", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Errorf("文件仍然存在") + } + t.Logf("重试 %v 后删除成功", elapsed.Round(time.Millisecond)) +} + +// 不可重试的错误(文件不存在)应立即返回,不做无谓等待 +func TestRemoveWithRetryFastFail(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "nope.log") + + start := time.Now() + err := removeWithRetry(missing, 5) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("删除不存在的文件应该返回错误") + } + if elapsed > 100*time.Millisecond { + t.Errorf("不该对不可重试的错误做退避等待,耗时 %v", elapsed) + } +} + +// isTransientRemoveErr 判定 +func TestIsTransientRemoveErr(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + {"remove x.log: The process cannot access the file because it is being used by another process.", true}, + {"sharing violation", true}, + {"resource busy", true}, + {"permission denied", false}, + {"no such file or directory", false}, + } + for _, c := range cases { + got := isTransientRemoveErr(fmt.Errorf("%s", c.msg)) + if got != c.want { + t.Errorf("isTransientRemoveErr(%q) = %v, 期望 %v", c.msg, got, c.want) + } + } + if isTransientRemoveErr(nil) { + t.Error("nil 不应判为可重试") + } +} + +// 压缩完成后,源文件与 .gz 不应长期并存 +func TestCompressCleansSourceFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "a.log") + if err := os.WriteFile(src, []byte(strings.Repeat("log line\n", 500)), 0644); err != nil { + t.Fatal(err) + } + + if err := gzipFile(src, 1); err != nil { + t.Fatalf("gzipFile: %v", err) + } + if _, err := os.Stat(src + ".gz"); err != nil { + t.Fatalf("没有生成 .gz: %v", err) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Errorf("源文件没有被删除: %v", err) + } + if _, err := os.Stat(src + ".gz.tmp"); !os.IsNotExist(err) { + t.Errorf("残留了 .gz.tmp") + } +} diff --git a/options.go b/options.go index e91f929..6553cc0 100644 --- a/options.go +++ b/options.go @@ -1,6 +1,7 @@ package loggerx import ( + "compress/gzip" "io" "os" "time" @@ -19,7 +20,10 @@ type loggerOption struct { days int // 日志保存天数 drivers []io.Writer // 文件落盘驱动器 fileSplit FileSplit // 文件切割规则 - sizeSplit int // 根据文件大小切割 + sizeSplit int // 根据文件大小切割(字节,<=0 不切割) + compress bool // 归档文件是否压缩为 .gz + compressLvl int // gzip 压缩级别 + flushEvery time.Duration // 定时刷盘间隔,0 表示不开启 timeZone *time.Location // 时区 escapeHTML bool expandData map[string]string // 扩展字段 @@ -45,6 +49,8 @@ func defaultOptions() loggerOption { traceField: "trace_id", days: 7, fileSplit: FileSplitTimeE, + compress: true, + compressLvl: gzip.BestSpeed, timeZone: time.Local, escapeHTML: true, expandData: make(map[string]string), @@ -186,13 +192,43 @@ const ( ) -// 根据文件大小切割(暂时未生效) +// 根据文件大小切割 +// m 为单个文件的大小上限(字节);<=0 表示不按大小切割 +// 超过上限时当前文件会被改名归档(并可选压缩),随后写入新文件 func SetSizeSplit(m int) Option { return func(o *loggerOption) { o.sizeSplit = m } } +// 归档文件是否压缩成 .gz(默认开启) +// 压缩在后台协程完成,不阻塞写入 +func SetCompress(open bool) Option { + return func(o *loggerOption) { + o.compress = open + } +} + +// 压缩级别 1~9,数字越大压缩率越高、CPU 开销越大,默认 gzip.BestSpeed +func SetCompressLevel(level int) Option { + return func(o *loggerOption) { + if level >= gzip.HuffmanOnly && level <= gzip.BestCompression { + o.compressLvl = level + } + } +} + +// 定时刷盘间隔(默认 0 = 不开启) +// 开启后会按间隔把内存缓冲刷到磁盘,把「进程崩溃时可能丢失的数据量」 +// 从「最多 32KB 缓冲」压缩到「一个间隔内产生的日志量」 +func SetFlushInterval(d time.Duration) Option { + return func(o *loggerOption) { + if d > 0 { + o.flushEvery = d + } + } +} + func SetEscapeHTML(b bool) Option { return func(o *loggerOption) { o.escapeHTML = b diff --git a/readme.md b/readme.md index fa902b2..c088e11 100644 --- a/readme.md +++ b/readme.md @@ -1,26 +1,163 @@ -# 简介 +# loggerx -这个是基于原生log实现的日志存储。 +基于 Go 原生 `log` 封装的日志库:支持按时间切分文件、同步/异步落盘、channel 分目录、Gin 中间件。 ```go +log := loggerx.NewLogger(ctx, loggerx.SetDir("./log"), loggerx.SetToConsole()) +defer log.Close() // 退出前务必调用,否则最后一批日志(最多 32KB)不会落盘 -log.Println("ddddd") - +log.Info(ctx, "hello") +log.Infof(ctx, "hello %s", "world") ``` -# 用法 +## 用法 -# 开发计划 +### 创建与选项 -1. [ ] 自动清除过期的日志文件 -2. [ ] 支持日志文件压缩 +```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.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 +g := gin.Default() +log := loggerx.NewLogger(context.Background(), loggerx.SetToConsole()) +defer log.Close() + +g.Use(middleware.SetGinTraceIdByLogger(log)) // 读取/生成 trace_id +g.Use(middleware.SetGinParams(log)) // 记录请求与响应 +``` + +### 与标准库 log 互通 + +`NewLogger` 会把全局 `log` 的输出接管到该实例,同时继承 `io.Writer`,可以直接传给任何需要 `io.Writer` 的地方: + +```go +log.SetOutput(loggerx.NewLogger(ctx, loggerx.SetDir("./log"))) +``` + +## 落盘行为与保证 + +- **缓冲**:每条日志先写进 32KB 内存缓冲,写满才 `write` 一次系统调用。 +- **完整性**:以下四种方式都会把缓冲落盘 —— 缓冲写满、`Close()`、`MustSync()`、`SetFlushInterval` 定时器。 +- **崩溃语义**:进程被 `kill -9`、panic 未恢复、断电时,**最多丢失最后 32KB** 未落盘的日志; + 开启 `SetFlushInterval` 后,丢失量收敛到「一个间隔内产生的日志量」。 +- **关闭语义**:`Close()` 之后该实例不再接受写入(返回 `loggerx: 日志已关闭`); + 会等异步队列排空、等压缩归档收尾;重复调用安全;不泄漏文件句柄。 +- **写失败**:写文件失败会重试一次(重开句柄);磁盘满等持续失败时该条日志会丢, + 错误通过 `io.Writer` 语义返回给调用方,建议对 `Write`/`MustSync`/`Close` 的返回值做检查。 +- **channel 隔离**:`Channel("x")` 的日志落在 `/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. [ ] 按照时间分割 - 2. [ ] 按照文件大小分割 +4. [ ] 支持日志文件归档到对象存储 +5. 支持多种文件分割类型 + 1. [X] 按照时间分割 + 2. [X] 按照文件大小分割(`SetSizeSplit`) 3. [ ] 按照日志行数分割 -6. [ ] 支持debug 模式 -7. [X] 添加异步落库,支持全局和单次 - 1. [ ] 优化:异步应该跟实例不应该全局,多实例异步落库将会有BUG -8. [X] 添加支持是否转义html +6. [ ] 支持日志级别过滤(`SetFormat("text")` 尚未生效) +7. [X] 异步落盘(按实例隔离,不再是全局队列) +8. [X] 支持是否转义 HTML +9. [X] 支持定时刷盘(`SetFlushInterval`) diff --git a/remove.go b/remove.go index b388967..300a343 100644 --- a/remove.go +++ b/remove.go @@ -23,6 +23,10 @@ func (l *Logger) delete() { fmt.Println("清理日志文件异常:", r) } }() + // 先清掉「压缩完成但源文件没删掉」留下的冗余文件 + if err := l.healCompressedLeftovers(); err != nil { + fmt.Println(err) + } if err := l.walkAndDel(); err != nil { fmt.Println(err) } @@ -59,7 +63,8 @@ func (l *Logger) walkAndDel() error { if !isEmptyDir(path) { return nil } - } else if filepath.Ext(path) != ".log" { + } else if ext := filepath.Ext(path); ext != ".log" && ext != ".gz" { + // 只清理日志与压缩归档 return nil } diff --git a/storage.go b/storage.go index 6e007c0..372b608 100644 --- a/storage.go +++ b/storage.go @@ -24,11 +24,16 @@ func (l *Logger) write(event string, b []byte) (n int, err error) { // 必须满足 io.Writer 契约:成功时 n == len(b),否则调用方(log / io.MultiWriter) // 会认为发生了短写并把日志吞掉 func (l *Logger) store(event string, b []byte) (n int, err error) { + return l.storeTo(l.channel, event, b) +} + +// storeTo 按指定 channel 落盘(异步消费协程用,它的 l.channel 是空的) +func (l *Logger) storeTo(channel, event string, b []byte) (n int, err error) { if l.option.isPrintFile { // 串行化写入:句柄缓冲区不是并发安全的, // 异步消费协程与同步调用可能同时写同一个句柄 l.writeMu.Lock() - n, err = l.storeFile(event, b) + n, err = l.storeFileTo(channel, event, b) l.writeMu.Unlock() if err != nil { return 0, err @@ -43,11 +48,25 @@ func (l *Logger) store(event string, b []byte) (n int, err error) { // storeFile 写入日志文件 func (l *Logger) storeFile(event string, b []byte) (int, error) { - f, err := l.getFile(event) + return l.storeFileTo(l.channel, event, b) +} + +// storeFileTo 把日志写进指定 channel 对应的文件 +func (l *Logger) storeFileTo(channel, event string, b []byte) (int, error) { + f, err := l.getFileTo(channel, event) if err != nil { return 0, err } + // 按大小切割:这条日志会把当前文件写超上限时,先归档当前文件再换新文件 + if limit := l.option.sizeSplit; limit > 0 && f.full(limit, len(b)) { + nf, rerr := l.rollFileTo(channel, event, f) + if rerr == nil { + f = nf + } + // 归档失败就继续往原文件写:宁可文件超一点,也不能把日志丢了 + } + n, err := f.Write(b) if err == nil && n < len(b) { err = io.ErrShortWrite @@ -58,8 +77,8 @@ func (l *Logger) storeFile(event string, b []byte) (int, error) { // 写入失败:丢弃这个句柄,落到磁盘后按最新文件名重开一次再写 // 只重试一次,避免原实现在短写时反复重开文件 - l.discardFile(event, f) - if nf, nerr := l.getFile(event); nerr == nil { + l.discardFileTo(channel, event, f) + if nf, nerr := l.getFileTo(channel, event); nerr == nil { if n2, err2 := nf.Write(b); err2 == nil && n2 == len(b) { return n2, nil } @@ -68,6 +87,43 @@ func (l *Logger) storeFile(event string, b []byte) (int, error) { return 0, err } +// rollFileTo 归档当前文件并返回新文件句柄 +func (l *Logger) rollFileTo(channel, event string, f *logFile) (*logFile, error) { + key := fileKey{channel: channel, event: event} + + // 先把缓冲清空再关句柄,保证归档内容完整 + if err := f.Close(); err != nil { + return nil, err + } + + // 归档基名直接用句柄记下的 baseName:绝不去猜文件名尾部的 _N 是不是归档序号 + // (按小时切割出来的名字本身就长这样:2026/09/13/06_info.log) + path, err := l.archive(f.baseName, f.fileName) + if err != nil { + return nil, err + } + + l.mu.Lock() + if cur, ok := l.filePath[key]; ok && cur == f { + delete(l.filePath, key) + } + l.mu.Unlock() + + // 新文件带走递增序号,避免覆盖刚归档出去的同名文件 + nf, err := l.openNumberedFile(key, l.nextIndex()) + if err != nil { + return nil, err + } + + l.mu.Lock() + l.filePath[key] = nf + l.mu.Unlock() + + // 后台压缩,不阻塞写入 + l.scheduleCompress(path) + return nf, nil +} + // 写入额外的驱动(控制台 / 自定义 writer) func (l *Logger) writeDrivers(b []byte) (int, error) { if len(l.option.drivers) == 0 { @@ -78,20 +134,29 @@ func (l *Logger) writeDrivers(b []byte) (int, error) { // discardFile 关闭文件并从缓存中移除,使下次写入重新打开 func (l *Logger) discardFile(event string, f *logFile) { + l.discardFileTo(l.channel, event, f) +} + +// discardFileTo 关闭并移除指定 channel 的文件句柄 +func (l *Logger) discardFileTo(channel, event string, f *logFile) { _ = f.Close() l.mu.Lock() defer l.mu.Unlock() - key := fileKey{channel: l.channel, event: event} + key := fileKey{channel: channel, event: event} if cur, ok := l.filePath[key]; ok && cur == f { delete(l.filePath, key) } } // 异步队列的任务 +// Channel 必须跟着任务走:消费协程持有的是「创建队列时的那个 logger」, +// 它的 channel 是空的。如果消费时用 logger 自己的 channel, +// 所有 channel 的日志都会落进根目录文件(曾经就是这么错的) type cacheData struct { - Event string - Data []byte + Channel string + Event string + Data []byte } // toAsync 尝试异步写入 @@ -105,11 +170,13 @@ func (l *Logger) toAsync(event string, b []byte) bool { // 整段「检查开关 + 入队」都在同一把锁内:Close 也拿这把锁来停止投递, // 这样 Close 拿到锁时就能确定「要么这条还没入队、要么已经完整入队」。 // 否则消费协程可能先看到空队列就退出,把还在路上的这条日志整条丢掉。 + // + // wg 用来告诉 drainAsync「投递临界区里还有人」:Close 必须等这些写入 + // 真正入队之后才能关闭队列,否则向已关闭的 channel 发送会 panic。 // 队列满时这里会阻塞,但消费者是独立 goroutine 且不需要这把锁,不会死锁 l.async.mu.Lock() - defer l.async.mu.Unlock() - if l.async.closed { + l.async.mu.Unlock() // 已开始关闭:退化为同步写入 return false } @@ -117,7 +184,13 @@ func (l *Logger) toAsync(event string, b []byte) bool { l.async.ch = make(chan cacheData, asyncQueueSize) go l.asyncWorker(l.async.ch) } - l.async.ch <- cacheData{Event: event, Data: b} + l.async.begin() + ch := l.async.ch + l.async.mu.Unlock() + + defer l.async.end() // 必须在解锁之后 Done,保证 begin/end 覆盖整段投递 + + ch <- cacheData{Channel: l.channel, Event: event, Data: b} return true } @@ -130,7 +203,8 @@ func (l *Logger) asyncWorker(q chan cacheData) { } }() for val := range q { - _, _ = l.store(val.Event, val.Data) + // 按任务里记的 channel 落盘,避免多个 channel 的日志混到根目录 + _, _ = l.storeTo(val.Channel, val.Event, val.Data) } } @@ -147,6 +221,11 @@ func (l *Logger) drainAsync() { return } + // 关键:必须等「已经进入投递临界区」的写入全部入队之后才能关队列。 + // 这里能看到 wg 已经是 0,就说明没有写入还卡在投递路径上, + // 否则 close(q) 之后它们再发就会 panic: send on closed channel + l.async.wg.Wait() + // 关闭队列并等消费者把缓冲里的任务全部处理完。 // 这一步必须真的等到 workerDone:否则 Close 会在消费协程还在写缓冲时 // 就刷盘并关闭文件,最后几条日志会连着句柄一起丢掉