前言
最近有较多的时间思考开发上的最佳实践,考虑到目前开发的一个后端项目基本就是拿 go 原生 log 到处打印与储存片面事件而已。
logger.Info("Some Error happened")在阅读以下文件与最佳实践后,我想着手改善现有的 logging 体验透过导入 Go 1.21 引入的原生 slog 库。
- 使用 slog 进行结构化日志记录 - Go
- Logging sucks. And here’s how to make it better. - Boris Tane
- 日志记录 - gin
- 自定义日志格式 - gin
最佳典范定义
- 结构化 log:储存为 JSON 或类似格式的 log。
- 宽事件:一种理念,将某事件(请求)对应一个包含所有上下文的 log。
- 基数(Cardinality):log 数值的丰富可能性。
- 高基数:如 user_id,独特容易抓出问题所在但耗费成本记录。
- 低基数:如 http_method (GET, POST, PUT, DELETE, etc.),常见且重复。
实际导入结构化宽事件 Log 到 Go Gin 专案
取一个待办事项的 CRUD 专案为例,实际范例代码可以参考:Logger PR:
internal/logger/├── logger.go # 初始化全局 Logger├── middleware.go # 宽事件组合 middleware└── context.go # 业务上下文累积工具logger.go:初始化 Logger
首先建立 logger.go,根据执行环境选择不同的 Handler,slog 的 Handler 其实就是一个接口处理「log 要怎么被处理和输出」:
var Log *slog.Logger
func InitLogger() { var handler slog.Handler
if gin.Mode() == gin.DebugMode { handler = tint.NewHandler(os.Stdout, &tint.Options{ Level: slog.LevelDebug, TimeFormat: time.Kitchen, }) } else { handler = slog.NewJSONHandler(&lumberjack.Logger{ Filename: "logs/app.log", MaxSize: 10, MaxBackups: 5, MaxAge: 30, Compress: true, }, &slog.HandlerOptions{Level: slog.LevelInfo}) }
Log = slog.New(handler.WithAttrs([]slog.Attr{ slog.String("service", "go-gin-testing-todos"), })) slog.SetDefault(Log)}开发环境使用 tint 输出带颜色与美化日期的文字格式 生产环境输出 JSON 并透过 Gin 官方文件推荐的 lumberjack 做 log rotation。
WithAttrs 让每一笔 log 都自动带上 service 字段,可以塞入一些全局低基数的常见信息像是:地区、服务名称、环境变量⋯⋯等。
middleware.go:组合宽事件
透过把单一请求中发生的事件对应到一笔 log 组成宽事件,实作上透过 gin middleware 在请求结束后统一组装记录于 Logger:
package logger
import ( "log/slog" "time"
"github.com/gin-gonic/gin")
// Base on Structured Logging from gin doc// https://gin-gonic.com/en/docs/logging/structured-logging/// WideEventMiddleware intercepts requests and emits a single wide event upon completion.func WideEventMiddleware() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() c.Next()
statusCode := c.Writer.Status() requestID, _ := c.Get("requestId") requestIDStr, _ := requestID.(string)
fields := []slog.Attr{ slog.String("method", c.Request.Method), slog.Int("status_code", statusCode), slog.String("path", c.Request.URL.Path), slog.String("query", c.Request.URL.RawQuery), slog.Int64("duration_ms", time.Since(start).Milliseconds()), slog.String("client_ip", c.ClientIP()), slog.String("requestId", requestIDStr), }
// Add Error to log if len(c.Errors) > 0 { fields = append(fields, slog.String("error_message", c.Errors.Last().Error())) }
msg := "request_completed" switch { case statusCode >= 500: Log.LogAttrs(c.Request.Context(), slog.LevelError, msg, fields...) case statusCode >= 400: Log.LogAttrs(c.Request.Context(), slog.LevelWarn, msg, fields...) default: Log.LogAttrs(c.Request.Context(), slog.LevelInfo, msg, fields...) } }}c.Next() 让所有请求工作先跑完,middleware 才在最后收集所有信息一次性输出。这样就不会有「一个请求散落多笔 log」的问题。
context.go:业务上下文累积
请求执行过程中,透过 AddContext 把业务信息写入 gin context,等 middleware 结束时一起带出:
package logger
import ( "log/slog"
"github.com/gin-gonic/gin")
const businessContextKey = "logger_context"
// AddContext appends typed slog attributes to the request's business context.func AddContext(c *gin.Context, attrs ...slog.Attr) { if len(attrs) == 0 { return }
ctxAttrs := make([]slog.Attr, 0, len(attrs)) if existing, exists := c.Get(businessContextKey); exists { if existingAttrs, ok := existing.([]slog.Attr); ok { ctxAttrs = append(ctxAttrs, existingAttrs...) } }
ctxAttrs = append(ctxAttrs, attrs...) c.Set(businessContextKey, ctxAttrs)}
// GetContext retrieves the accumulated business context from the request.// Returns a copy so callers cannot mutate the context state.func GetContext(c *gin.Context) []slog.Attr { if existing, exists := c.Get(businessContextKey); exists { if attrs, ok := existing.([]slog.Attr); ok { return append([]slog.Attr(nil), attrs...) } } return nil}func (c *TodoController) Create(ctx *gin.Context) { var todo model.Todo if err := ctx.ShouldBindJSON(&todo); err != nil { ctx.Error(err) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return }
logger.AddContext(ctx, slog.String("todo_title", todo.Title))
if err := c.service.Create(&todo); err != nil { ctx.Error(err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return }
logger.AddContext(ctx, slog.String("todo_id", todo.ID.Hex())) ctx.JSON(http.StatusCreated, todo)}注意 todo_id 是在 Create 成功后才加入,这展示了宽事件的弹性:上下文可以在请求生命周期中逐步累积,不需要在一开始就记录信息。
直接 log
要直接拿 logger 实例来操作也是没问题的,上面只是透过 gin context 储存请求发生的状态并透过 middleware 把重复的上下文给统一收集起来处理而已。
func (s *todoService) Create(todo *model.Todo) error { logger.Log.Debug("todo_create", slog.String("collection", "todos"), slog.String("operation", "InsertOne"), slog.Any("document", todo), ) // ...}main.go:装上 logger 系统
func main() { logger.InitLogger()
r := gin.New() // 不用 gin.Default(),避免 gin 内建 logger 重复输出 r.Use(gin.Recovery()) r.Use(logger.WideEventMiddleware()) // ...}使用 gin.New() 而非 gin.Default(),因为 gin.Default() 内建了自己的 logger middleware,会和我们的宽事件 middleware 重复输出。
输出结果
一个请求一笔 log,所有上下文都清晰地记录呈现
透过 slog.NewJSONHandler 生产环境(JSON)结果范例:
{ "time": "2026-05-06T13:40:44.192568+08:00", "level": "INFO", "msg": "todo_read", "service": "go-gin-testing-todos", "method": "GET", "status_code": 200, "path": "/todos", "query": "", "duration_ms": 4, "client_ip": "::1", "requestId": "076f59e0-2ca3-40e1-8789-7c45708707d5", "resource": "todo", "action": "read", "todos_count": 12}透过自制的开发环境 Handler 结果范例:
12:55PM DBG Get All Todos service=go-gin-testing-todos collection=todos operation=Find filter=map[]12:56PM ERR todo_read service=go-gin-testing-todos method=GET status_code=500 path=/todos query="" duration_ms=30001 client_ip=::1 requestId=2d02fee1-6912-4799-bd91-e1718ee0ab7b resource=todo action=read error_message="server selection error: server selection timeout, current topology: { Type: Unknown, Servers: [{ Addr: localhost:27017, Type: Unknown, Last error: dial tcp [::1]:27017: connect: connection refused }, ] }"总结
考量到现成套件使用人数不多,像是:sloggin、gin-contrib/slog、samber/slog-gin,我自己在 slog 之上添加了轻量的抽象让收集请求 log 变得更容易。未来可以考虑 Tail-based Sampling、异步更新 log 来解决不同延伸问题。
- slog-multi 管理多个 Handler 好用
- slog-syslog 现成 syslog Handler