Files

309 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}