refactor: 后端日志系统重构
- 新增模块化日志器(pkg/logger/module.go) - 新增 GORM 日志适配器 - 统一日志入口,移除所有 zap.L() 全局 logger 调用 - 字段标准化 - 启动阶段使用结构化日志 - 更新所有相关测试
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
const requestIDKey = "request_id"
|
||||
|
||||
// WithRequestID 向 logger 添加 request_id 字段
|
||||
func WithRequestID(logger *zap.Logger, requestID string) *zap.Logger {
|
||||
return logger.With(zap.String("request_id", requestID))
|
||||
return logger.With(zap.String(requestIDKey, requestID))
|
||||
}
|
||||
|
||||
// WithContext 向 logger 添加多个自定义字段
|
||||
func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger {
|
||||
zapFields := make([]zap.Field, 0, len(fields))
|
||||
for k, v := range fields {
|
||||
@@ -15,3 +22,37 @@ func WithContext(logger *zap.Logger, fields map[string]interface{}) *zap.Logger
|
||||
}
|
||||
return logger.With(zapFields...)
|
||||
}
|
||||
|
||||
func RequestIDFromGinContext(c *gin.Context) zap.Field {
|
||||
requestID, exists := c.Get("request_id")
|
||||
if !exists {
|
||||
return zap.Skip()
|
||||
}
|
||||
if id, ok := requestID.(string); ok {
|
||||
return RequestID(id)
|
||||
}
|
||||
return zap.Skip()
|
||||
}
|
||||
|
||||
func RequestIDFromContext(ctx context.Context) zap.Field {
|
||||
requestID := ctx.Value(ctxKey{})
|
||||
if requestID == nil {
|
||||
return zap.Skip()
|
||||
}
|
||||
if id, ok := requestID.(string); ok {
|
||||
return RequestID(id)
|
||||
}
|
||||
return zap.Skip()
|
||||
}
|
||||
|
||||
func ContextWithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, requestID)
|
||||
}
|
||||
|
||||
func LoggerFromContext(ctx context.Context, baseLogger *zap.Logger) *zap.Logger {
|
||||
field := RequestIDFromContext(ctx)
|
||||
if field == zap.Skip() {
|
||||
return baseLogger
|
||||
}
|
||||
return baseLogger.With(field)
|
||||
}
|
||||
|
||||
77
backend/pkg/logger/field.go
Normal file
77
backend/pkg/logger/field.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
const (
|
||||
FieldRequestID = "request_id"
|
||||
FieldProviderID = "provider_id"
|
||||
FieldModelName = "model_name"
|
||||
FieldMethod = "method"
|
||||
FieldPath = "path"
|
||||
FieldStatusCode = "status"
|
||||
FieldLatency = "latency"
|
||||
FieldClientIP = "client_ip"
|
||||
FieldQuery = "query"
|
||||
FieldBodySize = "body_size"
|
||||
FieldSQL = "sql"
|
||||
FieldRows = "rows_affected"
|
||||
)
|
||||
|
||||
func RequestID(id string) zap.Field {
|
||||
return zap.String(FieldRequestID, id)
|
||||
}
|
||||
|
||||
func ProviderID(id string) zap.Field {
|
||||
return zap.String(FieldProviderID, id)
|
||||
}
|
||||
|
||||
func ModelName(name string) zap.Field {
|
||||
return zap.String(FieldModelName, name)
|
||||
}
|
||||
|
||||
func Method(method string) zap.Field {
|
||||
return zap.String(FieldMethod, method)
|
||||
}
|
||||
|
||||
func Path(path string) zap.Field {
|
||||
return zap.String(FieldPath, path)
|
||||
}
|
||||
|
||||
func StatusCode(code int) zap.Field {
|
||||
return zap.Int(FieldStatusCode, code)
|
||||
}
|
||||
|
||||
func Latency(latency interface{}) zap.Field {
|
||||
switch v := latency.(type) {
|
||||
case int64:
|
||||
return zap.Int64(FieldLatency, v)
|
||||
case int:
|
||||
return zap.Int(FieldLatency, v)
|
||||
default:
|
||||
return zap.Any(FieldLatency, latency)
|
||||
}
|
||||
}
|
||||
|
||||
func ClientIP(ip string) zap.Field {
|
||||
return zap.String(FieldClientIP, ip)
|
||||
}
|
||||
|
||||
func Query(query string) zap.Field {
|
||||
return zap.String(FieldQuery, query)
|
||||
}
|
||||
|
||||
func BodySize(size int) zap.Field {
|
||||
return zap.Int(FieldBodySize, size)
|
||||
}
|
||||
|
||||
func SQL(sql string) zap.Field {
|
||||
return zap.String(FieldSQL, sql)
|
||||
}
|
||||
|
||||
func Rows(rows int64) zap.Field {
|
||||
return zap.Int64(FieldRows, rows)
|
||||
}
|
||||
|
||||
func Err(err error) zap.Field {
|
||||
return zap.Error(err)
|
||||
}
|
||||
99
backend/pkg/logger/field_test.go
Normal file
99
backend/pkg/logger/field_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFieldConstants(t *testing.T) {
|
||||
assert.Equal(t, "request_id", FieldRequestID)
|
||||
assert.Equal(t, "provider_id", FieldProviderID)
|
||||
assert.Equal(t, "model_name", FieldModelName)
|
||||
assert.Equal(t, "method", FieldMethod)
|
||||
assert.Equal(t, "path", FieldPath)
|
||||
assert.Equal(t, "status", FieldStatusCode)
|
||||
assert.Equal(t, "latency", FieldLatency)
|
||||
assert.Equal(t, "client_ip", FieldClientIP)
|
||||
assert.Equal(t, "query", FieldQuery)
|
||||
assert.Equal(t, "body_size", FieldBodySize)
|
||||
assert.Equal(t, "sql", FieldSQL)
|
||||
assert.Equal(t, "rows_affected", FieldRows)
|
||||
}
|
||||
|
||||
func TestFieldConstructors(t *testing.T) {
|
||||
t.Run("RequestID", func(t *testing.T) {
|
||||
field := RequestID("test-id")
|
||||
assert.Equal(t, FieldRequestID, field.Key)
|
||||
assert.Equal(t, "test-id", field.String)
|
||||
})
|
||||
|
||||
t.Run("ProviderID", func(t *testing.T) {
|
||||
field := ProviderID("provider-123")
|
||||
assert.Equal(t, FieldProviderID, field.Key)
|
||||
assert.Equal(t, "provider-123", field.String)
|
||||
})
|
||||
|
||||
t.Run("ModelName", func(t *testing.T) {
|
||||
field := ModelName("gpt-4")
|
||||
assert.Equal(t, FieldModelName, field.Key)
|
||||
assert.Equal(t, "gpt-4", field.String)
|
||||
})
|
||||
|
||||
t.Run("Method", func(t *testing.T) {
|
||||
field := Method("POST")
|
||||
assert.Equal(t, FieldMethod, field.Key)
|
||||
assert.Equal(t, "POST", field.String)
|
||||
})
|
||||
|
||||
t.Run("Path", func(t *testing.T) {
|
||||
field := Path("/v1/chat")
|
||||
assert.Equal(t, FieldPath, field.Key)
|
||||
assert.Equal(t, "/v1/chat", field.String)
|
||||
})
|
||||
|
||||
t.Run("StatusCode", func(t *testing.T) {
|
||||
field := StatusCode(200)
|
||||
assert.Equal(t, FieldStatusCode, field.Key)
|
||||
})
|
||||
|
||||
t.Run("Latency", func(t *testing.T) {
|
||||
field := Latency(int64(100))
|
||||
assert.Equal(t, FieldLatency, field.Key)
|
||||
})
|
||||
|
||||
t.Run("ClientIP", func(t *testing.T) {
|
||||
field := ClientIP("127.0.0.1")
|
||||
assert.Equal(t, FieldClientIP, field.Key)
|
||||
assert.Equal(t, "127.0.0.1", field.String)
|
||||
})
|
||||
|
||||
t.Run("Query", func(t *testing.T) {
|
||||
field := Query("key=value")
|
||||
assert.Equal(t, FieldQuery, field.Key)
|
||||
assert.Equal(t, "key=value", field.String)
|
||||
})
|
||||
|
||||
t.Run("BodySize", func(t *testing.T) {
|
||||
field := BodySize(1024)
|
||||
assert.Equal(t, FieldBodySize, field.Key)
|
||||
})
|
||||
|
||||
t.Run("SQL", func(t *testing.T) {
|
||||
field := SQL("SELECT * FROM users")
|
||||
assert.Equal(t, FieldSQL, field.Key)
|
||||
assert.Equal(t, "SELECT * FROM users", field.String)
|
||||
})
|
||||
|
||||
t.Run("Rows", func(t *testing.T) {
|
||||
field := Rows(42)
|
||||
assert.Equal(t, FieldRows, field.Key)
|
||||
assert.Equal(t, int64(42), field.Integer)
|
||||
})
|
||||
|
||||
t.Run("Err", func(t *testing.T) {
|
||||
err := assert.AnError
|
||||
field := Err(err)
|
||||
assert.Equal(t, "error", field.Key)
|
||||
})
|
||||
}
|
||||
130
backend/pkg/logger/gorm.go
Normal file
130
backend/pkg/logger/gorm.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type GormLogger struct {
|
||||
logger *zap.Logger
|
||||
level zapcore.Level
|
||||
}
|
||||
|
||||
func NewGormLogger(logger *zap.Logger) *GormLogger {
|
||||
return &GormLogger{
|
||||
logger: logger.Named("database"),
|
||||
level: zapcore.DebugLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
|
||||
newLogger := &GormLogger{
|
||||
logger: l.logger,
|
||||
level: l.gormLevelToZap(level),
|
||||
}
|
||||
return newLogger
|
||||
}
|
||||
|
||||
func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.level > zapcore.DebugLevel {
|
||||
return
|
||||
}
|
||||
l.log(ctx, zapcore.DebugLevel, msg, data...)
|
||||
}
|
||||
|
||||
func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.level > zapcore.WarnLevel {
|
||||
return
|
||||
}
|
||||
l.log(ctx, zapcore.WarnLevel, msg, data...)
|
||||
}
|
||||
|
||||
func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.level > zapcore.ErrorLevel {
|
||||
return
|
||||
}
|
||||
l.log(ctx, zapcore.ErrorLevel, msg, data...)
|
||||
}
|
||||
|
||||
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.level > zapcore.DebugLevel {
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(begin)
|
||||
sql, rows := fc()
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("sql", l.formatSQL(sql)),
|
||||
zap.Int64("rows", rows),
|
||||
zap.Duration("latency", elapsed),
|
||||
}
|
||||
|
||||
if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() {
|
||||
fields = append([]zap.Field{requestIDField}, fields...)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fields = append(fields, zap.Error(err))
|
||||
l.logger.Error("SQL执行错误", fields...)
|
||||
return
|
||||
}
|
||||
|
||||
l.logger.Debug("SQL查询", fields...)
|
||||
}
|
||||
|
||||
func (l *GormLogger) log(ctx context.Context, level zapcore.Level, msg string, data ...interface{}) {
|
||||
fields := make([]zap.Field, 0, len(data)/2+1)
|
||||
|
||||
if requestIDField := RequestIDFromContext(ctx); requestIDField != zap.Skip() {
|
||||
fields = append(fields, requestIDField)
|
||||
}
|
||||
|
||||
for i := 0; i < len(data); i += 2 {
|
||||
if i+1 < len(data) {
|
||||
key, ok := data[i].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields = append(fields, zap.Any(key, data[i+1]))
|
||||
}
|
||||
}
|
||||
|
||||
switch level {
|
||||
case zapcore.DebugLevel:
|
||||
l.logger.Debug(fmt.Sprintf(msg, data...), fields...)
|
||||
case zapcore.WarnLevel:
|
||||
l.logger.Warn(fmt.Sprintf(msg, data...), fields...)
|
||||
case zapcore.ErrorLevel:
|
||||
l.logger.Error(fmt.Sprintf(msg, data...), fields...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) gormLevelToZap(level gormlogger.LogLevel) zapcore.Level {
|
||||
switch level {
|
||||
case gormlogger.Silent:
|
||||
return zapcore.PanicLevel
|
||||
case gormlogger.Error:
|
||||
return zapcore.ErrorLevel
|
||||
case gormlogger.Warn:
|
||||
return zapcore.WarnLevel
|
||||
case gormlogger.Info:
|
||||
return zapcore.DebugLevel
|
||||
default:
|
||||
return zapcore.DebugLevel
|
||||
}
|
||||
}
|
||||
|
||||
func (l *GormLogger) formatSQL(sql string) string {
|
||||
re := regexp.MustCompile(`\s+`)
|
||||
return strings.TrimSpace(re.ReplaceAllString(sql, " "))
|
||||
}
|
||||
131
backend/pkg/logger/gorm_test.go
Normal file
131
backend/pkg/logger/gorm_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestNewGormLogger(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
assert.NotNil(t, gormLogger)
|
||||
assert.NotNil(t, gormLogger.logger)
|
||||
assert.Equal(t, zap.DebugLevel, gormLogger.level)
|
||||
}
|
||||
|
||||
func TestGormLogger_LogMode(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
level gormlogger.LogLevel
|
||||
expectedLevel zapcore.Level
|
||||
}{
|
||||
{"Silent", gormlogger.Silent, zapcore.PanicLevel},
|
||||
{"Error", gormlogger.Error, zapcore.ErrorLevel},
|
||||
{"Warn", gormlogger.Warn, zapcore.WarnLevel},
|
||||
{"Info", gormlogger.Info, zapcore.DebugLevel},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
newLogger := gormLogger.LogMode(tt.level)
|
||||
assert.NotNil(t, newLogger)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGormLogger_Trace(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
begin := time.Now()
|
||||
|
||||
fc := func() (string, int64) {
|
||||
return "SELECT * FROM users", 10
|
||||
}
|
||||
|
||||
gormLogger.Trace(ctx, begin, fc, nil)
|
||||
}
|
||||
|
||||
func TestGormLogger_TraceWithError(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
begin := time.Now()
|
||||
|
||||
fc := func() (string, int64) {
|
||||
return "SELECT * FROM users", 0
|
||||
}
|
||||
|
||||
gormLogger.Trace(ctx, begin, fc, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
func TestGormLogger_Info(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
gormLogger.Info(ctx, "test info message")
|
||||
}
|
||||
|
||||
func TestGormLogger_Warn(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
gormLogger.Warn(ctx, "test warn message")
|
||||
}
|
||||
|
||||
func TestGormLogger_Error(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
ctx := context.Background()
|
||||
gormLogger.Error(ctx, "test error message")
|
||||
}
|
||||
|
||||
func TestGormLogger_FormatSQL(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
gormLogger := NewGormLogger(logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple query",
|
||||
input: "SELECT * FROM users",
|
||||
expected: "SELECT * FROM users",
|
||||
},
|
||||
{
|
||||
name: "query with extra spaces",
|
||||
input: "SELECT * FROM users",
|
||||
expected: "SELECT * FROM users",
|
||||
},
|
||||
{
|
||||
name: "query with newlines",
|
||||
input: "SELECT\n*\nFROM\nusers",
|
||||
expected: "SELECT * FROM users",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := gormLogger.formatSQL(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func New(cfg Config) (*zap.Logger, error) {
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeDuration: zapcore.StringDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
EncodeName: encodeLoggerName,
|
||||
})
|
||||
|
||||
stdoutCore := zapcore.NewCore(
|
||||
@@ -115,3 +116,10 @@ func logFileName() string {
|
||||
func logFilePath(dir string) string {
|
||||
return filepath.Join(dir, logFileName())
|
||||
}
|
||||
|
||||
// encodeLoggerName 自定义 logger 名称编码器,输出 [name] 格式
|
||||
func encodeLoggerName(name string, enc zapcore.PrimitiveArrayEncoder) {
|
||||
if name != "" {
|
||||
enc.AppendString("[" + name + "]")
|
||||
}
|
||||
}
|
||||
|
||||
35
backend/pkg/logger/minimal.go
Normal file
35
backend/pkg/logger/minimal.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func NewMinimal() *zap.Logger {
|
||||
encoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{
|
||||
TimeKey: "ts",
|
||||
LevelKey: "level",
|
||||
NameKey: "logger",
|
||||
CallerKey: "caller",
|
||||
FunctionKey: zapcore.OmitKey,
|
||||
MessageKey: "msg",
|
||||
StacktraceKey: "stacktrace",
|
||||
LineEnding: zapcore.DefaultLineEnding,
|
||||
EncodeLevel: zapcore.CapitalColorLevelEncoder,
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeDuration: zapcore.StringDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
})
|
||||
|
||||
core := zapcore.NewCore(
|
||||
encoder,
|
||||
zapcore.AddSync(stdoutWriter{}),
|
||||
zapcore.DebugLevel,
|
||||
)
|
||||
|
||||
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
|
||||
}
|
||||
|
||||
func Upgrade(minimalLogger *zap.Logger, cfg Config) (*zap.Logger, error) {
|
||||
return New(cfg)
|
||||
}
|
||||
7
backend/pkg/logger/module.go
Normal file
7
backend/pkg/logger/module.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
func WithModule(logger *zap.Logger, moduleName string) *zap.Logger {
|
||||
return logger.Named(moduleName)
|
||||
}
|
||||
25
backend/pkg/logger/module_test.go
Normal file
25
backend/pkg/logger/module_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWithModule(t *testing.T) {
|
||||
baseLogger := zap.NewNop()
|
||||
moduleLogger := WithModule(baseLogger, "handler.proxy")
|
||||
|
||||
assert.NotNil(t, moduleLogger)
|
||||
}
|
||||
|
||||
func TestWithModuleMultiple(t *testing.T) {
|
||||
baseLogger := zap.NewNop()
|
||||
|
||||
logger1 := WithModule(baseLogger, "handler.proxy")
|
||||
logger2 := WithModule(baseLogger, "provider.client")
|
||||
|
||||
assert.NotNil(t, logger1)
|
||||
assert.NotNil(t, logger2)
|
||||
}
|
||||
Reference in New Issue
Block a user