提示:本节课最终代码为:feature/s08。
Go 应用上线后,经常需要观察一些线上运行指标。应用出故障,也需要快速定位问题,这些都依赖于日志。
所以,在开发完应用框架之后,还需要优先确定应用应该如何记录日志,因为接下来的开发可能随时需要记录日志。本节课,我们会详细介绍如何记录日志。
应用一般是如何记录日志的?
首先,你需要知道应用是如何记录日志的,主要包含以下几点:
使用什么方式记录日志?
如何记录日志?
如何保存日志?
下面,我们分别来看看每个问题的解决方法,并给你一些选择的思路。
使用什么方式记录日志?
我们是通过日志包来记录日志,所以首先要有一个日志包,这里根据我的研发经验,通常有以下 3 种方式来准备一个日志包:
使用开源的日志包,例如:log、glog、logrus、zap 等;例如 moby(docker)、cilium、tyk 等项目使用了 logrus,etcd 使用了 log 和 zap;
基于开源日志包封装一个满足特定需求的日志包,例如:kubernetes 使用的 klog 基于 glog 开发。有的项目封装的日志包,还会兼容多种类别的 Logger;
根据需求,从 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 项目的日志包需求来源、开发步骤,以及我在开发过程中的一些想法。
首先,说一下为什么要定制开发一个新的日志包,我思考有以下几点原因:
为了方便通过日志进行问题排障,需要在每一行日志中,打印一些定制的字段,例如:请求 ID、用户名称,虽然当前很多日志包,例如:logrus、zap 等都支持添加日志字段,但是使用起来并不方便,代码丑陋;
一个项目随着功能的不断迭代,后面可能会存在特殊的日志需求,所以开发一个定制的日志包,算是为未来做好技术铺垫,留下扩展空间;
logrus、zap 等日志包,具有很多功能,但这些功能并不是在项目开发中都需要的,所以,我考虑定制一个精简的日志包。一个精简的日志包,不仅易使用,而且有限的功能,也能起到规范作用(日志包具有多少日志记录函数,就意味着你的代码就可能有多少种日志记录方式,很难做到规范、统一)。
那么如何定制化开发一个日志包呢?首先要选择一个合适的开源日志包,然后基于这个日志包来封装。这里,我选择了 zap。所以,在定制开发之前,你需要学习如何使用 zap 包,你可参考 zap包介绍 文档进行学习。
定制开发步骤分为以下几步:
创建一个封装了
zap.Logger
的自定义 Logger;编写创建函数,创建
zapLogger
对象;创建
*zap.Logger
对象;实现日志接口。
创建一个封装了 zap.Logger
的自定义 Logger。
新建 internal/pkg/log/log.go
文件,内容如下:
package log
import (
"go.uber.org/zap"
)
// zapLogger 是 Logger 接口的具体实现. 它底层封装了 zap.Logger.
type zapLogger struct {
z *zap.Logger
}
我们将日志包放在 internal/pkg
目录下,是因为 log
包封装了一些定制化的逻辑,不适合对外。
编写创建函数,创建 zapLogger
对象
这里需要注意,一个日志包通常有 2 类 zapLogger
对象:一个全局对象、一个局部对象。全局对象方便我们通过 log.Infow()
这种方式来调用。局部对象方便我们传入不同的参数,来创建一个自定义的 Logger。为了实现这个目标,我们通常需要实现 2 个函数:
NewLogger(opts *Options) *zapLogger
:创建一个自定义的*zapLogger
;Init(opts *Options)
:用来初始化全局的 Logger。
我们可以在 Init
函数中调用 NewLogger
函数来创建一个 *zapLogger
对象,并赋值给一个类型为 *zapLogger
的全局变量中(为什么要创建一个全局变量,原因后面会介绍)。
NewLogger
和 Init
函数中的 Options
参数,是我们开发的日志包的配置,Options
中的配置字段,我们刚开始可以置空,后面根据需要逐个添加。
另外,根据 Go 代码开发最佳实践,建议为 Options
结构体开发一个 NewOptions() *Options
函数,用来创建带有默认值的 *Options
对象,通过创建一个默认的 *Options
对象可以简化代码开发、提高开发效率。
提示:有的代码实现会将
Options
命名为LoggerOptions
,但我一般习惯直接命名为更为简洁的Options
名字,因为我们通过包.结构体名
这种方式,已经能够明确知道Options
是一个 Logger Options。
所以,根据我们的思考,开发完成后的代码如下(internal/pkg/log/log.go
文件):
package log
import (
"sync"
"go.uber.org/zap"
)
// zapLogger 是 Logger 接口的具体实现. 它底层封装了 zap.Logger.
type zapLogger struct {
z *zap.Logger
}
// Options 包含与日志相关的配置项.
type Options struct {
}
var (
mu sync.Mutex
// std 定义了默认的全局 Logger.
std = NewLogger(NewOptions())
)
// Init 使用指定的选项初始化 Logger.
func Init(opts *Options) {
mu.Lock()
defer mu.Unlock()
std = NewLogger(opts)
}
// NewLogger 根据传入的 opts 创建 Logger.
func NewLogger(opts *Options) *zapLogger {
if opts == nil {
opts = NewOptions()
}
z := &zap.Logger{}
logger := &zapLogger{z: z}
return logger
}
// NewOptions 创建一个带有默认参数的 Options 对象.
func NewOptions() *Options {
return &Options{}
}
上述代码中,我们在 NewLogger
函数中,创建了一个空的 *zap.Logger
对象 z
,然后使用 z
创建并返回了 *zapLogger
对象。
创建 *zap.Logger
对象
那么如何创建 *zap.Logger
呢?我们可以参考社区既有的代码实现,并根据需要进行修改。如何找到适合改造的 demo 程序呢?这里分享下我的方法,方法见下。
查找 1, 我会尝试在 zap 官方仓库的 README 文件中和仓库中的 examples
这类目录中查找看是否有创建示例(官方仓库是最可能存放这种示例代码的地方。因为是官方仓库,所代码质量会比较高,所以要优先从官方仓库中找)。但是发现官方仓库中,并无此类代码。
查找 2, 我会尝试在 GitHub 上找,如何查找呢?见下图所示:
在 GitHub 搜索栏,输入
language:go zap demo
以搜索仓库名/仓库描述中同时有zap
和demo
关键字的 Go 代码仓库;按 Most stars 排序;
从上到下,阅读检索出的代码仓库,根据代码仓库名和描述,判断是否是可以参考的 Go 项目,如果是,则进入仓库进行更详细的了解。
如果找到合适的代码则可以参考代码创建 *zap.Logger
。否则,你可以继续查找。
查找 3, 你可以进行更深入的查找,查找使用了 zap 包的代码,根据代码来判断 Go 代码段或者代码段所在的项目是否可以借鉴使用,例如:
在 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。
你可以通过代码中的注释来学习,以下是我想补充的点:
- 通过创建一个默认配置,并根据需要修改指定的字段,是一个好的开发技巧。例如:
// 创建一个默认的 encoder 配置
encoderConfig := zap.NewProductionEncoderConfig()
// 自定义 MessageKey 为 message,message 语义更明确
encoderConfig.MessageKey = "message"
为了编译查找、分类,我们将
Options
的定义和创建单独放在了 options.go 文件中。在 Go 项目开发的过程中,要时刻思考,如何使开发的程序更易读、易维护。例如,为了日志的可读性,我们将
ts
换成了timestamp
,将msg
换成了message
,将1669314079.4161139
格式的时间换成了2022-11-24 23:12:25.479
格式的时间。因为我们封装了 zap 包,所以在调用栈中跳过的调用深度要加
1
:zap.AddCallerSkip(1)
。
实现日志接口
接下来,我们就可以根据我们的需求,来实现具体的日志接口。
首先,我们需要实现一个 Logger
接口,在 internal/pkg/log/log.go
文件添加以下代码:
// Logger 定义了 miniblog 项目的日志接口. 该接口只包含了支持的日志记录方法.
type Logger interface {
Debugw(msg string, keysAndValues ...interface{})
Infow(msg string, keysAndValues ...interface{})
Warnw(msg string, keysAndValues ...interface{})
Errorw(msg string, keysAndValues ...interface{})
Panicw(msg string, keysAndValues ...interface{})
Fatalw(msg string, keysAndValues ...interface{})
Sync()
}
上述代码,定义了一个 Logger
接口。接口中指出了需要实现的日志接口。接下来,我们就来改造 zapLogger
来实现 Logger
接口。改造方法很简单。例如,我们可以通过以下代码实现 Debugw(msg string, keysAndValues ...interface{})
方法:
// Debugw 输出 debug 级别的日志.
func Debugw(msg string, keysAndValues ...interface{}) {
std.z.Sugar().Debugw(msg, keysAndValues...)
}
func (l *zapLogger) Debugw(msg string, keysAndValues ...interface{}) {
l.z.Sugar().Debugw(msg, keysAndValues...)
}
可以看到,func (l *zapLogger) Debugw(msg string, keysAndValues ...interface{})
方法内部直接调用了 *zap.Logger
的 Sugar().Infow()
方法,用来结构化输出一个 debug 级别的日志。
为了方便通过 log.Debugw()
输出日志,我们还定义了一个包级别的函数 Debugw
,Debugw
函数内部通过 *zapLogger
类型的全局变量 std
,来在 debug 级别输出日志。
提示:以上的日志接口实现方法,基本上是标准的实现方法。
同理,我们可以为 zapLogger
实现 Logger
接口指定其他方法。
为了能在编译期确保 zapLogger
实现 Logger
接口,我们可以在 log.go
文件中添加以下变量定义:
// 确保 zapLogger 实现了 Logger 接口. 以下变量赋值,可以使错误在编译期被发现.
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 步:
使用
viper
读取日志配置,并初始化日志包;调用日志接口输出日志。
使用 viper
读取日志配置,并初始化日志包
在 internal/miniblog/helper.go
文件中添加日志配置创建函数:
// logOptions 从 viper 中读取日志配置,构建 `*log.Options` 并返回.
// 注意:`viper.Get<Type>()` 中 key 的名字需要使用 `.` 分割,以跟 YAML 中保持相同的缩进.
func logOptions() *log.Options {
return &log.Options{
DisableCaller: viper.GetBool("log.disable-caller"),
DisableStacktrace: viper.GetBool("log.disable-stacktrace"),
Level: viper.GetString("log.level"),
Format: viper.GetString("log.format"),
OutputPaths: viper.GetStringSlice("log.output-paths"),
}
}
在使用 viper.Get<Type>
方法读取配置的时候,注意缩进要跟配置文件中的缩进保持一致。
在 internal/miniblog/miniblog.go
文件中,添加日志初始化代码:
RunE: func(cmd *cobra.Command, args []string) error {
// 初始化日志
log.Init(logOptions())
defer log.Sync() // Sync 将缓存中的日志刷新到磁盘文件中
return run()
},
这里要注意,在 miniblog 应用退出时,调用 defer log.Sync()
将缓存中的日志写入磁盘中,否则可能会丢失日志。
调用日志接口输出日志
初始化完日志包之后,就可以调用日志包提供的接口输出日志。将 internal/miniblog/miniblog.go
文件 run()
函数中的 fmt.Println
调用替换为以下调用:
// 打印所有的配置项及其值
settings, _ := json.Marshal(viper.AllSettings())
log.Infow(string(settings))
// 打印 db -> username 配置项的值
log.Infow(viper.GetString("db.username"))
return nil
将 internal/miniblog/helper.go
文件中的 fmt.Fprintln
也用日志接口替换掉:
// 读取配置文件。如果指定了配置文件名,则使用指定的配置文件,否则在注册的搜索路径中搜索
if err := viper.ReadInConfig(); err != nil {
log.Errorw("Failed to read viper configuration file", "err", err)
}
// 打印 viper 当前使用的配置文件,方便 Debug.
log.Infow("Using config file", "file", viper.ConfigFileUsed())
测试输出结果
要运行 miniblog
测试日志输出,首先要指定日志配置。在 configs/miniblog.yaml
文件中添加以下日志配置项:
# 日志配置
log:
disable-caller: false
disable-stacktrace: false
level: debug
format: console
output-paths: [/tmp/miniblog.log, stdout]
最后,编译并运行 miniblog 应用:
$ make
$ _output/miniblog -c configs/miniblog.yaml
上述代码的日志输出如下:
$ _output/miniblog -c configs/miniblog.yaml
2022/11/25 02:04:17 maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined
2022-11-25 02:04:17.102 info miniblog/helper.go:66 Using config file {"file": "configs/miniblog.yaml"}
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"]}}
2022-11-25 02:04:17.102 info miniblog/miniblog.go:74 miniblog
根据配置,日志同时也被写入到了 /tmp/miniblog.log
文件中:
$ cat /tmp/miniblog.log
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"]}}
2022-11-25 02:10:43.548 info miniblog/miniblog.go:75 miniblog
开发完成后的完整代码见 feature/s08。
小结
Go 项目开发中,不可避免地需要记录日志。本节课介绍了在企业应用开发中,应该如何记录日志,并给出了建议:建议优先使用开源的日志包。如果开源日志包满足不了需求,可以基于开源日志包来定制自己的日志包,通常使用 zap
包来定制。
本节课中间部分,给出了具体如何定制日志包。并在最后,介绍了如何在 miniblog 项目中使用日志包。