Gin + Zap 日志:构建高性能、结构化的应用日志系统

发布于:2025-09-11 ⋅ 阅读:(21) ⋅ 点赞:(0)

前言

在项目开发和运维过程中,日志记录是不可或缺的一环,它帮助我们追踪请求、排查问题和监控系统状态。
Gin 框架本身提供了两个非常实用的默认中间件:gin.Logger() 和 gin.Recovery()。理解它们的功能是构建更强大日志系统的基础。本文会先介绍这两个中间件,并演示如何将 Gin 与功能强大的日志库 Zap 集成,以实现高性能、结构化的日志输出。

一、Gin 的默认日志中间件

1. gin.Logger()

gin.Logger() 是 Gin 提供的一个日志中间件,用于记录每个 HTTP 请求的基本信息。当你使用 gin.Default() 创建一个路由实例时,这个中间件会自动被加载。

它默认将日志输出到控制台(os.Stdout),记录的内容通常包括:

  • 请求方法(如 GET、POST)
  • 请求路径(URL)
  • HTTP 状态码
  • 响应耗时
  • 客户端 IP 地址

示例输出:

[GIN] 2025/09/09 - 15:00:00 | 200 |     123.456µs | 127.0.0.1 | GET /api/users

这个中间件对于开发和简单的调试非常有用,但其输出是纯文本格式,不利于后续的日志分析、搜索和监控。此外,它缺乏对日志级别、结构化字段等高级功能的支持。

2. gin.Recovery()

gin.Recovery() 是一个恢复中间件,用于捕获在处理请求过程中发生的 panic 异常,防止整个服务因单个请求的崩溃而终止。
当发生 panic 时,gin.Recovery() 会:

  • 捕获 panic。
  • 向客户端返回一个 500 Internal Server Error 响应。
  • 将 panic 的堆栈信息输出到日志(默认也是 os.Stdout)。

示例输出:

[GIN] 2025/09/09 - 15:05:00 | 500 |     1.234ms | 127.0.0.1 | GET /api/crash
panic: runtime error: invalid memory address or nil pointer dereference

与 gin.Logger() 类似,gin.Recovery() 的日志输出也是简单的文本格式,且默认输出到标准输出。

二、为什么需要集成 Zap?

虽然 Gin 的默认中间件提供了基本的日志功能,但在生产环境中,我们通常需要更强大、更灵活的日志解决方案。这就是 Zap 发挥作用的地方。

Zap 是 Uber 开源的一个高性能、结构化的 Go 日志库。它的主要优势包括:

  • 高性能:Zap 经过精心设计,性能远超标准库 log 和许多其他日志库,特别适合高并发场景。
  • 结构化日志:Zap 默认输出 JSON 格式的日志,包含明确的字段(如 level, msg, timestamp, fields 等),便于机器解析、搜索和集成到 ELK、Loki 等日志分析系统。
  • 丰富的日志级别:支持 Debug, Info, Warn, Error, DPanic, Panic, Fatal 等级别,方便进行日志分级管理。
  • 灵活的配置:可以轻松配置日志输出目标(文件、网络、标准输出等)、格式(JSON、文本)、编码器等。

如何将 Gin 与 Zap 集成

接下来,我们将一步步实现 Gin 与 Zap 的集成,替换默认的 Logger 和 Recovery 中间件

1.安装
go get -u go.uber.org/zap
2. 增加Viper log日志配置

config/config.go

Log struct {
		Level      string `mapstructure:"level"`       // 日志等级
		Format     string `mapstructure:"format"`      // 日志格式
		Filename   string `mapstructure:"filename"`    // 基准日志文件名
		MaxSize    int    `mapstructure:"maxsize"`     // 单个日志文件最大内容,单位:MB
		MaxAge     int    `mapstructure:"max_age"`     // 日志文件保存时间,单位:天
		MaxBackups int    `mapstructure:"max_backups"` // 最多保存几个日志文件
		Compress   bool   `mapstructure:"compress"`    // 是否压缩旧日志文件
		Stdout     bool   `mapstructure:"stdout"`      // 是否输出到标准输出
	} `mapstructure:"log"`

config.[dev|prod].yaml

log:
  level: "debug"
  format: "text" # 或 "json"
  filename: "./logs/dev/app.log"
  maxsize: 10 # 单个日志文件最大10MB
  max_age: 7 # 日志保存7天
  max_backups: 5 # 最多保存5个日志文件
  compress: false # 不压缩旧日志
  stdout: true # 输出到标准输出
3.初始化

在initialize目录下创建logger.go文件,实现zap日志的初始化功能:

package initialize

import (
	"fmt"
	"gin/global"
	"github.com/gin-gonic/gin"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
	"net"
	"net/http"
	"net/http/httputil"
	"os"
	"path"
	"runtime/debug"
	"strings"
	"time"
)

// InitLogger 初始化zap日志
func InitLogger() {
	// 创建编码器
	encoderConfig := zapcore.EncoderConfig{
		TimeKey:        "time",                           // 时间键
		LevelKey:       "level",                          // 日志级别键
		NameKey:        "logger",                         // 日志名称键
		CallerKey:      "caller",                         // 调用者键
		MessageKey:     "msg",                            // 消息键
		StacktraceKey:  "stacktrace",                     // 栈跟踪键
		LineEnding:     zapcore.DefaultLineEnding,        // 行结束符
		EncodeLevel:    zapcore.CapitalColorLevelEncoder, //使用带颜色的日志级别编码器
		EncodeTime:     zapcore.ISO8601TimeEncoder,       // 时间编码器
		EncodeDuration: zapcore.StringDurationEncoder,    // 持续时间编码器
		EncodeCaller:   zapcore.ShortCallerEncoder,       // 调用者编码器
	}

	// 设置日志级别
	var level zapcore.Level
	switch global.Config.Log.Level {
	case "debug":
		level = zapcore.DebugLevel
	case "info":
		level = zapcore.InfoLevel
	case "warn":
		level = zapcore.WarnLevel
	case "error":
		level = zapcore.ErrorLevel
	default:
		level = zapcore.InfoLevel
	}

	// 创建核心
	var writers []zapcore.WriteSyncer
	// 如果配置了标准输出
	if global.Config.Log.Stdout {
		writers = append(writers, zapcore.AddSync(os.Stdout))
	}
	// 如果配置了文件输出
	if global.Config.Log.Filename != "" {
		fileWriter := getLogWriter(
			global.Config.Log.Filename,
			global.Config.Log.MaxSize,
			global.Config.Log.MaxBackups,
			global.Config.Log.MaxAge,
			global.Config.Log.Compress,
		)
		writers = append(writers, fileWriter)
	}
	// 如果没有配置任何输出,默认输出到标准输出
	if len(writers) == 0 {
		writers = append(writers, zapcore.AddSync(os.Stdout))
	}

	core := zapcore.NewCore(
		getEncoder(global.Config.Log.Format, encoderConfig),
		zapcore.NewMultiWriteSyncer(writers...),
		level,
	)

	// 创建Logger
	logger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))

	// 设置全局Logger
	global.Logger = logger
	zap.ReplaceGlobals(logger)
}

// getEncoder 根据格式选择编码器
func getEncoder(format string, encoderConfig zapcore.EncoderConfig) zapcore.Encoder {
	if format == "json" {
		return zapcore.NewJSONEncoder(encoderConfig)
	}
	return zapcore.NewConsoleEncoder(encoderConfig)
}

// getLogWriter 创建日志文件写入器
func getLogWriter(filename string, maxSize, maxBackup, maxAge int, compress bool) zapcore.WriteSyncer {
	// 创建日志目录
	logDir := path.Dir(filename)
	if logDir != "." {
		// 确保日志目录存在
		if err := os.MkdirAll(logDir, os.ModePerm); err != nil {
			fmt.Printf("创建日志目录失败: %v\n", err)
		}
	}

	// 打开文件
	file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		fmt.Printf("打开日志文件失败: %v\n", err)
		// 如果打开文件失败,返回标准错误输出
		return zapcore.AddSync(os.Stderr)
	}

	return zapcore.AddSync(file)
}
4.自定义日志中间件
// initialize/logger.go
func ZapLogger(logger *zap.Logger) gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()

		// 处理请求
		c.Next()

		// 计算请求耗时
		latency := time.Since(start)

		// 获取请求信息
		clientIP := c.ClientIP()
		method := c.Request.Method
		statusCode := c.Writer.Status()
		reqPath := c.Request.URL.Path
		userAgent := c.Request.Header.Get("User-Agent")

		// 根据状态码决定日志级别
		var level zapcore.Level
		switch {
		case statusCode >= 500:
			level = zap.ErrorLevel
		case statusCode >= 400:
			level = zap.WarnLevel
		default:
			level = zap.InfoLevel
		}

		// 构建日志字段
		fields := []zap.Field{
			zap.Int("status", statusCode),
			zap.String("method", method),
			zap.String("path", reqPath),
			zap.String("ip", clientIP),
			zap.String("user-agent", userAgent),
			zap.Duration("latency", latency),
		}

		// 添加自定义字段(例如,从上下文中获取的请求ID)
		if requestId, exists := c.Get("X-Request-ID"); exists {
			fields = append(fields, zap.String("request_id", requestId.(string)))
		}

		// 记录日志
		logger.Log(level, "HTTP Request", fields...)
	}
}
5.自定义恢复中间件

https://github.com/gin-contrib/zap/blob/master/zap.go

func ZapRecovery(logger *zap.Logger, stack bool) gin.HandlerFunc {
	return func(c *gin.Context) {
		defer func() {
			if err := recover(); err != nil {
				// Check for a broken connection, as it is not really a
				// condition that warrants a panic stack trace.
				var brokenPipe bool
				if ne, ok := err.(*net.OpError); ok {
					if se, ok := ne.Err.(*os.SyscallError); ok {
						if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
							brokenPipe = true
						}
					}
				}

				httpRequest, _ := httputil.DumpRequest(c.Request, false)
				if brokenPipe {
					logger.Error(c.Request.URL.Path,
						zap.Any("error", err),
						zap.String("request", string(httpRequest)),
					)
					// If the connection is dead, we can't write a status to it.
					c.Error(err.(error)) // nolint: errcheck
					c.Abort()
					return
				}

				if stack {
					logger.Error("[Recovery from panic]",
						zap.Any("error", err),
						zap.String("request", string(httpRequest)),
						zap.String("stack", string(debug.Stack())),
					)
				} else {
					logger.Error("[Recovery from panic]",
						zap.Any("error", err),
						zap.String("request", string(httpRequest)),
					)
				}
				c.AbortWithStatus(http.StatusInternalServerError)
			}
		}()
		c.Next()
	}
}
6.在 Gin 应用中使用

main.go

// 在config初始化后
initialize.InitLogger()

initialize/router.go

Router := gin.New()

Router.Use(ZapLogger(global.Logger))
Router.Use(ZapRecovery(global.Logger, true))
// 记录不同级别的日志
global.Logger.Debug("这是一条调试日志", zap.String("key", "value"))
global.Logger.Info("这是一条信息日志", zap.Int("count", 10))
global.Logger.Warn("这是一条警告日志", zap.Error(err))
global.Logger.Error("这是一条错误日志", zap.String("error_type", "validation"))
global.Logger.Fatal("Failed to connect to database", zap.Error(err))

global.Logger.With(zap.String("request_id", "12345")).Info("处理请求")

注:Logger.Error仅记录日志。调用 Error 方法后,程序会继续正常执行后续的代码;
Logger.Fatal 记录日志 + 立即终止程序。调用 Fatal 方法后,Zap 会先将日志写入(并调用 Sync() 确保日志被刷新到磁盘或输出目标),然后立即调用 os.Exit(1)

7.运行与验证

启动应用,观察使用日志的地方是否有日志输出。

三、其他用法

  • 日志上下文:利用 Gin 的 Context 传递请求 ID、用户 ID 等信息,并在日志中输出,便于全链路追踪。
    异步写入:对于极高性能要求的场景,可以考虑使用 Zap 的异步写入功能。
  • 日志脱敏:在记录日志时,注意对敏感信息(如密码、身份证号)进行脱敏处理。
  • 集中化管理:将日志发送到 Kafka 或直接对接 Loki、Fluentd 等日志收集系统。

四、总结

通过将 Gin 与 Zap 集成,我们成功地将一个基础的 Web 框架升级为一个具备生产级日志能力的应用。Zap 提供的高性能和结构化日志特性,使得我们的应用在面对高并发流量时依然能够稳定、高效地记录关键信息。这不仅提升了系统的可观测性,也为后续的运维、监控和问题排查奠定了坚实的基础。

日志是系统的“黑匣子”,一个设计良好的日志系统是保障服务稳定运行的基石。希望本文能帮助你更好地在 Gin 项目中使用 Zap,构建更健壮的 Go 应用。

示例代码

gitee


网站公告

今日签到

点亮在社区的每一天
去签到