03 基础 go 包开发 - 日志包

1. 日志记录

记录日志通常涉及到以下几个方面:

  • 日志记录方式
  • 日志记录规范
  • 日志保存方式

1.1 日志记录方式

在 Go 项目开发中,通过日志包来记录日志。所以,在项目开发之前,需要准备一个易用、满足需求的 Go 日志包。准备日志包的方式有以下三种:

  1. 使用开源日志包:使用开源的日志包,例如 log、glog、logrus、zap 等。Docker、 ilium、Tyk 等项目使用了 logrus,etcd 则使用了 log 和 zap;
  2. 定制化开源日志包:基于开源日志包封装一个满足特定需求的日志包。例如,Kubernetes 使用的 klog 是基于 glog 开发的。有些项目封装的日志包还会兼容多种类别的 Logger;
  3. 自研日志包:根据需求,从零开发一个日志包。

目前已有许多开源日志包,社区比较受欢迎的开源日志包有 logrus、zap、zerolog、apex/log、log15 等。其中最受欢迎的两个日志包是 logrus 和 zap。k8s 使用的时 klog 进行记录。

一般项目中基于 logrus 或者 zap 两个包进行使用基本足够:

  • logrus 功能强大、使用简单,不仅实现了日志包的基本功能,还有很多高级特性,适合一些大型项目,尤其是需要结构化日志记录的项目。因为 logrus 封装了很多能力性能一般
  • zap 提供了很强大的日志功能,性能高,内存分配次数少,适合对日志性能要求很高的项目。另外,zap 包中的子包 zapcore,提供了很多底层的日志接口,适合用来做二次封装

logrus、 zap、 klog 对比:

特性对比 logrus (1) zap (2) klog (3)
日志级别 支持 Debug、Info、Warn、Error、Fatal、Panic 支持 Debug、Info、Warn、Error、DPanic、Panic、Fatal 支持 Info、Warning、Error、Fatal、Panic
结构化日志 支持,通过 Fields 添加结构化字段 支持,性能优化,可直接将复杂类型作为字段 支持,但不如 logrus 和 zap 灵活
日志格式 默认文本格式,可自定义为 JSON 提供 Console 和 JSON 编码器,可灵活配置 默认文本格式,可自定义为 JSON
日志输出 支持控制台、文件等 支持控制台、文件、网络等 支持控制台、文件
调用堆栈 支持,可输出堆栈信息 支持,可在特定级别输出堆栈 支持,可在日志中输出堆栈信息
插件支持 支持,可通过插件扩展功能 支持,可通过 Hooks 机制扩展 不支持
性能对比 logrus (3) zap (1) klog (2)
性能表现 性能一般,适合中小规模项目 性能极高,适合对性能要求极高的项目 性能较好,但不如 zap
内存分配 内存分配较多,性能瓶颈 使用 sync.Pool,内存分配少 内存分配适中
易用性 logrus (1) zap (3) klog (2)
学习成本 较低,适合新手 较高,功能丰富 较低,基于 glog 封装
使用复杂度 使用简单,适合快速开发 功能强大但配置复杂 使用简单,适合 Kubernetes
适用场景 logrus zap klog
适用项目 中小型项目、对结构化日志有需求的项目 高性能要求、大规模分布式系统 Kubernetes 生态
二次封装 不太适合,功能封装较多 非常适合,底层接口丰富 不适合,主要用于 Kubernetes

综上:

  • logrus:功能强大且灵活,适合中小规模项目,尤其是对结构化日志有需求的场景。
  • zap:性能卓越,适合对性能要求极高的项目,尤其是分布式系统。
  • klog:适合 Kubernetes 生态,使用简单,但功能相对有限。

如果是 Kubernetes 生态,应该选用 klog,平时开发直接选用 zap 即可。

定制化开源日志包以及自研日志包大概都接触不上,所以暂时不赘述。

1.2 日志记录规范

miniblog 也制定了相应的日志规范,具体规范内容见 docs/devel/zh-CN/conversions/logging.md。该日志规范可以在后续的开发过程中根据需求不断更新和迭代。

在 miniblog 的日志规范中,有以下两点规范需要注意:

  1. 错误日志应在最初发生错误的位置打印。这样做一方面可以避免上层代码缺失关键的日志信息(因为上层代码可能无法获取错误发生处的详细信息),另一方面可以减少日志漏打的情况(距离错误发生位置越远,越容易忽略错误的存在,从而导致日志未被打印);
  2. 当调用第三方报函数或放发报错时,需要在错误处打印日志,例如:
1
2
3
if err := os.Chdir("/root"); err != nil {
log.Errorf("change dir failed: %v", err)
}

对于嵌套的 Error,可在 Error 产生的最初位置打印 Error 日志,上层如果不需要添加必要的信息,可以直接返回下层的 Error。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
"flag"
"fmt"
"github.com/golang/glog"
)

func main() {
flag.Parse()
defer glog.Flush()
if err := loadConfig(); err != nil {
glog.Error(err)
}
}

// 正例:直接返回错误
func loadConfig() error {
return decodeConfig() // 直接返回
}

// 正例:如果需要基于函数返回的错误,封装更多的信息,可以封装返回的 err。否则,建议直接返回 err
func decodeConfig() error {
if err := readConfig(); err != nil {
// 添加必要的信息,用户名称

return fmt.Errorf("could not decode configuration data for user %s: %v", "colin", err)
}
return nil
}

func readConfig() error {
glog.Errorf("read: end of input.")
return fmt.Errorf("read: end of input")
}

在最初产生错误的位置打印日志,可以很方便地追踪到错误产生的根源,并且错误日志只打印一次,可以减少重复的日志打印,减少排障时重复日志干扰,也可以提高代码的简洁度。当然,在开发中也可以根据需要对错误补充一些有用的信息,以记录错误产生的其他影响。

1.3 日志保存方式

我们可以将日志保存到任意需要的位置,常见的保存位置包括以下几种:

  1. **标准输出:**通常用于开发和测试阶段,主要目的是便于调试和查看;
  2. **日志文件:**这是生产环境中最常见的日志保存方式。保存的日志通常会被 Filebeat、Fluentd 等日志采集组件收集,并存储到 Elasticsearch 等系统中;
  3. **消息中间件:**例如 Kafka。日志包会调用 API 接口将日志保存到 Kafka 中。为了提高性能,通常会使用异步任务队列异步保存。然而,在这种情况下,需要开发异步上报逻辑,且服务重启时可能导致日志丢失,因此这种方式较少被采用。

当前比较受欢迎的日志包(如 zap、logrus 等)都支持将日志同时保存到多个位置。例如,miniblog 项目的日志包底层封装了 zap,zap 支持同时将日志输出到标准输出和日志文件中。

如果应用采用容器化部署,建议优先将日志输出到标准输出。容器平台通常具备采集容器日志的能力,采集日志时可以选择从标准输出采集或从容器内的日志文件中采集。如果选择从日志文件采集,则需要配置日志采集路径;而如果选择从标准输出采集,则无需额外配置,可以直接复用容器平台现有的能力,从而实现日志记录与日志采集的完全解耦。在 Kubernetes 最新的日志设计方案中,也建议应用直接将日志输出到标准输出。

2. miniblog 日志包

2.1 miniblog 日志包开发

这里不做过多说明,详情参考文章:10 | 基础 Go 包开发:日志包设计和实现

2.2 miniblog 日志包使用

2.2.1 开箱即用前需要知道的知识点

日志级别和记录方法:

  1. **日志级别:**在记录日志时,按严重性由低到高通常包括 Debug、Info、Warn、Error、Panic、Fatal 级别。Warn 级别在有些日志包中也叫 Warning 级别;
  2. **日志记录方法:**每个日志级别,根据记录方式,又包括非格式化记录、格式化记录和结构化记录三种方式。形如 Info(msg string) 的方法为非格式化记录方式。形如 Infof(format string, args …any) 的方法为格式化记录方式。形如 Infow(msg string, kvs …any) 的方法为结构化记录方式。Infow 方法名中的 w 代表“with”,即“带有”额外的上下文信息。这些方法与没有 w 的方法(如 Debug,Info 等)相比,允许你在日志消息后面附加额外的键值对(key-value),从而提供更详细的上下文信息。

miniblog 在设计时,为了满足项目不同日志级别的记录需求,实现了 Debug、Info、Warn、Error、Panic、Fatal 级别的记录方法。

在 Go 项目开发中,建议的日志记录方式为结构化记录方式(带有 w 后缀的,例如 Infow**)**。

格式化记录方式可以通过结构化记录方式来替代,例如:log.Infof(“Failed to create user: %s”, username) 可替换为 log.Infow(“Failed to create user”, “username”, username)。

所以,miniblog 项目为了方便日志记录,降低开发者理解日志记录方法的负担,只实现了结构化记录方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Logger 定义了 miniblog 项目的日志接口。
// 该接口包含了项目中支持的日志记录方法,提供对不同日志级别的支持。
type Logger interface {
// Debugw 用于记录调试级别的日志,通常用于开发阶段,包含详细的调试信息。
Debugw(msg string, kvs ...any)

// Infow 用于记录信息级别的日志,表示系统的正常运行状态。
Infow(msg string, kvs ...any)

// Warnw 用于记录警告级别的日志,表示可能存在问题但不影响系统正常运行。
Warnw(msg string, kvs ...any)

// Errorw 用于记录错误级别的日志,表示系统运行中出现的错误,需要开发人员介入处理。
Errorw(msg string, kvs ...any)

// Panicw 用于记录严重错误级别的日志,表示系统无法继续运行,记录日志后会触发 panic。
Panicw(msg string, kvs ...any)

// Fatalw 用于记录致命错误级别的日志,表示系统无法继续运行,记录日志后会直接退出程序。
Fatalw(msg string, kvs ...any)

// Sync 用于刷新日志缓冲区,确保日志被完整写入目标存储。
Sync()
}

将日志包 log 放置在 internal/pkg 目录下的原因在于,日志包封装了一些定制化的逻辑,不适合对外暴露,所以不适合放在 pkg/ 目录下。但是日志包又是项目内的共享包,所以需要放在 internal/pkg 目录下。

通过定义 Logger 接口,可以体现接口即规范的编程哲学。这意味着,通过 Logger 接口可以清晰地表明 zapLogger 需要实现哪些方法,并明确日志调用者应调用哪些方法。在 Go 项目中,通常将日志接口命名为 Logger。

2.3 miniblog 日志包开箱即用

2.3.1 初始化日志包

日志配置,实际开发项目中如有特殊配置项再更新改配置:https://github.com/mmungdong/miniblog/blob/master/internal/pkg/log/options.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Copyright 2024 孔令飞 <[email protected]>. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file. The original repo for
// this file is https://github.com/onexstack/miniblog. The professional
// version of this repository is https://github.com/onexstack/onex.

package log

import (
"go.uber.org/zap/zapcore"
)

// Options 定义了日志配置的选项结构体.
// 通过该结构体,可以自定义日志的输出格式、级别以及其他相关配置.
type Options struct {
// DisableCaller 指定是否禁用 caller 信息.
// 如果设置为 false(默认值),日志中会显示调用日志所在的文件名和行号,例如:"caller":"main.go:42".
DisableCaller bool
// DisableStacktrace 指定是否禁用堆栈信息.
// 如果设置为 false(默认值),在日志级别为 panic 或更高时,会打印堆栈跟踪信息.
DisableStacktrace bool
// Level 指定日志级别.
// 可选值包括:debug、info、warn、error、dpanic、panic、fatal.
// 默认值为 info.
Level string
// Format 指定日志的输出格式.
// 可选值包括:console(控制台格式)和 json(JSON 格式).
// 默认值为 console.
Format string
// OutputPaths 指定日志的输出位置.
// 默认值为标准输出(stdout),也可以指定文件路径或其他输出目标.
OutputPaths []string
}

// NewOptions 创建并返回一个带有默认值的 Options 对象.
// 该方法用于初始化日志配置选项,提供默认的日志级别、格式和输出位置.
func NewOptions() *Options {
return &Options{
// 默认启用 caller 信息
DisableCaller: false,
// 默认启用堆栈信息
DisableStacktrace: false,
// 默认日志级别为 info
Level: zapcore.InfoLevel.String(),
// 默认日志输出格式为 console
Format: "console",
// 默认日志输出位置为标准输出
OutputPaths: []string{"stdout"},
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 日志配置
log:
# 是否开启 caller,如果开启会在日志中显示调用日志所在的文件和行号
disable-caller: false
# 是否禁止在 panic 及以上级别打印堆栈信息
disable-stacktrace: false
# 指定日志级别,可选值:debug, info, warn, error, dpanic, panic, fatal
# 生产环境建议设置为 info
level: debug
# 指定日志显示格式,可选值:console, json
# 生产环境建议设置为 json
format: json
# 指定日志输出位置,多个输出,用 `逗号 + 空格` 分开。stdout:标准输出
output-paths: [/tmp/miniblog.log, stdout]

初始化日志包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import (
...
"github.com/onexstack/miniblog/internal/pkg/log"
...
)
...
// run 是主运行逻辑,负责初始化日志、解析配置、校验选项并启动服务器。
func run(opts *options.ServerOptions) error {
// 如果传入 --version,则打印版本信息并退出
version.PrintAndExitIfRequested()

// 初始化日志
log.Init(logOptions())
defer log.Sync() // 确保日志在退出时被刷新到磁盘
...
}

// logOptions 从 viper 中读取日志配置,构建 *log.Options 并返回.
// 注意:viper.Get<Type>() 中 key 的名字需要使用 . 分割,以跟 YAML 中保持相同的缩进.
func logOptions() *log.Options {
opts := log.NewOptions()
if viper.IsSet("log.disable-caller") {
opts.DisableCaller = viper.GetBool("log.disable-caller")
}
if viper.IsSet("log.disable-stacktrace") {
opts.DisableStacktrace = viper.GetBool("log.disable-stacktrace")
}
if viper.IsSet("log.level") {
opts.Level = viper.GetString("log.level")
}
if viper.IsSet("log.format") {
opts.Format = viper.GetString("log.format")
}
if viper.IsSet("log.output-paths") {
opts.OutputPaths = viper.GetStringSlice("log.output-paths")
}
return opts
}

在 run 函数中,添加了 log.Init(logOptions()) 函数调用,用来在应用运行时,初始化日志实例,并在 miniblog 应用退出时,调用 log.Sync() 将缓存中的日志写入磁盘中。

🤗 总结归纳

  • 整理日志记录打印以及保存方式
  • miniblog 日志包开箱即用说明方式

📎 参考文章


03 基础 go 包开发 - 日志包
https://yangfanbin.cn/代码笔记/03 基础 go 包开发 - 日志包/
作者
Yang Fanbin
发布于
2025年8月8日
许可协议