添加自动文件压缩的实现
This commit is contained in:
@@ -1 +1,9 @@
|
|||||||
log
|
log
|
||||||
|
|
||||||
|
# 测试/基准跑出来的日志文件(默认目录是 ./log,包级 logger 与部分测试会落到仓库根)
|
||||||
|
20??-*.log
|
||||||
|
20??-*.log.gz
|
||||||
|
*.log.gz.tmp
|
||||||
|
|
||||||
|
# 本地构建缓存
|
||||||
|
.tmp-gocache
|
||||||
|
|||||||
+264
@@ -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
|
||||||
|
}
|
||||||
+156
-12
@@ -6,11 +6,15 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 文件操作
|
// 文件操作
|
||||||
|
|
||||||
|
// errClosed 日志实例已关闭,不再接受新的文件写入
|
||||||
|
var errClosed = errors.New("loggerx: 日志已关闭")
|
||||||
|
|
||||||
// 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志
|
// 每个文件的写缓冲大小:越大越省系统调用,代价是崩溃时可能丢最后一批日志
|
||||||
const fileBufSize = 32 * 1024
|
const fileBufSize = 32 * 1024
|
||||||
|
|
||||||
@@ -18,10 +22,17 @@ const fileBufSize = 32 * 1024
|
|||||||
// 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态,
|
// 自己管理写缓冲(不用 bufio):bufio.Writer 在底层短写时会永久置位内部错误状态,
|
||||||
// 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉
|
// 之后所有写入和 Flush 都会失败,已攒下的一批日志会整批丢掉
|
||||||
type logFile struct {
|
type logFile struct {
|
||||||
file *os.File
|
file *os.File
|
||||||
buf []byte
|
buf []byte
|
||||||
pending int
|
pending int
|
||||||
|
// written 已写进文件的字节数(内存计数)
|
||||||
|
// 按大小切割要判断是否该滚动:每条日志都 Stat 一次要花约 8µs,
|
||||||
|
// 而写了多少字节自己最清楚,只在打开文件时 Stat 一次做基准即可
|
||||||
|
written int64
|
||||||
fileName string
|
fileName string
|
||||||
|
// baseName 是 fileNameIn 的结果(不带大小切割的 _N 序号),
|
||||||
|
// 用于判断是否跨了时间切割边界,以及归档时取规范基名
|
||||||
|
baseName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write 写入缓冲,满了就落盘一次
|
// Write 写入缓冲,满了就落盘一次
|
||||||
@@ -41,6 +52,29 @@ func (f *logFile) Write(b []byte) (int, error) {
|
|||||||
return written, nil
|
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 保证把整块数据写进文件(允许底层短写,循环补齐)
|
// writeFull 保证把整块数据写进文件(允许底层短写,循环补齐)
|
||||||
func (f *logFile) writeFull(p []byte) error {
|
func (f *logFile) writeFull(p []byte) error {
|
||||||
for len(p) > 0 {
|
for len(p) > 0 {
|
||||||
@@ -65,10 +99,18 @@ func (f *logFile) Flush() error {
|
|||||||
// 保留未写成功的部分,下次继续
|
// 保留未写成功的部分,下次继续
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
f.written += int64(f.pending)
|
||||||
f.pending = 0
|
f.pending = 0
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasPending 缓冲里还有没有未落盘的数据
|
||||||
|
// 定时刷盘靠它跳过空闲文件:否则每次 tick 都会对所有句柄做一次 Sync(fsync),
|
||||||
|
// 高频间隔下光 fsync 就能把吞吐拖垮
|
||||||
|
func (f *logFile) hasPending() bool {
|
||||||
|
return f != nil && f.pending > 0
|
||||||
|
}
|
||||||
|
|
||||||
// Sync 把缓冲刷到文件并 fsync
|
// Sync 把缓冲刷到文件并 fsync
|
||||||
func (f *logFile) Sync() error {
|
func (f *logFile) Sync() error {
|
||||||
var errs []error
|
var errs []error
|
||||||
@@ -104,6 +146,14 @@ func joinErrors(errs []error) error {
|
|||||||
|
|
||||||
// 获取最新的文件名
|
// 获取最新的文件名
|
||||||
func (l *Logger) nowFileName(event string) string {
|
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 := ""
|
prefix := ""
|
||||||
|
|
||||||
switch l.option.fileSplit {
|
switch l.option.fileSplit {
|
||||||
@@ -128,15 +178,20 @@ func (l *Logger) nowFileName(event string) string {
|
|||||||
prefix = prefix + "_"
|
prefix = prefix + "_"
|
||||||
}
|
}
|
||||||
|
|
||||||
if l.channel != "" {
|
if channel != "" {
|
||||||
prefix = l.channel + "/" + prefix
|
prefix = channel + "/" + prefix
|
||||||
}
|
}
|
||||||
return l.option.dir + "/" + prefix + event + ".log"
|
return l.option.dir + "/" + prefix + event + ".log"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新建文件
|
// 新建文件
|
||||||
func (l *Logger) getFile(event string) (*logFile, error) {
|
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 {
|
if f := l.loadFile(key); f != nil {
|
||||||
return f, nil
|
return f, nil
|
||||||
@@ -150,9 +205,47 @@ func (l *Logger) getFile(event string) (*logFile, error) {
|
|||||||
return f, nil
|
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 dir := filepath.Dir(fileName); dir != "" {
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -168,12 +261,62 @@ func (l *Logger) getFile(event string) (*logFile, error) {
|
|||||||
file: file,
|
file: file,
|
||||||
buf: make([]byte, fileBufSize),
|
buf: make([]byte, fileBufSize),
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
|
baseName: fileName,
|
||||||
}
|
}
|
||||||
l.filePath[key] = lf
|
lf.initSize()
|
||||||
|
|
||||||
return lf, nil
|
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 {
|
func (l *Logger) loadFile(key fileKey) *logFile {
|
||||||
l.mu.RLock()
|
l.mu.RLock()
|
||||||
@@ -187,8 +330,9 @@ func (l *Logger) loadFileLocked(key fileKey) *logFile {
|
|||||||
if !ok || f == nil {
|
if !ok || f == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建
|
// 时间切割导致文件名变化:关闭旧文件,让调用方按新名字重建。
|
||||||
if f.fileName != l.nowFileName(key.event) {
|
// 注意比的是不带序号的基名,否则按大小切割出的 _N 文件会被误判成跨天
|
||||||
|
if f.baseName != l.fileNameIn(key.channel, key.event) {
|
||||||
delete(l.filePath, key)
|
delete(l.filePath, key)
|
||||||
f.Close()
|
f.Close()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ import (
|
|||||||
"time"
|
"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) {
|
func (l *Logger) logger(ctx context.Context, event string, v ...any) {
|
||||||
// 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层
|
// 调用方可能是 log 包(Logger.Write -> logger),所以这里取第 2 层
|
||||||
pc, file, line, ok := runtime.Caller(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 {
|
if fn := runtime.FuncForPC(pc); fn != nil {
|
||||||
funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".")
|
funcName = strings.TrimPrefix(filepath.Ext(fn.Name()), ".")
|
||||||
}
|
}
|
||||||
if basePath, err := filepath.Abs("./"); err == nil {
|
file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), l.basePath())
|
||||||
basePath = strings.ReplaceAll(basePath, "\\", "/")
|
|
||||||
file = strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), basePath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000")
|
nowTime := time.Now().In(l.option.timeZone).Format("2006-01-02 15:04:05.000000")
|
||||||
|
|||||||
+106
-19
@@ -11,6 +11,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
// "sync_log/global"
|
// "sync_log/global"
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ type Logger struct {
|
|||||||
writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里
|
writeType writeType // 是否异步落盘,这里作用范围是本条,优先判断这里
|
||||||
|
|
||||||
closeOnce *sync.Once
|
closeOnce *sync.Once
|
||||||
|
closed *atomic.Bool // 是否已经关闭:关闭后再写不再新开句柄
|
||||||
done chan struct{} // 通用关闭信号(delete / ctx 相关 goroutine 用)
|
done chan struct{} // 通用关闭信号(delete / ctx 相关 goroutine 用)
|
||||||
workerDone chan struct{} // 异步消费 goroutine 退出信号
|
workerDone chan struct{} // 异步消费 goroutine 退出信号
|
||||||
closing chan struct{} // 异步投递关闭信号:只关一次
|
closing chan struct{} // 异步投递关闭信号:只关一次
|
||||||
@@ -36,6 +39,16 @@ type Logger struct {
|
|||||||
// 浅拷贝,如果状态直接放在 Logger 里,拷贝出的实例就会各看各的
|
// 浅拷贝,如果状态直接放在 Logger 里,拷贝出的实例就会各看各的
|
||||||
// (曾经因此让 Close 看不到真正的队列,白等一场、最后一条日志丢失)
|
// (曾经因此让 Close 看不到真正的队列,白等一场、最后一条日志丢失)
|
||||||
async *asyncCtl
|
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 异步队列的共享控制块:所有拷贝共享同一份
|
// asyncCtl 异步队列的共享控制块:所有拷贝共享同一份
|
||||||
@@ -43,6 +56,17 @@ type asyncCtl struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ch chan cacheData
|
ch chan cacheData
|
||||||
closed bool // 只能读写于 mu 保护下
|
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 文件缓存的键
|
// fileKey 文件缓存的键
|
||||||
@@ -65,16 +89,21 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
l := &Logger{
|
l := &Logger{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
filePath: make(map[fileKey]*logFile),
|
filePath: make(map[fileKey]*logFile),
|
||||||
mu: &sync.RWMutex{},
|
mu: &sync.RWMutex{},
|
||||||
writeMu: &sync.Mutex{},
|
writeMu: &sync.Mutex{},
|
||||||
option: opt,
|
option: opt,
|
||||||
writeType: writeTypeDefault,
|
writeType: writeTypeDefault,
|
||||||
closeOnce: &sync.Once{},
|
closeOnce: &sync.Once{},
|
||||||
done: make(chan struct{}),
|
closed: &atomic.Bool{},
|
||||||
workerDone: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
async: &asyncCtl{},
|
workerDone: make(chan struct{}),
|
||||||
|
async: &asyncCtl{},
|
||||||
|
basePathOnce: &sync.Once{},
|
||||||
|
idxOnce: &sync.Once{},
|
||||||
|
idxMu: &sync.Mutex{},
|
||||||
|
compWg: &sync.WaitGroup{},
|
||||||
}
|
}
|
||||||
|
|
||||||
log.SetOutput(l)
|
log.SetOutput(l)
|
||||||
@@ -89,6 +118,11 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
|||||||
// 日志删除
|
// 日志删除
|
||||||
go l.delete()
|
go l.delete()
|
||||||
|
|
||||||
|
// 定时刷盘
|
||||||
|
if opt.flushEvery > 0 {
|
||||||
|
go l.flushLoop()
|
||||||
|
}
|
||||||
|
|
||||||
// 强制刷盘
|
// 强制刷盘
|
||||||
// 用 done 而不是只监听 ctx:ctx 为 Background 时 Close 也要能把 goroutine 收回去
|
// 用 done 而不是只监听 ctx:ctx 为 Background 时 Close 也要能把 goroutine 收回去
|
||||||
go func() {
|
go func() {
|
||||||
@@ -102,30 +136,78 @@ func NewLogger(ctx context.Context, opts ...Option) *Logger {
|
|||||||
return l
|
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 会全部退出
|
// 重复调用安全,调用后本实例的后台 goroutine 会全部退出
|
||||||
func (l *Logger) Close() error {
|
func (l *Logger) Close() error {
|
||||||
var err error
|
var err error
|
||||||
l.closeOnce.Do(func() {
|
l.closeOnce.Do(func() {
|
||||||
// 1. 停投递 + 等消费协程把队列里已入队的任务全部写完
|
// 1. 先停投递 + 等消费协程把队列里已入队的任务全部写完。
|
||||||
|
// 这一步必须在「封盘」之前:队列里可能还有别的 channel / event 的任务,
|
||||||
|
// 它们需要新开自己的文件句柄。如果提前封盘,这些任务会直接写失败被丢掉
|
||||||
l.drainAsync()
|
l.drainAsync()
|
||||||
|
|
||||||
// 2. 在写锁内完成最后一次刷盘与关闭
|
// 2. 封盘:此后不再新开句柄,避免 Close 扫完句柄表之后又冒出没人负责关的句柄
|
||||||
// 此后不会再有写入(投递已关闭,同步写入也要先抢到这把锁)
|
l.closed.Store(true)
|
||||||
l.writeMu.Lock()
|
|
||||||
syncErr := l.MustSync()
|
|
||||||
closeErr := l.close()
|
|
||||||
l.writeMu.Unlock()
|
|
||||||
err = joinErrors([]error{syncErr, closeErr})
|
|
||||||
|
|
||||||
// 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)
|
close(l.done)
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制刷盘:只 flush 缓存数据,文件继续可写
|
// 强制刷盘:只 flush 缓存数据,文件继续可写
|
||||||
|
// 与写入并发调用是安全的(内部会先拿到写锁)
|
||||||
func (l *Logger) MustSync() error {
|
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()
|
l.mu.RLock()
|
||||||
files := make([]*logFile, 0, len(l.filePath))
|
files := make([]*logFile, 0, len(l.filePath))
|
||||||
for _, f := range l.filePath {
|
for _, f := range l.filePath {
|
||||||
@@ -135,6 +217,11 @@ func (l *Logger) MustSync() error {
|
|||||||
|
|
||||||
var errs []error
|
var errs []error
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
|
// 没有待落盘数据的文件直接跳过:省掉一次 fsync。
|
||||||
|
// Close 仍然安全:logFile.Close 自己会再 Flush 一次并关句柄
|
||||||
|
if !f.hasPending() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
errs = append(errs, f.Sync())
|
errs = append(errs, f.Sync())
|
||||||
}
|
}
|
||||||
return joinErrors(errs)
|
return joinErrors(errs)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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-<i>-" 前缀而不是 "ALL-<i>\""
|
||||||
|
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 卡死")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 归档")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 卡死(死锁)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-2
@@ -1,6 +1,7 @@
|
|||||||
package loggerx
|
package loggerx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"compress/gzip"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,7 +20,10 @@ type loggerOption struct {
|
|||||||
days int // 日志保存天数
|
days int // 日志保存天数
|
||||||
drivers []io.Writer // 文件落盘驱动器
|
drivers []io.Writer // 文件落盘驱动器
|
||||||
fileSplit FileSplit // 文件切割规则
|
fileSplit FileSplit // 文件切割规则
|
||||||
sizeSplit int // 根据文件大小切割
|
sizeSplit int // 根据文件大小切割(字节,<=0 不切割)
|
||||||
|
compress bool // 归档文件是否压缩为 .gz
|
||||||
|
compressLvl int // gzip 压缩级别
|
||||||
|
flushEvery time.Duration // 定时刷盘间隔,0 表示不开启
|
||||||
timeZone *time.Location // 时区
|
timeZone *time.Location // 时区
|
||||||
escapeHTML bool
|
escapeHTML bool
|
||||||
expandData map[string]string // 扩展字段
|
expandData map[string]string // 扩展字段
|
||||||
@@ -45,6 +49,8 @@ func defaultOptions() loggerOption {
|
|||||||
traceField: "trace_id",
|
traceField: "trace_id",
|
||||||
days: 7,
|
days: 7,
|
||||||
fileSplit: FileSplitTimeE,
|
fileSplit: FileSplitTimeE,
|
||||||
|
compress: true,
|
||||||
|
compressLvl: gzip.BestSpeed,
|
||||||
timeZone: time.Local,
|
timeZone: time.Local,
|
||||||
escapeHTML: true,
|
escapeHTML: true,
|
||||||
expandData: make(map[string]string),
|
expandData: make(map[string]string),
|
||||||
@@ -186,13 +192,43 @@ const (
|
|||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 根据文件大小切割(暂时未生效)
|
// 根据文件大小切割
|
||||||
|
// m 为单个文件的大小上限(字节);<=0 表示不按大小切割
|
||||||
|
// 超过上限时当前文件会被改名归档(并可选压缩),随后写入新文件
|
||||||
func SetSizeSplit(m int) Option {
|
func SetSizeSplit(m int) Option {
|
||||||
return func(o *loggerOption) {
|
return func(o *loggerOption) {
|
||||||
o.sizeSplit = m
|
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 {
|
func SetEscapeHTML(b bool) Option {
|
||||||
return func(o *loggerOption) {
|
return func(o *loggerOption) {
|
||||||
o.escapeHTML = b
|
o.escapeHTML = b
|
||||||
|
|||||||
@@ -1,26 +1,163 @@
|
|||||||
# 简介
|
# loggerx
|
||||||
|
|
||||||
这个是基于原生log实现的日志存储。
|
基于 Go 原生 `log` 封装的日志库:支持按时间切分文件、同步/异步落盘、channel 分目录、Gin 中间件。
|
||||||
|
|
||||||
```go
|
```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. [ ] 自动清除过期的日志文件
|
```go
|
||||||
2. [ ] 支持日志文件压缩
|
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")` 的日志落在 `<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] 支持日志文件切割
|
3. [X] 支持日志文件切割
|
||||||
4. [ ] 支持日志文件归档
|
4. [ ] 支持日志文件归档到对象存储
|
||||||
5. [ ] 支持多种文件分割类型
|
5. 支持多种文件分割类型
|
||||||
1. [ ] 按照时间分割
|
1. [X] 按照时间分割
|
||||||
2. [ ] 按照文件大小分割
|
2. [X] 按照文件大小分割(`SetSizeSplit`)
|
||||||
3. [ ] 按照日志行数分割
|
3. [ ] 按照日志行数分割
|
||||||
6. [ ] 支持debug 模式
|
6. [ ] 支持日志级别过滤(`SetFormat("text")` 尚未生效)
|
||||||
7. [X] 添加异步落库,支持全局和单次
|
7. [X] 异步落盘(按实例隔离,不再是全局队列)
|
||||||
1. [ ] 优化:异步应该跟实例不应该全局,多实例异步落库将会有BUG
|
8. [X] 支持是否转义 HTML
|
||||||
8. [X] 添加支持是否转义html
|
9. [X] 支持定时刷盘(`SetFlushInterval`)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ func (l *Logger) delete() {
|
|||||||
fmt.Println("清理日志文件异常:", r)
|
fmt.Println("清理日志文件异常:", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
// 先清掉「压缩完成但源文件没删掉」留下的冗余文件
|
||||||
|
if err := l.healCompressedLeftovers(); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
if err := l.walkAndDel(); err != nil {
|
if err := l.walkAndDel(); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
}
|
}
|
||||||
@@ -59,7 +63,8 @@ func (l *Logger) walkAndDel() error {
|
|||||||
if !isEmptyDir(path) {
|
if !isEmptyDir(path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else if filepath.Ext(path) != ".log" {
|
} else if ext := filepath.Ext(path); ext != ".log" && ext != ".gz" {
|
||||||
|
// 只清理日志与压缩归档
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-11
@@ -24,11 +24,16 @@ func (l *Logger) write(event string, b []byte) (n int, err error) {
|
|||||||
// 必须满足 io.Writer 契约:成功时 n == len(b),否则调用方(log / io.MultiWriter)
|
// 必须满足 io.Writer 契约:成功时 n == len(b),否则调用方(log / io.MultiWriter)
|
||||||
// 会认为发生了短写并把日志吞掉
|
// 会认为发生了短写并把日志吞掉
|
||||||
func (l *Logger) store(event string, b []byte) (n int, err error) {
|
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 {
|
if l.option.isPrintFile {
|
||||||
// 串行化写入:句柄缓冲区不是并发安全的,
|
// 串行化写入:句柄缓冲区不是并发安全的,
|
||||||
// 异步消费协程与同步调用可能同时写同一个句柄
|
// 异步消费协程与同步调用可能同时写同一个句柄
|
||||||
l.writeMu.Lock()
|
l.writeMu.Lock()
|
||||||
n, err = l.storeFile(event, b)
|
n, err = l.storeFileTo(channel, event, b)
|
||||||
l.writeMu.Unlock()
|
l.writeMu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -43,11 +48,25 @@ func (l *Logger) store(event string, b []byte) (n int, err error) {
|
|||||||
|
|
||||||
// storeFile 写入日志文件
|
// storeFile 写入日志文件
|
||||||
func (l *Logger) storeFile(event string, b []byte) (int, error) {
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
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)
|
n, err := f.Write(b)
|
||||||
if err == nil && n < len(b) {
|
if err == nil && n < len(b) {
|
||||||
err = io.ErrShortWrite
|
err = io.ErrShortWrite
|
||||||
@@ -58,8 +77,8 @@ func (l *Logger) storeFile(event string, b []byte) (int, error) {
|
|||||||
|
|
||||||
// 写入失败:丢弃这个句柄,落到磁盘后按最新文件名重开一次再写
|
// 写入失败:丢弃这个句柄,落到磁盘后按最新文件名重开一次再写
|
||||||
// 只重试一次,避免原实现在短写时反复重开文件
|
// 只重试一次,避免原实现在短写时反复重开文件
|
||||||
l.discardFile(event, f)
|
l.discardFileTo(channel, event, f)
|
||||||
if nf, nerr := l.getFile(event); nerr == nil {
|
if nf, nerr := l.getFileTo(channel, event); nerr == nil {
|
||||||
if n2, err2 := nf.Write(b); err2 == nil && n2 == len(b) {
|
if n2, err2 := nf.Write(b); err2 == nil && n2 == len(b) {
|
||||||
return n2, nil
|
return n2, nil
|
||||||
}
|
}
|
||||||
@@ -68,6 +87,43 @@ func (l *Logger) storeFile(event string, b []byte) (int, error) {
|
|||||||
return 0, err
|
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)
|
// 写入额外的驱动(控制台 / 自定义 writer)
|
||||||
func (l *Logger) writeDrivers(b []byte) (int, error) {
|
func (l *Logger) writeDrivers(b []byte) (int, error) {
|
||||||
if len(l.option.drivers) == 0 {
|
if len(l.option.drivers) == 0 {
|
||||||
@@ -78,20 +134,29 @@ func (l *Logger) writeDrivers(b []byte) (int, error) {
|
|||||||
|
|
||||||
// discardFile 关闭文件并从缓存中移除,使下次写入重新打开
|
// discardFile 关闭文件并从缓存中移除,使下次写入重新打开
|
||||||
func (l *Logger) discardFile(event string, f *logFile) {
|
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()
|
_ = f.Close()
|
||||||
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
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 {
|
if cur, ok := l.filePath[key]; ok && cur == f {
|
||||||
delete(l.filePath, key)
|
delete(l.filePath, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异步队列的任务
|
// 异步队列的任务
|
||||||
|
// Channel 必须跟着任务走:消费协程持有的是「创建队列时的那个 logger」,
|
||||||
|
// 它的 channel 是空的。如果消费时用 logger 自己的 channel,
|
||||||
|
// 所有 channel 的日志都会落进根目录文件(曾经就是这么错的)
|
||||||
type cacheData struct {
|
type cacheData struct {
|
||||||
Event string
|
Channel string
|
||||||
Data []byte
|
Event string
|
||||||
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// toAsync 尝试异步写入
|
// toAsync 尝试异步写入
|
||||||
@@ -105,11 +170,13 @@ func (l *Logger) toAsync(event string, b []byte) bool {
|
|||||||
// 整段「检查开关 + 入队」都在同一把锁内:Close 也拿这把锁来停止投递,
|
// 整段「检查开关 + 入队」都在同一把锁内:Close 也拿这把锁来停止投递,
|
||||||
// 这样 Close 拿到锁时就能确定「要么这条还没入队、要么已经完整入队」。
|
// 这样 Close 拿到锁时就能确定「要么这条还没入队、要么已经完整入队」。
|
||||||
// 否则消费协程可能先看到空队列就退出,把还在路上的这条日志整条丢掉。
|
// 否则消费协程可能先看到空队列就退出,把还在路上的这条日志整条丢掉。
|
||||||
|
//
|
||||||
|
// wg 用来告诉 drainAsync「投递临界区里还有人」:Close 必须等这些写入
|
||||||
|
// 真正入队之后才能关闭队列,否则向已关闭的 channel 发送会 panic。
|
||||||
// 队列满时这里会阻塞,但消费者是独立 goroutine 且不需要这把锁,不会死锁
|
// 队列满时这里会阻塞,但消费者是独立 goroutine 且不需要这把锁,不会死锁
|
||||||
l.async.mu.Lock()
|
l.async.mu.Lock()
|
||||||
defer l.async.mu.Unlock()
|
|
||||||
|
|
||||||
if l.async.closed {
|
if l.async.closed {
|
||||||
|
l.async.mu.Unlock()
|
||||||
// 已开始关闭:退化为同步写入
|
// 已开始关闭:退化为同步写入
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -117,7 +184,13 @@ func (l *Logger) toAsync(event string, b []byte) bool {
|
|||||||
l.async.ch = make(chan cacheData, asyncQueueSize)
|
l.async.ch = make(chan cacheData, asyncQueueSize)
|
||||||
go l.asyncWorker(l.async.ch)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +203,8 @@ func (l *Logger) asyncWorker(q chan cacheData) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
for val := range q {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关键:必须等「已经进入投递临界区」的写入全部入队之后才能关队列。
|
||||||
|
// 这里能看到 wg 已经是 0,就说明没有写入还卡在投递路径上,
|
||||||
|
// 否则 close(q) 之后它们再发就会 panic: send on closed channel
|
||||||
|
l.async.wg.Wait()
|
||||||
|
|
||||||
// 关闭队列并等消费者把缓冲里的任务全部处理完。
|
// 关闭队列并等消费者把缓冲里的任务全部处理完。
|
||||||
// 这一步必须真的等到 workerDone:否则 Close 会在消费协程还在写缓冲时
|
// 这一步必须真的等到 workerDone:否则 Close 会在消费协程还在写缓冲时
|
||||||
// 就刷盘并关闭文件,最后几条日志会连着句柄一起丢掉
|
// 就刷盘并关闭文件,最后几条日志会连着句柄一起丢掉
|
||||||
|
|||||||
Reference in New Issue
Block a user