直接用log.setoutput无法统一多格式日志,因为它仅影响显式调用log.printf的代码,而gin、zap、fmt等各自直写os.stderr或文件,需通过自定义io.writer拦截并统一格式化。

为什么直接用 log.SetOutput 无法统一多格式日志
因为不同来源的日志(比如 gin.DefaultLogger、zap、第三方库的 fmt.Printf、甚至系统 stderr 重定向)根本不会走 Go 标准库的 log 包,它们各自写文件或输出到 os.Stderr,你用 log.SetOutput 只能接管自己代码里显式调用的 log.Printf,对其他日志完全无效。
真正可行的思路是:在日志落地前做「拦截」——要么改写日志目标为自定义 io.Writer,要么用钩子(hook)机制注入格式化逻辑。但注意:不是所有日志库都支持钩子,比如原生 log 没有,zap 有,gin 的中间件日志需要单独包装。
用 io.MultiWriter + 自定义 io.Writer 统一捕获并格式化
核心是把所有日志目标(文件、控制台、网络等)替换成一个统一的 io.Writer,它内部做两件事:解析原始日志行(按换行切分)、按规则重写时间戳/级别/服务名等字段,再转发给下游。
常见错误现象:panic: interface conversion: interface {} is string, not []byte —— 因为有些日志库(如 gin)会直接写 string 而非 []byte,你的 Write 方法必须兼容两者。
实操建议:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 实现
Write(p []byte) (n int, err error)时,先尝试string(p)解析;若含结构化字段(如 JSON),用json.Unmarshal提取;若纯文本,用正则提取时间、级别(如\b(INFO|ERROR|DEBUG)\b) - 不要在
Write中做耗时操作(如磁盘 I/O、网络请求),否则阻塞日志调用方;格式化后应异步写入或批量刷盘 - 对
gin日志,替换gin.DefaultWriter:gin.DefaultWriter = yourCustomWriter;对zap,用zapcore.AddSync(yourCustomWriter)
zap 钩子(Hook)与 gin 中间件的协同难点
zap 的 Core 支持 Check/Write 钩子,但默认不解析原始日志字符串 —— 它接收的是 zapcore.Entry 结构体,字段已结构化,无需正则提取。而 gin 日志是纯文本,且默认不接入 zap。
所以不能只靠一个钩子解决全部问题。必须分层处理:
-
zap日志:用自定义Core在Write阶段统一补全service_name、格式化time字段(用entry.Time.Format("2006-01-02T15:04:05.000Z0700")) -
gin日志:写中间件,捕获c.Next()前后的time.Now()和c.Request.Method等,构造zap字段后交由同一logger输出,避免双写 - 第三方库日志(如
database/sql的慢查询):只能重定向其io.Writer到你的统一Writer,再做文本解析
时间戳和时区错乱是最高频的坑
Go 默认用本地时区格式化时间,但日志聚合系统(如 Loki、ELK)通常要求 UTC。更麻烦的是:gin 日志用 time.Now().String()(带本地时区缩写),zap 默认用 time.RFC3339Nano(UTC),混在一起就出现 8 小时偏差。
解决方案必须全局一致:
- 所有日志输出前,强制转为 UTC:
time.Now().UTC().Format("2006-01-02T15:04:05.000Z0700")(注意末尾Z0700表示 UTC+0,不是字面 Z) - 不要依赖
time.Local或环境变量TZ,启动时就调用time.LoadLocation("UTC")并显式传入 - 验证方式:在日志行开头加固定标记,如
[UTC],再对比系统date -u输出
多格式日志统一真正的复杂点不在格式重写,而在「谁在什么时候以什么方式写入了哪一段内容」—— 一旦有任意一路绕过你的 Writer 或 Hook,格式就会断层。上线前务必用真实流量打日志,grep 检查每种来源的首行时间戳是否对齐。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










