本文是用Go从零实现日志包教程系列的第一篇。
- 实现日志包初始化,代码约 64 行
- 介绍常见的输出级别:Info,Warning,Error 和 Fatal
1 日志包初始化
闭上眼睛,思考日志包最基本的功能,就是需要初始化的配置。日志是标准输出还是输出到文件?如果输出到文件,那么日志文件存在哪里呢?
新建 options.go 文件,我们创建一个结构体类型 Options,提供上面提到的日志配置。
type Options struct {
out io.Writer // 输出路径
isDiscard bool // 输出格式,其实从兼容性的角度考虑,应该使用 int32 类型标识
}
参考目前大部分日志包开源项目,我发现成熟的日志包,选项功能都很多,初始化参数复杂,这会影响调用方使用,所以我选择使用选项设计模式来初始化 options。选项设计模式的实现就是给一个结构体,定义一个函数,给结构体初始化。编辑 options.go 文件。
// 定义一个函数
type OptionFunc func(*Options)
// 给结构体初始化
func initOptions(opts ...OptionFunc) *Options {
options := &Options{}
for _, opt := range opts {
opt(options)
}
if options.out == nil {
options.out = os.Stderr
}
return options
}
// 修改配置
func WithOut(out io.Writer) OptionFunc {
return func(options *Options) {
options.out = out
}
}
初始化日志的选项我们完成了,接下来就是日志包的构造函数,即根据日志配置 Options 创建 Logger,Logger 就是日志记录对象。调用者可以通过调 Logger 的方法打印日志。让我们创建一个新文件 logger.go。
type Logger struct {
mu sync.Mutex
opt *Options
}
func New(opts *Options) *Logger {
return &Logger{
mu: sync.Mutex{},
opt: opts,
}
}
var logger *Logger
func DefaultLogger(opts ...OptionFunc) {
logger = New(initOptions(opts...))
}
在 Logger 中加了一把锁,是为了保证原子输入,保护 Logger 结构体中字段的写入。
提供 DefaultLogger 方法来构造一个默认的日志实例,为什么不直接使用 New 方法构造呢?DefaultLogger 方法不也是调用 New 吗?我们当然可以直接调用 New 来构造,不过我们通过 DefaultLogger 构造,是为了让调用者从方法名知道,通过这个方法能够构造默认的日志实例,不用自己去配置日志选项,未来我们可以提供更多不同配置的日志构造方法,例如:DefaultConsoleLogger 默认输出到控制台的日志实例,DefaultJsonLogger 输出 Json 格式的日志实例等。
现在我们日志的初始化时不完整的,还缺少了日志内容,即我们记录的日志应该有什么东西。 常见的日志内容有:
- 日志级别
- 格式化的时间
- 打印了什么自定义信息
- 哪个文件的哪一行打印的日志
新建文件 entry.go ,在 entry.go 中,将日志打印的内容抽象为结构体 entry,即内容条目,定义如下结构体:
type Entry struct {
Line int // 文件中的哪行
File string // 哪个文件打印的
Body []interface{} // 自定义信息
Time time.Time // 时间
Logger *Logger // logger 配置
Buffer *bytes.Buffer // 写入缓冲区
}
func initEntry(logger *Logger) *Entry {
return &Entry{Logger: logger, Buffer: new(bytes.Buffer)}
}
修改 logger.go 文件,将我们的 Entry 添加到 Logger 日志实例中。
type Logger struct {
mu sync.Mutex
opt *Options
entryPool *sync.Pool
}
func New(opts *Options) *Logger {
logger := &Logger{
mu: sync.Mutex{},
opt: opts,
}
logger.entryPool = &sync.Pool{
New: func() interface{} {
return initEntry(logger)
},
}
return logger
}
entry 条目我们使用对象池进行保存,因为日志的打印是频繁的!使用 *sync.Pool 保存和复用临时对象,减少内存分配,降低 GC 的压力,够极大地提升性能。(假如你还是不太理解为什么使用 *sync.Pool,别着急,我们完成日志打印函数后你就会明白了)。
我们已经完成了日志包的初始化,但是还没写日志打印方法,在写打印方法前,我们先了解一下日志的输出级别。
2 日志输出级别简介
编写打印日志方法前,先了解一下日志级别,是一种对日志数据进行分类的概念。分类后,将做到快速定位,排除了与目的不符的“噪音”。
常见的日志级别有 Fatal > Error > Warning > Info,级别越高,说明对程序的影响越大。下面分别介绍它们。
2.1 Fatal
问题相当严重,程序遇到了致命错误,无法继续执行。例如数据库连接失败。 在 Go 的 log 标准库中,Fatal 日志级别的函数调用后,会调用 os.Exit ,这意味着:
- 其他 goroutine defer 语句不会被执行;
- 各种 buffers 不会被 flush,包括日志的;
- 临时文件或目录不会被移除。
2.2 Error
程序执行出错了,这些错误会影响到程序的处理结果,这是最常用的错误级别(希望它不会是最常见的)。
假如我们现在一个函数 A 将返回 error,那么我们应该在调用该函数 A 的地方打印日志并进行降级处理,提供有损服务,即处理错误,而不是在被调用函数里打印日志。
Go 语言贡献者 Davio 认为,对错误进行降级处理后,应该打印 info 级别的日志,这意味着我处理了错误,或者往上抛并 warp 它。
2.3 Warning
警告,这类日志往往说明程序运行异常,但是又不影响程序继续运行。
在实际开发过程中,往往没人看警告,因为从定义上来讲,它并没有出现什么错误,也许是将来会出问题,按照 Go 语言的设计哲学,所有的警告都是错误,因此我们应该尽量消除它,别忽视它。我们可以结合 CICD 流程,强制 Warning 为 Error。
2.4 Info
Info 级别,用来记录一些供运营分析的数据,没有什么好说的。