提示:本节课最终代码为:feature/s08

Go 应用上线后,经常需要观察一些线上运行指标。应用出故障,也需要快速定位问题,这些都依赖于日志。

所以,在开发完应用框架之后,还需要优先确定应用应该如何记录日志,因为接下来的开发可能随时需要记录日志。本节课,我们会详细介绍如何记录日志。

应用一般是如何记录日志的?

首先,你需要知道应用是如何记录日志的,主要包含以下几点:

  • 使用什么方式记录日志?

  • 如何记录日志?

  • 如何保存日志?

下面,我们分别来看看每个问题的解决方法,并给你一些选择的思路。

使用什么方式记录日志?

我们是通过日志包来记录日志,所以首先要有一个日志包,这里根据我的研发经验,通常有以下 3 种方式来准备一个日志包:

  1. 使用开源的日志包,例如:log、glog、logrus、zap 等;例如 moby(docker)、cilium、tyk 等项目使用了 logrus,etcd 使用了 log 和 zap;

  2. 基于开源日志包封装一个满足特定需求的日志包,例如:kubernetes 使用的 klog 基于 glog 开发。有的项目封装的日志包,还会兼容多种类别的 Logger;

  3. 根据需求,从 0 开发一个日志包,例如:github.com/go-kratos/k…github.com/go-admin-te… 等。

那么该如何选择使用哪种方法呢?下面我就来详细介绍下。

使用开源的日志包

如果你对记录日志没有特殊需求,并且已经有开源的优秀包可供选择,那我觉得你没必要封装或者自研一个日志包,直接使用原生的开源日志包即可。因为这样既能享受开源日志包的所有功能,又能跟随开源日志包进行功能升级、迭代。当然,最大的优势还是能够极大提高开发效率,减少日志包维护的工作量。

当然,如果有以下情景,你可以选择封装或者自研一个新的日志包:

  • 有定制化的需求,并且没有开源日志包能满足这些定制化的需求;

  • 研发能力比较强,可以开发出在性能或者功能上比当前开源日志包更强的日志包;

  • 场景独特,记录日志,需要匹配特殊场景。

目前有很多开源的日志包,这里按 GitHub Star 数排序,罗列如下:

(统计时间:2022-11-24)。

日志包 Star数 描述
logrus 21693 logrus 功能强大、性能高效、高度灵活,还提供了自定义插件的功能
zap 17476 zap 是 uber 开源的日志包,以高性能著称,很多公司的日志包都是基于 zap 改造而来。zap 除了具有日志基本的功能之外,还具有很多强大的特性,例如:性能非常好、支持预设日志字段、支持结构化记录、支持设置调用堆栈等
zerolog 7256 zerolog 只专注于记录 JSON 格式的日志,号称 0 内存分配,性能更加极致
glog 3258 glog 是 Google 推出的日志包,跟标准库 log 包一样,是一个轻量级的日志包,使用简单方便,但要比标准库 log 包提供更多的功能
go-logging 1756 又一个好用的日志包,可以自定义日志输出的格式和颜色,支持并设置多个日志记录后端
seelog 1620 Seelog 是一个功能强大且易于学习的日志记录框架,它提供了灵活的调度、过滤和格式化日志消息的功能
apex/log 1271 实现了一个简单的结构化日志记录 API,其灵感来自 logrus,在设计时考虑到了集中化
log15 1071 结构化、可组合日志记录的日志包

这么多的开源日志包,该如何选择呢?这里我提供给你一个思路。

  • logrus: logrus 功能强大、使用简单,不仅实现了日志包的基本功能,还有很多高级特性,适合一些大型项目,尤其是需要结构化日志记录的项目。因为 logrus 封装了很多能力,性能一般。

  • zap: zap 提供了很强大的日志功能,性能高,内存分配次数少,适合对日志性能要求很高的项目。另外,zap 包中的子包 zapcore,提供了很多底层的日志接口,适合用来做二次封装。例如,miniblog 项目的日志包就是基于 zap 和 zapcore 进行封装而来。zap 使用起来也比较简单。

  • zerolog: zap 和 zerolog 的性能都很好, 但是 zap 更加易用。

  • 标准库 log 包: 标准库 log 包不支持日志级别、日志分割、日志格式等功能,所以在大型项目中很少直接使用,通常用于一些短小的程序,比如:用于生成 JWT Token 的 main.go 文件中。标准库日志包也很适合一些简短的代码,用于快速调试和验证。

  • glog: glog 实现了日志包的基本功能,对于一些对日志功能要求不多的小型项目非常适合。

为了方便大家学习这些优秀的开源日志包,我找了一些相对优质的文档,可供你参考学习。这里建议你优先学习下 zap、logrus 的使用方式,其他包可以不用学习。

在做企业级 Go 应用开发时,如果要选择一个开源日志库,基本上会在 logrus、zap、zerolog 三者之间选择。这里我的建议如下:

  • 如果对性能要求不高,追求使用简单,可以选择 logrus;

  • 如果对性能要求较高,并且追求相对方便的使用方式,可以选择 zap;

  • 如果对性能要求非常高,相对于使用便捷度,更关注性能,可以选择 zerolog。

miniblog 项目选择了 zap,因为 zap 性能高、使用便捷。实际上,zap 也可以作为你今后 Go 项目开发中的首选日志包。

基于开源日志包定制化

为什么要基于开源日志包开发定制化的日志包呢?当然是开源日志包满足不了需求,例如:kubernetes 团队因为 glog 存在缺陷并且不维护,基于 glog 开发了 klog;还有一些团队,需要在日志中打印固定的字段,例如:用户名、请求 ID 等,需要改造开源的日志包;也有一些团队,会根据日志内容,执行一些回调逻辑,开源的日志包不太能很好地满足需求,也会定制化开发。

总之,在企业应用开发过程中,可能会出于各种各样的需求,需要定制化开发一个新的日志包,这是一个正常的需求,但在决定开发一个新的日志包时,最好想明白一个问题:是否真的需要开发一个新的日志包?开源的日志包真的满足不了需求吗?

自研日志包

如果前两种方式,满足不了你的日志记录需求,你可以选择自研一个日志包。自研日志包需要一定的研发能力,并且开发周期比较长。这里不太建议,不过多介绍。

如何记录日志?

记录日志,其实就是遵循我们制定的日志规范,调用日志包提供的方法记录日志。miniblog 也制定了日志规范,具体可参考:日志规范。日志规范,可在后面的开发过程中根据需求不断更新迭代。

如何保存日志?

我们可以将日志保存在需要的任何地方,通常有以下几个地方:

  • 标准输出: 开发测试时经常用到,主要是方便。

  • 日志文件: 生产环境部署时最常见的方式,保存的日志可能后续会被 Filebeat、Fluentd 这类日志采集组件采集,并保存到 Elasticsearch;

  • 消息中间件: 例如 kafka。日志包会直接通过调用 API 接口的形式,将日志保存在 kafka 中,为了提高性能,通常有个异步任务队列,异步保存。这种情况下,异步上报逻辑需要开发,重启服务日志可能会丢失,所以这种方式很少采用。

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

如果你的应用采用容器化部署,其实更建议将日志输出到标准输出。容器平台一般都具有采集容器日志的能力。采集日志时,可以选择从标准输出采集或者容器中的日志文件采集,如果是从日志文件进行采集,通常需要配置日志采集路径,但如果是从标准输出采集,则不用。所以,如果将日志直接输出到标准输出,则可以不加配置直接复用容器平台已有的能力,做到记录日志和采集日志完全解耦。

在 Kubernetes 最新的日志设计方案中,也是建议应用直接将日志输出到标准输出中。

miniblog 日志包定制开发

这里,我就来详细介绍下 miniblog 项目的日志包需求来源、开发步骤,以及我在开发过程中的一些想法。

首先,说一下为什么要定制开发一个新的日志包,我思考有以下几点原因:

  1. 为了方便通过日志进行问题排障,需要在每一行日志中,打印一些定制的字段,例如:请求 ID、用户名称,虽然当前很多日志包,例如:logrus、zap 等都支持添加日志字段,但是使用起来并不方便,代码丑陋;

  2. 一个项目随着功能的不断迭代,后面可能会存在特殊的日志需求,所以开发一个定制的日志包,算是为未来做好技术铺垫,留下扩展空间;

  3. logrus、zap 等日志包,具有很多功能,但这些功能并不是在项目开发中都需要的,所以,我考虑定制一个精简的日志包。一个精简的日志包,不仅易使用,而且有限的功能,也能起到规范作用(日志包具有多少日志记录函数,就意味着你的代码就可能有多少种日志记录方式,很难做到规范、统一)。

那么如何定制化开发一个日志包呢?首先要选择一个合适的开源日志包,然后基于这个日志包来封装。这里,我选择了 zap。所以,在定制开发之前,你需要学习如何使用 zap 包,你可参考 zap包介绍 文档进行学习。

定制开发步骤分为以下几步:

  1. 创建一个封装了 zap.Logger 的自定义 Logger;

  2. 编写创建函数,创建 zapLogger 对象;

  3. 创建 *zap.Logger 对象;

  4. 实现日志接口。

创建一个封装了 zap.Logger 的自定义 Logger。

新建 internal/pkg/log/log.go文件,内容如下:

  1. package log
  2. import (
  3. "go.uber.org/zap"
  4. )
  5. // zapLogger 是 Logger 接口的具体实现. 它底层封装了 zap.Logger.
  6. type zapLogger struct {
  7. z *zap.Logger
  8. }

我们将日志包放在 internal/pkg 目录下,是因为 log 包封装了一些定制化的逻辑,不适合对外。

编写创建函数,创建 zapLogger 对象

这里需要注意,一个日志包通常有 2 类 zapLogger 对象:一个全局对象、一个局部对象。全局对象方便我们通过 log.Infow() 这种方式来调用。局部对象方便我们传入不同的参数,来创建一个自定义的 Logger。为了实现这个目标,我们通常需要实现 2 个函数:

  • NewLogger(opts *Options) *zapLogger:创建一个自定义的 *zapLogger

  • Init(opts *Options):用来初始化全局的 Logger。

我们可以在 Init 函数中调用 NewLogger 函数来创建一个 *zapLogger 对象,并赋值给一个类型为 *zapLogger 的全局变量中(为什么要创建一个全局变量,原因后面会介绍)。

NewLoggerInit 函数中的 Options 参数,是我们开发的日志包的配置,Options 中的配置字段,我们刚开始可以置空,后面根据需要逐个添加。

另外,根据 Go 代码开发最佳实践,建议为 Options 结构体开发一个 NewOptions() *Options 函数,用来创建带有默认值的 *Options 对象,通过创建一个默认的 *Options 对象可以简化代码开发、提高开发效率。

提示:有的代码实现会将 Options 命名为 LoggerOptions,但我一般习惯直接命名为更为简洁的 Options 名字,因为我们通过 包.结构体名 这种方式,已经能够明确知道 Options 是一个 Logger Options。

所以,根据我们的思考,开发完成后的代码如下(internal/pkg/log/log.go文件):

  1. package log
  2. import (
  3. "sync"
  4. "go.uber.org/zap"
  5. )
  6. // zapLogger 是 Logger 接口的具体实现. 它底层封装了 zap.Logger.
  7. type zapLogger struct {
  8. z *zap.Logger
  9. }
  10. // Options 包含与日志相关的配置项.
  11. type Options struct {
  12. }
  13. var (
  14. mu sync.Mutex
  15. // std 定义了默认的全局 Logger.
  16. std = NewLogger(NewOptions())
  17. )
  18. // Init 使用指定的选项初始化 Logger.
  19. func Init(opts *Options) {
  20. mu.Lock()
  21. defer mu.Unlock()
  22. std = NewLogger(opts)
  23. }
  24. // NewLogger 根据传入的 opts 创建 Logger.
  25. func NewLogger(opts *Options) *zapLogger {
  26. if opts == nil {
  27. opts = NewOptions()
  28. }
  29. z := &zap.Logger{}
  30. logger := &zapLogger{z: z}
  31. return logger
  32. }
  33. // NewOptions 创建一个带有默认参数的 Options 对象.
  34. func NewOptions() *Options {
  35. return &Options{}
  36. }

上述代码中,我们在 NewLogger 函数中,创建了一个空的 *zap.Logger 对象 z,然后使用 z 创建并返回了 *zapLogger 对象。

创建 *zap.Logger 对象

那么如何创建 *zap.Logger 呢?我们可以参考社区既有的代码实现,并根据需要进行修改。如何找到适合改造的 demo 程序呢?这里分享下我的方法,方法见下。

查找 1, 我会尝试在 zap 官方仓库的 README 文件中和仓库中的 examples 这类目录中查找看是否有创建示例(官方仓库是最可能存放这种示例代码的地方。因为是官方仓库,所代码质量会比较高,所以要优先从官方仓库中找)。但是发现官方仓库中,并无此类代码。

查找 2, 我会尝试在 GitHub 上找,如何查找呢?见下图所示:

7.基础功能:如何设计日志包,并记录日志? - 图1

  • 在 GitHub 搜索栏,输入 language:go zap demo 以搜索仓库名/仓库描述中同时有 zapdemo 关键字的 Go 代码仓库;

  • Most stars 排序;

  • 从上到下,阅读检索出的代码仓库,根据代码仓库名和描述,判断是否是可以参考的 Go 项目,如果是,则进入仓库进行更详细的了解。

如果找到合适的代码则可以参考代码创建 *zap.Logger。否则,你可以继续查找。

查找 3, 你可以进行更深入的查找,查找使用了 zap 包的代码,根据代码来判断 Go 代码段或者代码段所在的项目是否可以借鉴使用,例如:

7.基础功能:如何设计日志包,并记录日志? - 图2

  • 在 GitHub 搜索栏,输入 language:go ``go.uber.org/zap/zapcore 以搜索可能封装了 zap 包的代码段。

  • 根据代码段内容判断该代码段是否是可能的参考对象。如果是,则打开文件阅读源码,如果觉得源码可以利用,可以再进一步了解其所在的代码仓库,也许你会发现这个代码仓库就是一个完整的实现。

通过以上 3 步查找,最终我发现 go_leaderboard 仓库下的 logging.go 代码可以参考。因为我觉得 InitLogging() 函数通过配置一个 zap.Config,进而创建一个 *zap.Logger 的方式,能够满足我需要的定制化创建 *zap.Logger 的诉求。

开发完成后的代码见:feature/s06/internal/pkg/log

你可以通过代码中的注释来学习,以下是我想补充的点:

  • 通过创建一个默认配置,并根据需要修改指定的字段,是一个好的开发技巧。例如:
  1. // 创建一个默认的 encoder 配置
  2. encoderConfig := zap.NewProductionEncoderConfig()
  3. // 自定义 MessageKey 为 message,message 语义更明确
  4. encoderConfig.MessageKey = "message"
  • 为了编译查找、分类,我们将 Options 的定义和创建单独放在了 options.go 文件中。

  • 在 Go 项目开发的过程中,要时刻思考,如何使开发的程序更易读、易维护。例如,为了日志的可读性,我们将 ts 换成了 timestamp,将 msg 换成了 message,将 1669314079.4161139 格式的时间换成了 2022-11-24 23:12:25.479 格式的时间。

  • 因为我们封装了 zap 包,所以在调用栈中跳过的调用深度要加 1zap.AddCallerSkip(1)

实现日志接口

接下来,我们就可以根据我们的需求,来实现具体的日志接口。

首先,我们需要实现一个 Logger 接口,在 internal/pkg/log/log.go 文件添加以下代码:

  1. // Logger 定义了 miniblog 项目的日志接口. 该接口只包含了支持的日志记录方法.
  2. type Logger interface {
  3. Debugw(msg string, keysAndValues ...interface{})
  4. Infow(msg string, keysAndValues ...interface{})
  5. Warnw(msg string, keysAndValues ...interface{})
  6. Errorw(msg string, keysAndValues ...interface{})
  7. Panicw(msg string, keysAndValues ...interface{})
  8. Fatalw(msg string, keysAndValues ...interface{})
  9. Sync()
  10. }

上述代码,定义了一个 Logger 接口。接口中指出了需要实现的日志接口。接下来,我们就来改造 zapLogger 来实现 Logger 接口。改造方法很简单。例如,我们可以通过以下代码实现 Debugw(msg string, keysAndValues ...interface{}) 方法:

  1. // Debugw 输出 debug 级别的日志.
  2. func Debugw(msg string, keysAndValues ...interface{}) {
  3. std.z.Sugar().Debugw(msg, keysAndValues...)
  4. }
  5. func (l *zapLogger) Debugw(msg string, keysAndValues ...interface{}) {
  6. l.z.Sugar().Debugw(msg, keysAndValues...)
  7. }

可以看到,func (l *zapLogger) Debugw(msg string, keysAndValues ...interface{}) 方法内部直接调用了 *zap.LoggerSugar().Infow() 方法,用来结构化输出一个 debug 级别的日志。

为了方便通过 log.Debugw() 输出日志,我们还定义了一个包级别的函数 DebugwDebugw 函数内部通过 *zapLogger 类型的全局变量 std,来在 debug 级别输出日志。

提示:以上的日志接口实现方法,基本上是标准的实现方法。

同理,我们可以为 zapLogger 实现 Logger 接口指定其他方法。

为了能在编译期确保 zapLogger 实现 Logger 接口,我们可以在 log.go 文件中添加以下变量定义:

  1. // 确保 zapLogger 实现了 Logger 接口. 以下变量赋值,可以使错误在编译期被发现.
  2. var _ Logger = &zapLogger{}

通过上述的变量定义,如果 *zapLogger 没有实现 Logger, 就会在代码编译时报编译错误。上面的编程技巧,在 Go 项目开发中有被大量使用到。

通过定义 Logger 接口,我们可以实现 接口即规范 的编程哲学。也就是说,通过 Logger 接口可以明确告诉你 zapLogger 需要实现什么方法、日志调用者、应该调用哪些方法。

实现后的代码见 feature/s07/internal/pkg/log/log.go

miniblog 应用初始化并调用日志接口

通过上面的学习、开发,我们已经成功开发了自定义日志包: github.com/``marmotedu``/miniblog/internal/pkg/log。接下来,我们就需要在应用中使用日志包,具体分为以下 2 步:

  1. 使用 viper 读取日志配置,并初始化日志包;

  2. 调用日志接口输出日志。

使用 viper 读取日志配置,并初始化日志包

internal/miniblog/helper.go 文件中添加日志配置创建函数:

  1. // logOptions 从 viper 中读取日志配置,构建 `*log.Options` 并返回.
  2. // 注意:`viper.Get<Type>()` 中 key 的名字需要使用 `.` 分割,以跟 YAML 中保持相同的缩进.
  3. func logOptions() *log.Options {
  4. return &log.Options{
  5. DisableCaller: viper.GetBool("log.disable-caller"),
  6. DisableStacktrace: viper.GetBool("log.disable-stacktrace"),
  7. Level: viper.GetString("log.level"),
  8. Format: viper.GetString("log.format"),
  9. OutputPaths: viper.GetStringSlice("log.output-paths"),
  10. }
  11. }

在使用 viper.Get<Type> 方法读取配置的时候,注意缩进要跟配置文件中的缩进保持一致。

internal/miniblog/miniblog.go 文件中,添加日志初始化代码:

  1. RunE: func(cmd *cobra.Command, args []string) error {
  2. // 初始化日志
  3. log.Init(logOptions())
  4. defer log.Sync() // Sync 将缓存中的日志刷新到磁盘文件中
  5. return run()
  6. },

这里要注意,在 miniblog 应用退出时,调用 defer log.Sync() 将缓存中的日志写入磁盘中,否则可能会丢失日志。

调用日志接口输出日志

初始化完日志包之后,就可以调用日志包提供的接口输出日志。将 internal/miniblog/miniblog.go 文件 run() 函数中的 fmt.Println 调用替换为以下调用:

  1. // 打印所有的配置项及其值
  2. settings, _ := json.Marshal(viper.AllSettings())
  3. log.Infow(string(settings))
  4. // 打印 db -> username 配置项的值
  5. log.Infow(viper.GetString("db.username"))
  6. return nil

internal/miniblog/helper.go 文件中的 fmt.Fprintln 也用日志接口替换掉:

  1. // 读取配置文件。如果指定了配置文件名,则使用指定的配置文件,否则在注册的搜索路径中搜索
  2. if err := viper.ReadInConfig(); err != nil {
  3. log.Errorw("Failed to read viper configuration file", "err", err)
  4. }
  5. // 打印 viper 当前使用的配置文件,方便 Debug.
  6. log.Infow("Using config file", "file", viper.ConfigFileUsed())

测试输出结果

要运行 miniblog 测试日志输出,首先要指定日志配置。在 configs/miniblog.yaml 文件中添加以下日志配置项:

  1. # 日志配置
  2. log:
  3. disable-caller: false
  4. disable-stacktrace: false
  5. level: debug
  6. format: console
  7. output-paths: [/tmp/miniblog.log, stdout]

最后,编译并运行 miniblog 应用:

  1. $ make
  2. $ _output/miniblog -c configs/miniblog.yaml

上述代码的日志输出如下:

  1. $ _output/miniblog -c configs/miniblog.yaml
  2. 2022/11/25 02:04:17 maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined
  3. 2022-11-25 02:04:17.102 info miniblog/helper.go:66 Using config file {"file": "configs/miniblog.yaml"}
  4. 2022-11-25 02:04:17.102 info miniblog/miniblog.go:72 {"db":{"database":"miniblog","host":"127.0.0.1","log-level":4,"max-connection-life-time":"10s","max-idle-connections":100,"max-open-connections":100,"password":"miniblog1234","username":"miniblog"},"log":{"disable-caller":false,"disable-stacktrace":false,"format":"console","level":"debug","output-paths":["/tmp/miniblog.log","stdout"]}}
  5. 2022-11-25 02:04:17.102 info miniblog/miniblog.go:74 miniblog

根据配置,日志同时也被写入到了 /tmp/miniblog.log 文件中:

  1. $ cat /tmp/miniblog.log
  2. 2022-11-25 02:10:43.548 info miniblog/miniblog.go:73 {"db":{"database":"miniblog","host":"127.0.0.1","log-level":4,"max-connection-life-time":"10s","max-idle-connections":100,"max-open-connections":100,"password":"miniblog1234","username":"miniblog"},"log":{"disable-caller":false,"disable-stacktrace":false,"format":"console","level":"debug","output-paths":["/tmp/miniblog.log","stdout"]}}
  3. 2022-11-25 02:10:43.548 info miniblog/miniblog.go:75 miniblog

开发完成后的完整代码见 feature/s08

小结

Go 项目开发中,不可避免地需要记录日志。本节课介绍了在企业应用开发中,应该如何记录日志,并给出了建议:建议优先使用开源的日志包。如果开源日志包满足不了需求,可以基于开源日志包来定制自己的日志包,通常使用 zap 包来定制。

本节课中间部分,给出了具体如何定制日志包。并在最后,介绍了如何在 miniblog 项目中使用日志包。