309 lines
8.9 KiB
Go
309 lines
8.9 KiB
Go
// Package logger 提供统一的日志初始化功能
|
||
//
|
||
// 使用示例:
|
||
//
|
||
// logger.InitLog(logrus.DebugLevel, true)
|
||
// logger.SetProjectPrefix("D:/Projects/MyProject/")
|
||
// logrus.Debug("调试信息")
|
||
// logrus.Infof("信息: %s", "test")
|
||
package logger
|
||
|
||
import (
|
||
"archive/zip"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/lestrrat-go/file-rotatelogs"
|
||
"github.com/mattn/go-colorable"
|
||
"github.com/sirupsen/logrus"
|
||
)
|
||
|
||
var (
|
||
// 项目根目录前缀,用于简化日志中的调用文件路径
|
||
// 可以通过 SetProjectPrefix 自定义设置
|
||
projectPrefix string
|
||
projectPrefixOnce sync.Once
|
||
)
|
||
|
||
// SetProjectPrefix 设置项目路径前缀,用于简化日志中的文件路径显示
|
||
// 例如:SetProjectPrefix("D:/Projects/ZoSmartCybercafe/service-server-new/")
|
||
func SetProjectPrefix(prefix string) {
|
||
projectPrefix = prefix
|
||
}
|
||
|
||
// getProjectPrefix 获取项目路径前缀(懒加载)
|
||
func getProjectPrefix() string {
|
||
projectPrefixOnce.Do(func() {
|
||
// 默认前缀,如果没有通过 SetProjectPrefix 设置,则使用空字符串
|
||
if projectPrefix == "" {
|
||
projectPrefix = ""
|
||
}
|
||
})
|
||
return projectPrefix
|
||
}
|
||
|
||
// fileLogHook 写入按天轮转的日志文件的 Hook
|
||
type fileLogHook struct {
|
||
writer io.Writer
|
||
formatter logrus.Formatter
|
||
levels []logrus.Level // 此 Hook 生效的日志级别
|
||
}
|
||
|
||
// NewFileLogHook 创建按天轮转的日志 Hook
|
||
// 参数:
|
||
// - logDir: 日志目录
|
||
// - filename: 日志文件名模板(如 "app-%Y-%m-%d.log")
|
||
// - levels: 此 Hook 生效的日志级别,如果为空则对所有级别生效
|
||
func NewFileLogHook(logDir string, filename string, levels []logrus.Level) (*fileLogHook, error) {
|
||
// 创建日志目录
|
||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 创建按天轮转的日志文件
|
||
logPath := filepath.Join(logDir, filename)
|
||
rl, err := rotatelogs.New(
|
||
logPath,
|
||
rotatelogs.WithMaxAge(-1), // 不自动删除旧日志
|
||
rotatelogs.WithRotationTime(24*time.Hour), // 24小时轮转一次
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 文件日志使用的 formatter(不带颜色)
|
||
formatter := &logrus.TextFormatter{
|
||
TimestampFormat: "2006-01-02 15:04:05",
|
||
FullTimestamp: true,
|
||
DisableColors: true, // 文件中不使用颜色
|
||
DisableLevelTruncation: true, // 不缩略 INFO -> INF
|
||
DisableSorting: true,
|
||
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
|
||
file := f.File
|
||
prefix := getProjectPrefix()
|
||
if prefix != "" && strings.HasPrefix(file, prefix) {
|
||
file = file[len(prefix):]
|
||
}
|
||
// 返回 "文件:行号",file= 字段内不要前导空格
|
||
return "", file + ":" + strconv.Itoa(f.Line)
|
||
},
|
||
}
|
||
|
||
// 如果没有指定级别,则对所有级别生效
|
||
if len(levels) == 0 {
|
||
levels = logrus.AllLevels
|
||
}
|
||
|
||
return &fileLogHook{
|
||
writer: rl,
|
||
formatter: formatter,
|
||
levels: levels,
|
||
}, nil
|
||
}
|
||
|
||
// Fire 每次日志记录时调用,写入到文件
|
||
func (hook *fileLogHook) Fire(entry *logrus.Entry) error {
|
||
line, err := hook.formatter.Format(entry)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = hook.writer.Write(line)
|
||
return err
|
||
}
|
||
|
||
// Levels 返回此 Hook 生效的日志级别
|
||
func (hook *fileLogHook) Levels() []logrus.Level {
|
||
return hook.levels
|
||
}
|
||
|
||
// InitLog 初始化 logrus 日志配置
|
||
// 参数:
|
||
// - level: 日志级别,例如 logrus.DebugLevel, logrus.InfoLevel
|
||
// - reportCaller: 是否显示调用者信息(文件名和行号)
|
||
// - logDir: 日志文件目录,如果为空则不输出到文件
|
||
//
|
||
// 注意:初始化后,在其他文件中直接使用 logrus.Debug、logrus.Info 等函数,
|
||
// 不要通过 logger.Debug 等方式调用,否则会导致调用位置信息不准确。
|
||
//
|
||
// 日志文件:
|
||
// - app-YYYY-MM-DD.log: 所有级别的日志
|
||
// - error-YYYY-MM-DD.log: 只记录 Error 及以上级别的日志
|
||
func InitLog(level logrus.Level, reportCaller bool, logDir ...string) {
|
||
logrus.SetOutput(colorable.NewColorableStdout())
|
||
logrus.SetLevel(level)
|
||
logrus.SetReportCaller(reportCaller)
|
||
logrus.SetFormatter(&logrus.TextFormatter{
|
||
TimestampFormat: "2006-01-02 15:04:05",
|
||
FullTimestamp: true,
|
||
ForceColors: true, // 强制输出颜色,配合 colorable 在 Windows 上显示
|
||
DisableLevelTruncation: true, // 不缩略 INFO -> INF
|
||
DisableSorting: true,
|
||
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
|
||
file := f.File
|
||
prefix := getProjectPrefix()
|
||
if prefix != "" && strings.HasPrefix(file, prefix) {
|
||
file = file[len(prefix):]
|
||
}
|
||
// 返回 "文件:行号",file= 字段内不要前导空格
|
||
return "", " " + file + ":" + strconv.Itoa(f.Line)
|
||
},
|
||
})
|
||
|
||
// 如果指定了日志目录,则添加文件输出
|
||
if len(logDir) > 0 && logDir[0] != "" {
|
||
// 创建主日志 Hook(记录所有级别)
|
||
mainHook, err := NewFileLogHook(logDir[0], "app-%Y-%m-%d.log", logrus.AllLevels)
|
||
if err == nil {
|
||
logrus.AddHook(mainHook)
|
||
} else {
|
||
logrus.Warnf("创建主日志文件失败: %v", err)
|
||
}
|
||
|
||
// 创建错误日志 Hook(只记录 Error 及以上级别)
|
||
errorHook, err := NewFileLogHook(logDir[0], "error-%Y-%m-%d.log", []logrus.Level{
|
||
logrus.ErrorLevel,
|
||
logrus.FatalLevel,
|
||
logrus.PanicLevel,
|
||
})
|
||
if err == nil {
|
||
logrus.AddHook(errorHook)
|
||
} else {
|
||
logrus.Warnf("创建错误日志文件失败: %v", err)
|
||
}
|
||
|
||
// 启动后台历史日志压缩协程(每日 0:00-0:30 为不压缩窗口)
|
||
StartLogCompressor(logDir[0], time.Hour)
|
||
}
|
||
}
|
||
|
||
// 历史日志压缩相关常量
|
||
const (
|
||
// compressSkipStartHour / compressSkipEndMin 定义每日不压缩窗口:0:00 - 0:30
|
||
// 该窗口通常与日志轮转时间重合,期间不执行压缩,避免与正在写入/轮转的日志冲突
|
||
compressSkipStartHour = 0
|
||
compressSkipEndMin = 30
|
||
)
|
||
|
||
// inCompressSkipWindow 判断当前时间是否处于每日不压缩窗口(0:00 - 0:30)
|
||
func inCompressSkipWindow(now time.Time) bool {
|
||
h, m, _ := now.Clock()
|
||
return h == compressSkipStartHour && m < compressSkipEndMin
|
||
}
|
||
|
||
// StartLogCompressor 在后台启动历史日志压缩协程,按 interval 周期扫描并压缩历史日志。
|
||
// 每日 0:00-0:30 为不压缩窗口,期间直接跳过。
|
||
// 参数:
|
||
// - logDir: 日志目录
|
||
// - interval: 扫描周期,<=0 时默认 1 小时
|
||
func StartLogCompressor(logDir string, interval time.Duration) {
|
||
if logDir == "" {
|
||
return
|
||
}
|
||
if interval <= 0 {
|
||
interval = time.Hour
|
||
}
|
||
go func() {
|
||
// 启动时先执行一次,立即清理已有历史日志
|
||
_ = CompressHistoryLogs(logDir)
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
_ = CompressHistoryLogs(logDir)
|
||
}
|
||
}()
|
||
}
|
||
|
||
// CompressHistoryLogs 将历史日志(非当天、且尚未压缩的 .log 文件)逐个压缩为同名的 .zip 文件,
|
||
// 压缩成功后删除原始日志文件以完成清理。每日 0:00-0:30 为不压缩窗口,期间直接跳过。
|
||
// 参数 logDir 为日志目录。
|
||
func CompressHistoryLogs(logDir string) error {
|
||
// 不压缩窗口内直接跳过
|
||
if inCompressSkipWindow(time.Now()) {
|
||
return nil
|
||
}
|
||
|
||
entries, err := os.ReadDir(logDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 当天的日期串,用于跳过正在写入的当天日志
|
||
today := time.Now().Format("2006-01-02")
|
||
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
continue
|
||
}
|
||
name := entry.Name()
|
||
|
||
// 仅处理 .log 文件
|
||
if !strings.HasSuffix(name, ".log") {
|
||
continue
|
||
}
|
||
// 跳过当天的日志(正在写入,不压缩)
|
||
if strings.Contains(name, today) {
|
||
continue
|
||
}
|
||
|
||
logPath := filepath.Join(logDir, name)
|
||
// 文件名不变,仅追加 .zip 后缀(如 app-2026-07-16.log -> app-2026-07-16.log.zip)
|
||
zipPath := logPath + ".zip"
|
||
|
||
// 若已存在同名 zip,说明此前已压缩成功,直接清理原日志避免重复压缩
|
||
if _, err := os.Stat(zipPath); err == nil {
|
||
if rmErr := os.Remove(logPath); rmErr != nil {
|
||
logrus.Warnf("清理已压缩的历史日志失败 %s: %v", name, rmErr)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if err := zipSingleFile(logPath, zipPath); err != nil {
|
||
logrus.Warnf("压缩历史日志失败 %s: %v", name, err)
|
||
continue
|
||
}
|
||
|
||
// 压缩成功后清理原始日志文件
|
||
if err := os.Remove(logPath); err != nil {
|
||
logrus.Warnf("删除历史日志失败 %s: %v", name, err)
|
||
} else {
|
||
logrus.Infof("历史日志已压缩并清理: %s -> %s", name, filepath.Base(zipPath))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// zipSingleFile 将单个文件 src 压缩写入 dst(zip 包内仅包含原始文件名,文件名保持不变)
|
||
func zipSingleFile(src, dst string) error {
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer out.Close()
|
||
|
||
zw := zip.NewWriter(out)
|
||
w, err := zw.Create(filepath.Base(src))
|
||
if err != nil {
|
||
_ = zw.Close()
|
||
return err
|
||
}
|
||
|
||
if _, err := io.Copy(w, in); err != nil {
|
||
_ = zw.Close()
|
||
return err
|
||
}
|
||
|
||
return zw.Close()
|
||
}
|