日志最好能被机器读懂
过去很多 Go 小项目使用标准库 log:
log.Printf("user %d login failed: %v", userID, err)
这种日志给人看没问题,但给日志系统分析不够方便。如果你想按 user_id 搜索,按 status 聚合,按 duration_ms 排序,结构化日志会更好。Go 1.21 引入了标准库 log/slog,让结构化日志成为标准能力。
slog 的核心思想很简单:日志消息是一段文本,额外上下文用 key/value 字段表示。字段稳定后,日志平台、命令行工具和开发者都更容易处理。
这篇文章从最小用法讲起,然后写一个 HTTP 请求日志中间件。
最小用法
logger := slog.Default()
logger.Info("server starting")
带字段:
logger.Info("server starting",
"addr", ":8080",
"env", "development",
)
错误:
if err != nil {
logger.Error("load config failed", "err", err)
}
字段名建议稳定、短小、英文,比如 request_id、user_id、path、status、duration_ms。不要今天写 uid,明天写 userId,后天写 user_id。日志字段不统一,后面查询会很痛苦。
TextHandler 和 JSONHandler
文本日志:
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("hello", "name", "Go")
JSON 日志:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello", "name", "Go")
本地开发用文本更容易看,生产环境通常用 JSON,更适合日志平台解析。可以通过配置选择:
func NewLogger(format string) *slog.Logger {
if format == "json" {
return slog.New(slog.NewJSONHandler(os.Stdout, nil))
}
return slog.New(slog.NewTextHandler(os.Stdout, nil))
}
使用:
logger := NewLogger(os.Getenv("LOG_FORMAT"))
不要在业务函数里到处决定日志格式。格式是运行环境选择,业务只负责记录事件和字段。
With 添加公共字段
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
With("service", "user-api").
With("version", "v1.0.0")
之后这两个字段会出现在每条日志里。请求级别也可以创建子 logger:
requestLogger := logger.With(
"request_id", requestID,
"method", r.Method,
"path", r.URL.Path,
)
requestLogger.Info("request started")
With 返回新 logger,不会修改原 logger。这样你可以在不同范围添加不同字段。
HTTP 请求日志
记录状态码需要包装 ResponseWriter:
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
中间件:
func RequestLogger(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{
ResponseWriter: w,
status: http.StatusOK,
}
next.ServeHTTP(rec, r)
logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
使用:
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
server := &http.Server{
Addr: ":8080",
Handler: RequestLogger(logger, mux),
}
请求日志是最基础的可观测性。它应该告诉你:什么方法、什么路径、返回什么状态、耗时多久。
不要记录敏感信息
结构化日志更容易被收集和长期保存,所以敏感数据更要小心。不要记录密码、token、完整身份证、银行卡号和完整私密请求体。
错误示例:
logger.Info("login request",
"email", req.Email,
"password", req.Password,
)
正确示例:
logger.Info("login failed",
"email", req.Email,
"reason", "invalid_credentials",
)
是否记录 email 也要看业务合规要求。日志不是另一个数据库,不能什么都塞进去。
在业务函数里传 logger
小项目里可以直接使用全局默认 logger,但服务变大后,更推荐把 logger 作为依赖传入:
type UserService struct {
logger *slog.Logger
}
func NewUserService(logger *slog.Logger) *UserService {
return &UserService{logger: logger}
}
func (s *UserService) Register(ctx context.Context, email string) error {
s.logger.Info("register user", "email", email)
if email == "" {
return fmt.Errorf("email is required")
}
return nil
}
如果请求里有 request id,可以创建带字段的 logger:
requestLogger := logger.With("request_id", requestID)
service := NewUserService(requestLogger)
这样 service 不需要知道 request id 从哪里来,只是正常记录日志。测试时也可以传一个写到 buffer 的 logger,检查是否输出了关键字段。不要让业务函数到处调用 slog.Default(),否则依赖隐藏起来,后面替换和测试都会麻烦。
小结
Go 1.21 的 log/slog 让结构化日志进入标准库。你可以选择文本或 JSON handler,用 key/value 记录字段,用 With 添加公共上下文,用中间件记录 HTTP 请求。
好的日志不在于多,而在于关键字段清楚、命名稳定、敏感信息克制。小服务从一开始用 slog 建立习惯,后面接日志平台会轻松很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。