Files
cs-note/hzh/GIN/14-logging.md
T
2026-05-24 11:42:38 +08:00

6.8 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
日志
监控
2026-04-28 00:00

日志系统

概述

Gin 的日志机制建立在两个核心概念之上:默认日志器的可替换性(DefaultWriter / DefaultErrorWriter)和中间件的灵活组合。生产环境中通常需要接入结构化日志库,自定义日志格式,并支持按路径跳过某些路由的日志记录。

思考题:gin.Default() 内部的 Logger 中间件会把请求体打印到日志吗?如果会,这对性能有什么影响?

正文

1. Gin 内置 Logger

gin.Default() 内置了 Logger() 中间件——每处理一个请求,在终端输出类似这样的日志:

[GIN] 2026/04/27 - 10:30:00 | 200 |     2.345ms |       127.0.0.1 | GET     "/api/users"

输出字段含义:

字段 说明
[GIN] 前缀标识
2026/04/27 - 10:30:00 时间戳
200 HTTP 状态码
2.345ms 请求耗时
127.0.0.1 客户端 IP
GET "/api/users" HTTP 方法 + 路径
// gin/logger.go — 简化版实现
func Logger() HandlerFunc {
    return func(c *Context) {
        start := time.Now()
        path := c.Request.URL.Path
        query := c.Request.URL.RawQuery

        c.Next()

        latency := time.Since(start)
        status := c.Writer.Status()

        log.Printf("[%s] %d %s %s %s",
            start.Format(time.RFC3339),
            status,
            latency,
            c.ClientIP(),
            c.Request.Method+" "+path,
        )
    }
}

提问: Gin 的默认日志直接写到 os.Stdout,那如果你想把日志输出到文件而不是终端,应该怎么做?

2. 自定义日志输出目标

通过修改 DefaultWriter 和 DefaultErrorWriter:

import (
    "os"
    "gopkg.in/natefinber/lumberjack.v2" // 滚动日志
)

func main() {
    // 创建滚动日志文件
    logFile := &lumberjack.Logger{
        Filename:  "./logs/gin.log",
        MaxSize:   100,     // MB
        MaxBackups: 30,     // 最多保留 30 个备份
        Compress:  true,    // gzip 压缩
    }

    // 重写默认输出
    gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile)
    gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, logFile)

    r := gin.Default()  // Logger 内部使用 DefaultWriter
    r.Run(":8080")
}

3. 接入结构化日志(Zap / Logrus)

用结构化日志库替换 Gin 内置的简单 logger:

func structuredLogger(logger *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        query := c.Request.URL.RawQuery
        body := ""

        // ⚠️ 读取 body 会影响性能,通常只记录大 body 或错误请求
        if c.Request.ContentLength > 0 && shouldLogBody(c) {
            raw, _ := io.ReadAll(io.LimitReader(c.Request.Body, 4096))
            c.Request.Body = io.NopCloser(bytes.NewBuffer(raw))
            body = string(raw)
        }

        c.Next()

        latency := time.Since(start)
        fields := zap.Fields(
            zap.String("method", c.Request.Method),
            zap.String("path", path),
            zap.String("query", query),
            zap.Int("status", c.Writer.Status()),
            zap.Duration("latency", latency),
            zap.String("client_ip", c.ClientIP()),
            zap.String("user_agent", c.Request.UserAgent()),
            zap.Int("body_size", c.Request.ContentLength),
        )

        if len(c.Errors) > 0 {
            fields = append(fields, zap.Strings("errors", c.Errors.ToStrings()))
        }

        if c.Writer.Status() >= 500 {
            logger.Error("request error", fields...)
        } else {
            logger.Info("request", fields...)
        }
    }
}

注册方式:

logger, _ := zap.NewProduction()
defer logger.Sync()

r := gin.New()
r.Use(structuredLogger(logger))
r.Use(gin.Recovery())

关键细节: 在日志中间件中读 c.Request.Body 会消耗 body,后续 handler 就再也读不到了。所以必须先读完再重新赋值 c.Request.Body = io.NopCloser(...)。

4. 跳过特定路径的日志

并非所有请求都需要记录——静态资源、健康检查等高频请求会产生大量噪音:

func skipLogging() gin.HandlerFunc {
    skipPaths := map[string]bool{
        "/health":   true,
        "/ready":    true,
        "/metrics":  true,
        "/favicon.ico": true,
    }

    return func(c *gin.Context) {
        if skipPaths[c.Request.URL.Path] {
            c.Next()
            return
        }
        // 走正常日志流程
        c.Next()
        latency := time.Since(start)
        log.Printf("%s %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), latency)
    }
}

或者更简单地——让路由不经过 Logger 中间件(将日志中间件挂载到特定分组而非全局):

r := gin.New()

// 不挂全局,只挂到 API 分组
api := r.Group("/api")
api.Use(Logger())
{
    api.GET("/users", listUsers)    // 有日志
    api.POST("/users", createUser)  // 有日志
}

r.GET("/health", healthCheck)      // 无日志(不在 api 分组下)
r.Static("/static", "./static")    // 无日志

5. 控制日志颜色和格式

// 关闭彩色输出(适合日志采集系统)
gin.DisableConsoleColor()

// 修改日期格式
gin.DebugPrint(func(format string, args ...interface{}) {
    logger.Printf("[DEBUG] "+format, args...)
})

// 完全禁用 Debug 输出
gin.SetMode(gin.ReleaseMode) // 隐藏 DebugPrint

6. 路由日志格式定制

如果需要不同的日志格式(如 JSON),可以自建 middleware:

func jsonRouterLogger() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()

        entry := map[string]interface{}{
            "timestamp": time.Now().Format(time.RFC3339),
            "method":    c.Request.Method,
            "path":      c.Request.URL.Path,
            "query":     c.Request.URL.RawQuery,
            "status":    c.Writer.Status(),
            "latency_ms": time.Since(start).Milliseconds(),
            "client_ip": c.ClientIP(),
            "bytes_in":  c.Request.ContentLength,
            "bytes_out": c.Writer.Size(),
        }

        // 序列化输出 JSON 日志行
        b, _ := json.Marshal(entry)
        os.Stdout.Write(b)
        os.Stdout.WriteString("\n")
    }
}

思考题:如果你的服务跑在 Kubernetes 中,日志输出到 stdout 后被 sidecar(如 Fluent Bit)收集,你觉得需要手动做 JSON 序列化吗?还是可以用 K8s 生态已有的方案?

关联笔记