"未经审查的日志不值得输出"
上周五下午四点五十九分,我正准备合上电脑冲去赶地铁,突然收到运维小哥的钉钉:"兄弟,你刚上线的那个服务,日志里order_id怎么有一半变成了!BADKEY?"
我心头一紧,赶紧打开Kibana一看——好家伙,果然有一批日志的字段名是!BADKEY,值是我本该传的amount。再翻代码,发现是写Info时少传了一个参数:
// 我的"杰作"
slog.Info("order placed", "order_id", id, "amount") // 少了amount的值!
那一刻,我仿佛听到笛卡尔在耳边低语:"我写故我崩"。
这就是Go标准库slog的"经典陷阱":用...any传键值对,编译器不检查,运行时才暴露问题。但今天我想聊的不是吐槽,而是一套让我从"日志社死"到"类型安全"的实战工作流——亲测在千级Pod、250QPS的生产环境稳如老狗。
为什么我"叛逃"了zap,却差点被slog背刺
先坦白:在Go 1.21之前,我是zap的死忠粉。性能强、生态好、社区卷,谁用谁知道。但自从标准库推出slog,我开始动摇——毕竟少一个依赖,就少一分"明天这个库不维护了怎么办"的焦虑。
可刚用slog时,我也踩过坑。比如:
// 键值顺序写反,结果dashboard按用户ID聚合了订单金额
slog.Info("placed", id, "order_id")
// 输出: {"msg":"placed","ord_001":"order_id"}
// 类型传错,金额变成字符串,后续聚合计算全挂
slog.Info("placed", "amount", "1290")
// 输出: {"amount":"1290"} // string, not int
最可怕的是,这些日志格式都是"合法"的JSON,查询时不会报错,但业务逻辑已经悄悄跑偏。这让我想起康德说的"人为自然立法"——我们以为在给日志"立法",结果被运行时悄悄"修了法"。
第一招:把Logger当"对象"传,别当"全局变量"用
很多教程喜欢用slog.Info()这种包级函数,写起来爽,但隐患极大。因为底层依赖一个可变的全局默认logger,测试时容易串数据,不同模块还可能互相覆盖配置。
我的做法很简单:像传*sql.DB一样,把*slog.Logger作为依赖注入。
type OrderService struct {
logger *slog.Logger // 明确依赖,一目了然
}
func NewOrderService(logger *slog.Logger) *OrderService {
return &OrderService{
logger: logger}
}
main函数里统一初始化一次,测试时传个写内存的logger,清爽又隔离。这招看似基础,但能避免80%的"为什么测试日志不输出"类问题。
第二招:LogAttrs,类型安全的"开关"
这才是今天的重头戏。slog提供了Info、Warn等快捷方法,但它们用...any传参,类型检查全靠"自觉"。而LogAttrs要求你显式用slog.String()、slog.Int64()等构造器——编译器帮你把关,写错直接编译失败。
// ✅ 类型安全版
logger.LogAttrs(ctx, slog.LevelInfo, "order placed",
slog.String("order_id", id),
slog.Int64("amount_cents", amount),
)
看起来代码多了点?但想想:编译期报错 vs 线上查日志两小时,你选哪个?
而且LogAttrs其实更高效。Info方法要把每个参数装箱成any,运行时再解析配对;而LogAttrs的参数已经是预类型的Attr,直接传给handler,少了一次反射和类型断言。在高频日志场景下,这点优化积少成多。
小技巧:如果当前函数没有context,我习惯传
context.TODO()而不是Background()。TODO()像一个"技术债务标记",提醒我"这里该传context但还没传",倒逼架构演进。
第三招:把字段定义"收编"到internal/log
光用LogAttrs还不够。如果每个地方都写slog.String("order_id", id),哪天产品经理说"order_id改成orderId",你就得全局搜索替换,还怕漏掉拼写错误的。
我的解法:在internal/log/attrs.go里集中定义所有日志字段的helper函数。
// internal/log/attrs.go
package log
import "log/slog"
func OrderID(id string) slog.Attr {
return slog.String("order_id", id) // key统一管理
}
func AmountCents(c int64) slog.Attr {
return slog.Int64("amount_cents", c) // 类型也锁死
}
func Err(e error) slog.Attr {
if e == nil {
return slog.Attr{
} // 空error不输出
}
return slog.String("err", e.Error())
}
调用时:
logger.LogAttrs(ctx, slog.LevelInfo, "order created",
applog.OrderID(order.ID),
applog.AmountCents(order.Amount),
)
好处立竿见影:
- 重命名:改
attrs.go里一行,全站生效 - 类型保护:
AmountCents只接受int64,传int或string直接编译报错 - 隐私控制:某天说要脱敏
email字段?改Email()函数返回[redacted]就行,不用翻几百个调用点
对于嵌套结构,同样适用:
func User(u User) slog.Attr {
return slog.Group("user", // 分组输出,结构清晰
slog.String("id", u.ID),
slog.String("tier", u.Tier),
// 注意:故意不输出Email,防泄露
)
}
第四招:让sloglint当你的"日志监工"
人总会偷懒,尤其赶需求时。怎么保证团队都遵守这套规范?上golangci-lint的sloglint插件。
我的配置:
linters-settings:
sloglint:
attr-only: true # 禁止用kv形式,必须用LogAttrs
no-global: "all" # 禁止用slog.Info等全局函数
context: "all" # 所有日志必须传context
static-msg: true # 日志消息必须是字符串字面量
key-naming-case: snake # key统一用snake_case
配置完,提交代码时自动检查。新人不小心写了Info("xxx", "key", val)?CI直接红叉,附带友好提示。这比Code Review时人肉发现高效多了。
写到这,突然想起波兹曼在《技术垄断》里的提醒:"工具会重塑我们的思维习惯"。LogAttrs+helper+sloglint这套组合拳确实能避免很多低级错误,但别因此产生"类型安全=逻辑正确"的错觉。
比如:
AmountCents(int64)能防止传错类型,但防止不了"该传分却传了元"的业务逻辑错误- helper函数能统一key命名,但定义哪些字段该记录、哪些该脱敏,还得靠人对业务的理解
我的原则是:用类型系统挡住"语法级"错误,把精力留给"语义级"思考。就像给智能体配了安全带,但方向盘还得自己握。
结语:日志是程序的"黑匣子",值得认真对待
海德格尔说"语言是存在之家"。对程序员而言,日志就是程序运行的"存在证明"。写得好,排查问题如庖丁解牛;写得烂,线上故障像盲人摸象。
Go的slog或许不是性能最强的日志库,但它胜在"标准"和"可控"。配合LogAttrs、依赖注入、统一helper和lint检查,完全能构建出一套类型安全、易于维护、适合协作的日志体系。
最后送大家一句程序员版"存在主义":
"我类型安全,故我日志可信;我日志可信,故我上线心安。"
下次写日志前,不妨多花30秒定义个helper。也许某天深夜救你命的,就是这30秒的"类型执念"✨