添加自动文件压缩的实现
This commit is contained in:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user