2. log包深度使用:log包源码分析、自定义Logger、输出到文件
聊到Go的日志,很多人第一反应就是标准库的log包。说实话,这个包看起来简单,但用好了真能省不少事。我刚开始写Go的时候,也觉得日志嘛,打印出来不就行了?后来线上出问题,翻日志翻到崩溃,才意识到——日志这事,真得认真对待。
2.1 标准log包到底干了啥?
先看看标准库的log包。它其实就做三件事:
- 格式化输出
- 控制输出位置
- 加个时间戳和前缀
你想想看,日常开发中,我们最常用的就是log.Println、log.Printf这些。但你知道它底层是怎么工作的吗?
标准库的Logger结构体,核心字段就这几个:
type Logger struct {
mu sync.Mutex
prefix string
flag int
out io.Writer
buf []byte
}
mu是互斥锁,保证并发安全。prefix就是日志前缀。flag控制输出格式,比如要不要打印时间、文件名。out是输出目标,默认是os.Stderr。buf是个缓冲区,用来拼接日志内容。
嗯,这里要注意:buf是每个Logger实例独享的,所以并发写日志时,锁保护的是整个buf的拼接和写入过程。这也是为什么标准库的log包是并发安全的。
2.2 源码里的小细节
我翻过标准库的源码,发现一个有意思的地方。每次写日志时,它会先加锁,然后重置buf,再拼接前缀、时间、消息,最后一次性写入out。这样做的好处是——减少系统调用次数。
举个例子:
func (l *Logger) Output(calldepth int, s string) error {
now := time.Now()
var file string
var line int
l.mu.Lock()
defer l.mu.Unlock()
l.buf = l.buf[:0]
l.formatHeader(&l.buf, now, file, line)
l.buf = append(l.buf, s...)
if len(s) == 0 || s[len(s)-1] != '\n' {
l.buf = append(l.buf, '\n')
}
_, err := l.out.Write(l.buf)
return err
}
看到没?它先把所有内容拼到buf里,然后一次Write。我在项目中遇到过,有人自己写日志库,每次写一行就调一次Write,结果高并发下性能惨不忍睹。标准库这种做法,其实挺聪明的。
2.3 自定义Logger,没那么玄乎
标准库的log.New函数,可以创建自定义Logger。它的签名是:
func New(out io.Writer, prefix string, flag int) *Logger
三个参数:输出目标、前缀、格式标志。就这么简单。
我建议你至少掌握这几个flag:
| Flag | 含义 |
|---|---|
Ldate | 打印本地日期 |
Ltime | 打印本地时间 |
Lmicroseconds | 打印微秒级时间 |
Llongfile | 打印完整文件名和行号 |
Lshortfile | 只打印文件名和行号 |
LstdFlags | 默认值,等于Ldate | Ltime |
举个例子,我想让日志带上文件名和行号:
logger := log.New(os.Stderr, "[INFO] ", log.Ldate|log.Ltime|log.Lshortfile)
logger.Println("服务启动成功")
输出效果:
[INFO] 2025/01/15 14:30:22 main.go:42: 服务启动成功
我个人习惯,开发环境用Lshortfile,生产环境去掉它。为什么?因为获取调用栈信息是有性能开销的。生产环境日志量一大,这个开销就明显了。
2.4 输出到文件,这才是正经事
默认日志输出到控制台,但生产环境肯定要写文件。怎么做?很简单,把io.Writer换成文件句柄就行。
file, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatal("打开日志文件失败:", err)
}
defer file.Close()
logger := log.New(file, "[APP] ", log.LstdFlags)
logger.Println("这条日志会写到文件里")
这里要注意几个点:
os.O_CREATE:文件不存在就创建os.O_WRONLY:只写模式os.O_APPEND:追加写入,不会覆盖- 权限
0666:读写权限,生产环境建议更严格
曾经踩过的坑: 我曾经在生产环境直接用了os.O_TRUNC,结果每次重启服务,历史日志全没了。排查问题的时候,日志文件只有重启后的记录,之前的错误信息全丢了。那次教训让我记住了——生产环境永远用O_APPEND。
2.5 多输出目标,一个Logger搞定
有时候我想同时输出到控制台和文件,怎么办?标准库的io.MultiWriter可以帮忙。
file, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
multiWriter := io.MultiWriter(os.Stdout, file)
logger := log.New(multiWriter, "[INFO] ", log.LstdFlags)
logger.Println("控制台和文件都能看到这条日志")
你看,一个Logger,同时写两个地方。这在调试阶段特别有用——控制台实时看,文件留底备查。
2.6 日志轮转,别让文件撑爆磁盘
日志文件一直写,迟早会撑爆磁盘。标准库没有内置轮转功能,但你可以用第三方库,比如lumberjack。
import "gopkg.in/natefinch/lumberjack.v2"
logger := log.New(&lumberjack.Logger{
Filename: "app.log",
MaxSize: 100, // 兆字节
MaxBackups: 3,
MaxAge: 28, // 天
Compress: true,
}, "[INFO] ", log.LstdFlags)
这个库会自动处理文件切割、压缩、清理旧文件。我个人习惯,MaxSize设100MB,MaxBackups设3-5个,MaxAge设30天。这样既不会丢日志,也不会撑爆磁盘。
小提示: 如果你不想引入第三方库,也可以自己写个定时任务,用os.Rename重命名日志文件,然后重新OpenFile。但说实话,轮转这事看着简单,做起来坑不少。我建议直接用成熟的库。
2.7 总结一下
标准库的log包,虽然功能不多,但胜在稳定、简单。你想想看,日常开发中,80%的场景用标准库就够了。自定义Logger、输出到文件、多目标输出,这些技巧掌握了,日志这块基本就稳了。
当然,如果你需要更高级的功能——比如日志级别、结构化日志、异步写入——那就得考虑logrus、zap这些第三方库了。不过那是后面章节的内容,咱们一步步来。
嗯,这一章就到这。下一章我会聊聊日志级别和格式化输出,到时候再给你分享一些实战经验。