网络应用跑起来之后,日志往往是排查问题的唯一线索。但不少项目里日志输出相当随意:有的地方用fmt.Println直接打印,有的地方用标准库log包,有的甚至干脆没打日志,等到线上出问题才开始补。更常见的情况是,多人协作的项目里每个人打日志的风格都不一样,有人喜欢输出JSON,有人喜欢纯文本,有人带时间戳有人不带,最后聚合到一起根本没法做结构化检索。这篇文章就来聊聊如何借助logger包把整个网络应用的日志格式统一起来,并且顺带打通调试与监控的链路。

为什么日志格式统一是网络应用调试的基础
先说清楚一个观点:日志的价值不在于打了多少条,而在于能不能被高效消费。网络应用的日志最终会被采集到ELK、Loki或者云厂商的日志服务里,如果格式不统一,采集端就要为每一种格式写解析规则,解析失败的数据等于白打。而统一的日志格式,尤其是JSON结构化日志,可以直接被日志平台按字段建立索引,检索速度和准确性都会大幅提升。
统一的日志格式至少要包含这几个字段:时间戳(精确到毫秒甚至微秒)、日志级别(DEBUG、INFO、WARN、ERROR)、代码位置(文件名和行号)、请求标识(比如request_id或trace_id)、以及业务消息本身。其中请求标识在网络应用里特别关键,一个HTTP请求进来,可能依次经过路由、鉴权、业务处理、数据库访问,如果每条日志都带上同一个request_id,用这个ID一搜就能还原请求的完整处理链路,调试效率完全不一样。
另一个容易被忽视的收益是监控集成。日志格式统一之后,监控系统可以直接统计ERROR级别的日志数量做告警,也可以从日志里提取响应耗时字段生成性能指标,不需要再额外埋一套监控代码。日志和监控共用一套数据源,维护成本也低得多。
设计一个结构化的logger包
与其在项目里直接散用标准库,不如自己封装一个轻量的logger包,或者基于成熟的第三方库做二次封装。这里先看一个自定义logger包的核心设计,用Go标准库的log/slog作为底层实现,结构清晰且容易扩展。
package logger
import (
"context"
"log/slog"
"os"
)
type ctxKey string
const requestIDKey ctxKey = "request_id"
// WithRequestID 将请求ID写入context
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// Init 初始化全局logger,输出JSON格式
func Init() *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
l := slog.New(handler)
slog.SetDefault(l)
return l
}
// FromCtx 从context取出logger,自动附带request_id
func FromCtx(ctx context.Context) *slog.Logger {
if ctx == nil {
return slog.Default()
}
if id, ok := ctx.Value(requestIDKey).(string); ok {
return slog.Default().With("request_id", id)
}
return slog.Default()
}
这个设计的核心思路是把logger和context.Context绑定。Go 1.7之后context贯穿整个请求处理链路,中间件生成request_id后放进context,后续任何一层代码通过logger.FromCtx(ctx)取出的logger都自动携带这个ID,业务代码完全不需要手动传request_id参数,减少了遗漏的可能。
输出统一采用JSON格式,一条日志长这样:{"time":"2024-06-01T10:23:45.123+08:00","level":"INFO","msg":"handle request","request_id":"a1b2c3"}。这种格式对日志平台最友好,字段名可以在初始化时统一约定,全团队共用一份字段规范文档,新增字段也走评审,避免每个人随性发挥。
在HTTP服务中接入中间件自动注入日志上下文
有了logger包,下一步是让HTTP服务的每个请求自动获得日志上下文。用中间件实现最自然,下面是一个完整的示例,包含request_id生成、访问日志记录和错误恢复。
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"yourapp/logger"
)
func genRequestID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 优先复用上游传入的request_id,便于跨服务串联
rid := r.Header.Get("X-Request-ID")
if rid == "" {
rid = genRequestID()
}
ctx := logger.WithRequestID(r.Context(), rid)
w.Header().Set("X-Request-ID", rid)
log := logger.FromCtx(ctx)
defer func() {
if rec := recover(); rec != nil {
log.Error("panic recovered",
"error", rec,
"path", r.URL.Path,
)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r.WithContext(ctx))
log.Info("request done",
"method", r.Method,
"path", r.URL.Path,
"status", 200,
"cost_ms", time.Since(start).Milliseconds(),
)
})
}
注意一个细节:中间件优先读取请求头里的X-Request-ID。这样当请求经过网关或者上游服务时,同一个调用链路在多个服务里的日志能共用一个ID,跨服务排查问题时按ID一搜,所有相关服务的日志都能串起来。这就是分布式追踪最朴素的实现方式,不引入完整链路追踪框架时非常实用。
访问日志里带上cost_ms字段是刻意为之的。耗时数据打进日志后,监控系统(比如Grafana Loki配合LogQL,或者用filebeat采集后做指标聚合)可以直接按这个字段统计接口的P95、P99延迟,日志一份数据两用,调试和监控都覆盖了。
业务代码里使用起来也很简单,任何handler里只要有context就能拿到带完整上下文的logger:
func GetUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logger.FromCtx(ctx)
user, err := queryUser(ctx, r.URL.Query().Get("id"))
if err != nil {
log.Error("query user failed", "err", err, "id", r.URL.Query().Get("id"))
http.Error(w, "query failed", http.StatusInternalServerError)
return
}
log.Info("query user ok", "user_id", user.ID)
// 正常返回业务数据...
}
日志分级、轮转与监控告警的落地实践
统一格式只是第一步,要让这套日志体系真正服务调试和监控,还需要处理三个问题:级别控制、文件轮转和告警对接。
关于级别控制,建议在生产环境把全局级别设为INFO,但允许通过环境变量或者配置中心动态调整。线上出现疑难问题时,临时把某个模块调成DEBUG级别,能拿到更细的信息,排查完再调回去。slog可以用slog.LevelVar实现运行时动态调整,不需要重启服务:
var level = new(slog.LevelVar)
func init() {
level.Set(slog.LevelInfo)
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})))
}
// 提供HTTP接口或监听配置变更,动态调整级别
func SetLevel(s string) {
switch s {
case "debug":
level.Set(slog.LevelDebug)
case "info":
level.Set(slog.LevelInfo)
case "error":
level.Set(slog.LevelError)
}
}
文件轮转方面,如果日志写本地文件,推荐配合lumberjack库,按大小和时间切分,避免单文件无限膨胀:
import "gopkg.in/natefinch/lumberjack.v2"
logFile := &lumberjack.Logger{
Filename: "/var/log/myapp/app.log",
MaxSize: 100, // 单文件最大100MB
MaxBackups: 7, // 保留7个备份
MaxAge: 14, // 保留14天
Compress: true,
}
handler := slog.NewJSONHandler(logFile, nil)
最后是监控告警。日志采集到平台后,告警规则通常围绕两类指标:一是ERROR级别日志的速率,比如五分钟内ERROR超过某个阈值就触发告警;二是从日志提取的业务指标,比如上面提到的cost_ms做延迟监控,或者统计特定错误码的出现频率。因为日志格式统一,这些规则只需要写一次就能覆盖全部服务,这也是前面花力气统一格式换来的最大回报。
总结一下,统一日志格式这件事本质上是在为整个系统的可观测性打地基。一个设计良好的logger包加上中间件注入,成本不高,但能让调试从大海捞针变成按图索骥,监控告警也有了可靠的数据来源,值得在每个网络应用项目一开始就规划好。