1. 重写logs
2. logs增加单测 3. task更换新的log实例 4. 配置文件增加日志相关配置
This commit is contained in:
		| @@ -1,42 +1,165 @@ | ||||
| // Package logs 提供统一的日志记录功能 | ||||
| // 支持不同级别的日志记录和格式化输出 | ||||
| // Package logs 提供了高度可配置的日志功能,基于 uber-go/zap 实现。 | ||||
| // 它支持将日志同时输出到控制台和文件,并提供日志滚动归档功能。 | ||||
| // 该包还特别为 Gin 和 GORM 框架提供了开箱即用的日志接管能力。 | ||||
| package logs | ||||
|  | ||||
| import ( | ||||
| 	"log" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/config" | ||||
| 	"go.uber.org/zap" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| 	"gopkg.in/natefinch/lumberjack.v2" | ||||
| 	gormlogger "gorm.io/gorm/logger" | ||||
| ) | ||||
|  | ||||
| // Logger 代表日志记录器结构 | ||||
| // Logger 是一个封装了 zap.SugaredLogger 的日志记录器。 | ||||
| // 它提供了结构化日志记录的各种方法,并实现了 io.Writer 接口以兼容 Gin。 | ||||
| type Logger struct { | ||||
| 	// logger 内部日志记录器 | ||||
| 	logger *log.Logger | ||||
| 	*zap.SugaredLogger | ||||
| } | ||||
|  | ||||
| // NewLogger 创建并返回一个新的日志记录器实例 | ||||
| func NewLogger() *Logger { | ||||
| 	return &Logger{ | ||||
| 		logger: log.New(os.Stdout, "", 0), | ||||
| // NewLogger 根据提供的配置创建一个新的 Logger 实例。 | ||||
| // 这是实现依赖注入的关键,在应用启动时调用一次。 | ||||
| func NewLogger(cfg config.LogConfig) *Logger { | ||||
| 	// 1. 设置日志编码器 | ||||
| 	encoder := getEncoder(cfg.Format) | ||||
|  | ||||
| 	// 2. 设置日志写入器 (支持文件和控制台) | ||||
| 	writeSyncer := getWriteSyncer(cfg) | ||||
|  | ||||
| 	// 3. 设置日志级别 | ||||
| 	level := zap.NewAtomicLevel() | ||||
| 	if err := level.UnmarshalText([]byte(cfg.Level)); err != nil { | ||||
| 		level.SetLevel(zap.InfoLevel) // 解析失败则默认为 Info 级别 | ||||
| 	} | ||||
|  | ||||
| 	// 4. 创建 Zap 核心 | ||||
| 	core := zapcore.NewCore(encoder, writeSyncer, level) | ||||
|  | ||||
| 	// 5. 构建 Logger | ||||
| 	// zap.AddCaller() 会记录调用日志的代码行 | ||||
| 	// zap.AddCallerSkip(1) 可以向上跳一层调用栈,如果我们将 logger.Info 等方法再封装一层,这个选项会很有用 | ||||
| 	zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) | ||||
|  | ||||
| 	return &Logger{zapLogger.Sugar()} | ||||
| } | ||||
|  | ||||
| // getEncoder 根据指定的格式返回一个 zapcore.Encoder。 | ||||
| func getEncoder(format string) zapcore.Encoder { | ||||
| 	encoderConfig := zap.NewProductionEncoderConfig() | ||||
| 	encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder   // 时间格式: 2006-01-02T15:04:05.000Z0700 | ||||
| 	encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder // 日志级别大写: INFO | ||||
|  | ||||
| 	if format == "json" { | ||||
| 		return zapcore.NewJSONEncoder(encoderConfig) | ||||
| 	} | ||||
| 	// 默认或 "console" | ||||
| 	return zapcore.NewConsoleEncoder(encoderConfig) | ||||
| } | ||||
|  | ||||
| // getWriteSyncer 根据配置创建日志写入目标。 | ||||
| func getWriteSyncer(cfg config.LogConfig) zapcore.WriteSyncer { | ||||
| 	writers := []zapcore.WriteSyncer{os.Stdout} | ||||
|  | ||||
| 	if cfg.EnableFile { | ||||
| 		// 使用 lumberjack 实现日志滚动 | ||||
| 		fileWriter := &lumberjack.Logger{ | ||||
| 			Filename:   cfg.FilePath, | ||||
| 			MaxSize:    cfg.MaxSize, | ||||
| 			MaxBackups: cfg.MaxBackups, | ||||
| 			MaxAge:     cfg.MaxAge, | ||||
| 			Compress:   cfg.Compress, | ||||
| 		} | ||||
| 		writers = append(writers, zapcore.AddSync(fileWriter)) | ||||
| 	} | ||||
|  | ||||
| 	return zapcore.NewMultiWriteSyncer(writers...) | ||||
| } | ||||
|  | ||||
| // Write 实现了 io.Writer 接口,用于接管 Gin 的默认输出。 | ||||
| // Gin 的日志(如 [GIN-debug] Listening and serving HTTP on :8080)会通过这个方法写入。 | ||||
| func (l *Logger) Write(p []byte) (n int, err error) { | ||||
| 	msg := strings.TrimSpace(string(p)) | ||||
| 	if msg != "" { | ||||
| 		l.Info(msg) // 使用我们自己的 logger 来打印 Gin 的日志 | ||||
| 	} | ||||
| 	return len(p), nil | ||||
| } | ||||
|  | ||||
| // --- GORM 日志适配器 --- | ||||
|  | ||||
| // GormLogger 是一个实现了 gormlogger.Interface 的适配器, | ||||
| // 它将 GORM 的日志重定向到我们的 zap Logger 中。 | ||||
| type GormLogger struct { | ||||
| 	ZapLogger             *Logger | ||||
| 	SlowThreshold         time.Duration | ||||
| 	SkipErrRecordNotFound bool // 是否跳过 "record not found" 错误 | ||||
| } | ||||
|  | ||||
| // NewGormLogger 创建一个新的 GORM 日志记录器实例。 | ||||
| func NewGormLogger(zapLogger *Logger) *GormLogger { | ||||
| 	return &GormLogger{ | ||||
| 		ZapLogger:             zapLogger, | ||||
| 		SlowThreshold:         200 * time.Millisecond, // 慢查询阈值,超过200ms则警告 | ||||
| 		SkipErrRecordNotFound: true,                   // 通常我们不关心 "record not found" 错误 | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Info 记录信息级别日志 | ||||
| func (l *Logger) Info(message string) { | ||||
| 	l.logger.Printf("[信息] %s %s", time.Now().Format(time.RFC3339), message) | ||||
| // LogMode 设置日志模式,这里我们总是使用 zap 的级别控制,所以这个方法可以为空。 | ||||
| func (g *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { | ||||
| 	// GORM 的 LogLevel 在这里不起作用,因为我们完全由 Zap 控制 | ||||
| 	return g | ||||
| } | ||||
|  | ||||
| // Error 记录错误级别日志 | ||||
| func (l *Logger) Error(message string) { | ||||
| 	l.logger.Printf("[错误] %s %s", time.Now().Format(time.RFC3339), message) | ||||
| // Info 打印 Info 级别的日志。 | ||||
| func (g *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) { | ||||
| 	g.ZapLogger.Infof(msg, data...) | ||||
| } | ||||
|  | ||||
| // Debug 记录调试级别日志 | ||||
| func (l *Logger) Debug(message string) { | ||||
| 	l.logger.Printf("[调试] %s %s", time.Now().Format(time.RFC3339), message) | ||||
| // Warn 打印 Warn 级别的日志。 | ||||
| func (g *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { | ||||
| 	g.ZapLogger.Warnf(msg, data...) | ||||
| } | ||||
|  | ||||
| // Warn 记录警告级别日志 | ||||
| func (l *Logger) Warn(message string) { | ||||
| 	l.logger.Printf("[警告] %s %s", time.Now().Format(time.RFC3339), message) | ||||
| // Error 打印 Error 级别的日志。 | ||||
| func (g *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) { | ||||
| 	g.ZapLogger.Errorf(msg, data...) | ||||
| } | ||||
|  | ||||
| // Trace 打印 SQL 查询日志,这是 GORM 日志的核心。 | ||||
| func (g *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { | ||||
| 	elapsed := time.Since(begin) | ||||
| 	sql, rows := fc() | ||||
|  | ||||
| 	fields := []interface{}{ | ||||
| 		"sql", sql, | ||||
| 		"rows", rows, | ||||
| 		"elapsed", fmt.Sprintf("%.3fms", float64(elapsed.Nanoseconds())/1e6), | ||||
| 	} | ||||
|  | ||||
| 	// --- 逻辑修复开始 --- | ||||
| 	if err != nil { | ||||
| 		// 如果是 "record not found" 错误且我们配置了跳过,则直接返回 | ||||
| 		if g.SkipErrRecordNotFound && strings.Contains(err.Error(), "record not found") { | ||||
| 			return | ||||
| 		} | ||||
| 		// 否则,记录为错误日志 | ||||
| 		g.ZapLogger.With(fields...).Errorf("[GORM] error: %s", err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 如果查询时间超过慢查询阈值,则记录警告 | ||||
| 	if g.SlowThreshold != 0 && elapsed > g.SlowThreshold { | ||||
| 		g.ZapLogger.With(fields...).Warnf("[GORM] slow query") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 正常情况,记录 Debug 级别的 SQL 查询 | ||||
| 	g.ZapLogger.With(fields...).Debugf("[GORM] trace") | ||||
| 	// --- 逻辑修复结束 --- | ||||
| } | ||||
|   | ||||
							
								
								
									
										128
									
								
								internal/logs/logs_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								internal/logs/logs_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | ||||
| package logs_test | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/config" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/logs" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"go.uber.org/zap" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| ) | ||||
|  | ||||
| // captureOutput 是一个辅助函数,用于捕获 logger 的输出到内存缓冲区 | ||||
| func captureOutput(cfg config.LogConfig) (*logs.Logger, *bytes.Buffer) { | ||||
| 	var buf bytes.Buffer | ||||
| 	// 使用一个简单的 Console Encoder 进行测试,方便断言字符串 | ||||
| 	encoderConfig := zap.NewDevelopmentEncoderConfig() | ||||
| 	encoderConfig.EncodeTime = nil // 忽略时间,避免测试结果不一致 | ||||
| 	encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder | ||||
| 	encoderConfig.EncodeCaller = nil // 忽略调用者信息 | ||||
| 	encoder := zapcore.NewConsoleEncoder(encoderConfig) | ||||
|  | ||||
| 	writer := zapcore.AddSync(&buf) | ||||
|  | ||||
| 	level := zap.NewAtomicLevel() | ||||
| 	_ = level.UnmarshalText([]byte(cfg.Level)) | ||||
|  | ||||
| 	core := zapcore.NewCore(encoder, writer, level) | ||||
| 	// 在测试中我们直接操作 zap Logger,而不是通过封装的 NewLogger,以注入内存 writer | ||||
| 	zapLogger := zap.New(core) | ||||
|  | ||||
| 	logger := &logs.Logger{SugaredLogger: zapLogger.Sugar()} | ||||
| 	return logger, &buf | ||||
| } | ||||
|  | ||||
| func TestNewLogger(t *testing.T) { | ||||
| 	t.Run("Constructor does not panic", func(t *testing.T) { | ||||
| 		// 测试 Console 格式 | ||||
| 		cfgConsole := config.LogConfig{Level: "info", Format: "console"} | ||||
| 		assert.NotPanics(t, func() { logs.NewLogger(cfgConsole) }) | ||||
|  | ||||
| 		// 测试 JSON 格式 | ||||
| 		cfgJSON := config.LogConfig{Level: "info", Format: "json"} | ||||
| 		assert.NotPanics(t, func() { logs.NewLogger(cfgJSON) }) | ||||
|  | ||||
| 		// 测试文件日志启用 | ||||
| 		// 不实际写入文件,只确保构造函数能正常运行 | ||||
| 		cfgFile := config.LogConfig{ | ||||
| 			Level:      "info", | ||||
| 			EnableFile: true, | ||||
| 			FilePath:   "test.log", | ||||
| 		} | ||||
| 		assert.NotPanics(t, func() { logs.NewLogger(cfgFile) }) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestLogger_Write_ForGin(t *testing.T) { | ||||
| 	logger, buf := captureOutput(config.LogConfig{Level: "info"}) | ||||
|  | ||||
| 	ginLog := "[GIN-debug] Listening and serving HTTP on :8080\n" | ||||
| 	_, err := logger.Write([]byte(ginLog)) | ||||
|  | ||||
| 	assert.NoError(t, err) | ||||
| 	output := buf.String() | ||||
| 	// logger.Write 会将 gin 的日志转为 info 级别 | ||||
| 	assert.Contains(t, output, "INFO") | ||||
| 	assert.Contains(t, output, strings.TrimSpace(ginLog)) | ||||
| } | ||||
|  | ||||
| func TestGormLogger(t *testing.T) { | ||||
| 	logger, buf := captureOutput(config.LogConfig{Level: "debug"}) // 设置为 debug 以捕获所有级别 | ||||
| 	gormLogger := logs.NewGormLogger(logger) | ||||
|  | ||||
| 	// 模拟 GORM 的 Trace 调用参数 | ||||
| 	ctx := context.Background() | ||||
| 	sql := "SELECT * FROM users WHERE id = 1" | ||||
| 	rows := int64(1) | ||||
| 	fc := func() (string, int64) { | ||||
| 		return sql, rows | ||||
| 	} | ||||
|  | ||||
| 	t.Run("Slow Query", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		// 模拟一个耗时超过 200ms 的查询 | ||||
| 		begin := time.Now().Add(-300 * time.Millisecond) | ||||
| 		gormLogger.Trace(ctx, begin, fc, nil) | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.Contains(t, output, "WARN", "应包含 WARN 级别") | ||||
| 		assert.Contains(t, output, "[GORM] slow query", "应包含慢查询信息") | ||||
| 		// 修复:不再检查严格的 JSON 格式,只检查关键内容 | ||||
| 		assert.Contains(t, output, "SELECT * FROM users WHERE id = 1", "应包含 SQL 语句") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("Error Query", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		queryError := errors.New("syntax error") | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, queryError) | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.Contains(t, output, "ERROR") | ||||
| 		assert.Contains(t, output, "[GORM] error: syntax error") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("Record Not Found Error is Skipped", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		queryError := errors.New("record not found") // 模拟 GORM 的 RecordNotFound 错误 | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, queryError) | ||||
|  | ||||
| 		// 在修复 logs.go 中的 bug 后,这里应该为空 | ||||
| 		assert.Empty(t, buf.String(), "开启 SkipErrRecordNotFound 后,record not found 错误不应产生任何日志") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("Normal Query", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		// 模拟一个快速查询 | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, nil) | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.Contains(t, output, "DEBUG") // 正常查询是 Debug 级别 | ||||
| 		assert.Contains(t, output, "[GORM] trace") | ||||
| 	}) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user