package loggerx import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "time" ) // 文件操作 // errClosed 日志实例已关闭,不再接受新的文件写入 var errClosed = errors.New("loggerx: 日志已关闭") // 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志 const fileBufSize = 32 * 1024 // logFile 一个已打开的日志文件句柄 // 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态, // 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉 type logFile struct { file *os.File buf []byte pending int // written 已写进文件的字节数(内存计数) // 按大小切割要判断是否该滚动:每条日志都 Stat 一次要花约 8µs, // 而写了多少字节自己最清楚,只在打开文件时 Stat 一次做基准即可 written int64 fileName string // baseName 是 fileNameIn 的结果(不带大小切割的 _N 序号), // 用于判断是否跨了时间切割边界,以及归档时取规范基名 baseName string } // Write 写入缓冲,满了就落盘一次 func (f *logFile) Write(b []byte) (int, error) { written := 0 for len(b) > 0 { if f.pending == len(f.buf) { if err := f.Flush(); err != nil { return written, err } } n := copy(f.buf[f.pending:], b) f.pending += n written += n b = b[n:] } 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 { n, err := f.file.Write(p) p = p[n:] if err != nil { return err } if n == 0 { return io.ErrShortWrite } } return nil } // Flush 把缓冲写入文件(不做 fsync) func (f *logFile) Flush() error { if f.pending == 0 { return nil } if err := f.writeFull(f.buf[:f.pending]); err != nil { // 保留未写成功的部分,下次继续 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 if err := f.Flush(); err != nil { errs = append(errs, err) } if f.file != nil { if err := f.file.Sync(); err != nil { errs = append(errs, err) } } return joinErrors(errs) } // Close 落盘并关闭文件 func (f *logFile) Close() error { return joinErrors([]error{f.Sync(), f.file.Close()}) } // joinErrors 汇总错误,Go 1.20+ 用 errors.Join func joinErrors(errs []error) error { var real []error for _, err := range errs { if err != nil { real = append(real, err) } } if len(real) == 0 { return nil } return errors.Join(real...) } // 获取最新的文件名 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 { case FileSplitTimeA: // (年/月/日/时) prefix = time.Now().In(l.option.timeZone).Format("2006/01/02/15") case FileSplitTimeB: // (年/月/日) prefix = time.Now().In(l.option.timeZone).Format("2006/01/02") case FileSplitTimeC: // (年/月-日) prefix = time.Now().In(l.option.timeZone).Format("2006/01-02") case FileSplitTimeD: // (年-月-日-时) prefix = time.Now().In(l.option.timeZone).Format("2006-01-02-15") case FileSplitTimeE: // (年-月-日) prefix = time.Now().In(l.option.timeZone).Format("2006-01-02") } if prefix != "" { prefix = prefix + "_" } if channel != "" { prefix = channel + "/" + prefix } return l.option.dir + "/" + prefix + event + ".log" } // 新建文件 func (l *Logger) getFile(event string) (*logFile, error) { 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 } l.mu.Lock() defer l.mu.Unlock() // 双检:可能在等锁期间已经被别的 goroutine 建好了 if f := l.loadFileLocked(key); f != nil { return f, nil } // 已经关闭:不再新开句柄,否则没人负责关它 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 } } 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: fileName, } 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() defer l.mu.RUnlock() return l.loadFileLocked(key) } // loadFileLocked 调用方必须已持有锁 func (l *Logger) loadFileLocked(key fileKey) *logFile { f, ok := l.filePath[key] if !ok || f == nil { return nil } // 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建。 // 注意比的是不带序号的基名,否则按大小切割出的 _N 文件会被误判成跨天 if f.baseName != l.fileNameIn(key.channel, key.event) { delete(l.filePath, key) f.Close() return nil } return f } // close 关闭所有文件句柄(Close 专用) func (l *Logger) close() error { l.mu.Lock() defer l.mu.Unlock() var errs []error for key, f := range l.filePath { errs = append(errs, f.Close()) delete(l.filePath, key) } return joinErrors(errs) }