2. log包深度使用:log包源码分析、自定义Logger、输出到文件

聊到Go的日志,很多人第一反应就是标准库的log包。说实话,这个包看起来简单,但用好了真能省不少事。我刚开始写Go的时候,也觉得日志嘛,打印出来不就行了?后来线上出问题,翻日志翻到崩溃,才意识到——日志这事,真得认真对待。

2.1 标准log包到底干了啥?

先看看标准库的log包。它其实就做三件事:

  • 格式化输出
  • 控制输出位置
  • 加个时间戳和前缀

你想想看,日常开发中,我们最常用的就是log.Printlnlog.Printf这些。但你知道它底层是怎么工作的吗?

标准库的Logger结构体,核心字段就这几个:

type Logger struct {
    mu     sync.Mutex
    prefix string
    flag   int
    out    io.Writer
    buf    []byte
}

mu是互斥锁,保证并发安全。prefix就是日志前缀。flag控制输出格式,比如要不要打印时间、文件名。out是输出目标,默认是os.Stderrbuf是个缓冲区,用来拼接日志内容。

嗯,这里要注意: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、输出到文件、多目标输出,这些技巧掌握了,日志这块基本就稳了。

当然,如果你需要更高级的功能——比如日志级别、结构化日志、异步写入——那就得考虑logruszap这些第三方库了。不过那是后面章节的内容,咱们一步步来。

嗯,这一章就到这。下一章我会聊聊日志级别和格式化输出,到时候再给你分享一些实战经验。