243 lines
6.8 KiB
Markdown
243 lines
6.8 KiB
Markdown
|
|
---
|
|||
|
|
tags: [后端, Go, Gin, 日志, 监控]
|
|||
|
|
create time: 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 方法 + 路径 |
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 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`:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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...)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
注册方式:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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. 跳过特定路径的日志
|
|||
|
|
|
|||
|
|
并非所有请求都需要记录——静态资源、健康检查等高频请求会产生大量噪音:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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 中间件(将日志中间件挂载到特定分组而非全局):
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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. 控制日志颜色和格式
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 关闭彩色输出(适合日志采集系统)
|
|||
|
|
gin.DisableConsoleColor()
|
|||
|
|
|
|||
|
|
// 修改日期格式
|
|||
|
|
gin.DebugPrint(func(format string, args ...interface{}) {
|
|||
|
|
logger.Printf("[DEBUG] "+format, args...)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 完全禁用 Debug 输出
|
|||
|
|
gin.SetMode(gin.ReleaseMode) // 隐藏 DebugPrint
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6. 路由日志格式定制
|
|||
|
|
|
|||
|
|
如果需要不同的日志格式(如 JSON),可以自建 middleware:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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 生态已有的方案?
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[GIN/3-middleware]] — 中间件注册位置和 Skip 模式
|
|||
|
|
- [[GIN/18-observability]] — 结合 Prometheus metrics 的全面监控方案
|
|||
|
|
- [[部署与运维基础]] — 容器化环境中的日志采集(stdout → fluentd/fluent-bit)
|