今天把项目中的日志部分完成了,内容还是比较多的,所以从写代码到看各种函数花了十几个小时。那就还是老样子,先总结一下最重要的部分——Zap库的基本使用方法。

日志系统

在任何一个投入使用的项目中,都需要一个好的日志系统(关于什么是日志,可以看之前的总结,更建议看总结中的参考)。在 Go 语言中,我们有很多能够投入使用的日志库,比如logzap等。对于一个好的日志记录器来说,我们需要它能够实现一下功能:

  • 能够将事件记录到文件中,而不是应用程序控制台。
  • 日志切割-能够根据文件大小、时间或间隔等来切割日志文件。
  • 支持不同的日志级别。例如INFO,DEBUG,ERROR等。
  • 能够打印基本信息,如调用文件/函数名和行号,日志时间等。

默认的Go Logger

在学习Uber-go的zap包之前,还是先学习一下Go语言提供的基本日志功能。由于之前写项目的时候在日志系统设计方面并没有很在意,所以根本就没记住什么东西,还是重新学一下吧。

如何使用

实现一个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
package main

import (
"log"
"net/http"
"os"
)

// 设置日志记录器
func SetupLogger() {
logFileLocation, _ := os.OpenFile("test.log", os.O_CREATE|os.O_APPEND|os.O_RDWR, 0744)
log.SetOutput(logFileLocation)
}

// 使用日志记录器
func simpleHttpGet(url string) {
resp, err := http.Get(url)
if err != nil {
log.Printf("Error fetching url %s : %s", url, err.Error())
} else {
log.Printf("Status Code for %s : %s", url, resp.Status)
resp.Body.Close()
}
}

// 运行
func main() {
SetupLogger()
simpleHttpGet("www.baidu.com")
simpleHttpGet("http://www.baidu.com")
}

当我们执行上述代码后,会有一个 test.log 文件被创建,文件中的内容为:

1
2
2024/05/25 18:03:00 Error fetching url www.baidu.com : Get "www.baidu.com": unsupported protocol scheme ""
2024/05/25 18:03:00 Status Code for http://www.baidu.com : 200 OK

优势与劣势

优势

它最大的优点是使用非常简单。我们可以设置任何io.Writer作为日志记录输出并向其发送要写入的日志。

劣势

  • 仅限基本的日志级别
    • 只有一个Print选项。不支持INFO/DEBUG等多个级别。
  • 对于错误日志,它有 FatalPanic
    • Fatal日志通过调用os.Exit(1)来结束程序
    • Panic日志在写入日志消息之后抛出一个panic
    • 但是它缺少一个ERROR日志级别,这个级别可以在不抛出panic或退出程序的情况下记录错误
  • 缺乏日志格式化的能力——例如记录调用者的函数名和行号,格式化日期和时间格式。等等。
  • 不提供日志切割的能力。

Uber-go Zap

由于上面提到的 Logger 库的劣势并不能帮助我们设计一个良好的日志记录器,所以在本项目中选择了另一个功能更丰富等库——Zap。它同时提供了结构化日志记录和 printf 风格的日志记录。

那这时候就有同学会有疑问了,提供了更多的功能会不会导致在完成设定的任务时变得更慢呢?

一个优秀的工具就是实现了既要又要,根据Uber-go Zap的文档,它的性能比类似的结构化日志包更好——也比标准库更快。 以下是Zap发布的基准测试信息

记录一条消息和10个字段:

Package Time Time % to zap Objects Allocated
⚡️ zap 862 ns/op +0% 5 allocs/op
⚡️ zap (sugared) 1250 ns/op +45% 11 allocs/op
zerolog 4021 ns/op +366% 76 allocs/op
go-kit 4542 ns/op +427% 105 allocs/op
apex/log 26785 ns/op +3007% 115 allocs/op
logrus 29501 ns/op +3322% 125 allocs/op
log15 29906 ns/op +3369% 122 allocs/op

记录一个静态字符串,没有任何上下文或printf风格的模板:

Package Time Time % to zap Objects Allocated
⚡️ zap 118 ns/op +0% 0 allocs/op
⚡️ zap (sugared) 191 ns/op +62% 2 allocs/op
zerolog 93 ns/op -21% 0 allocs/op
go-kit 280 ns/op +137% 11 allocs/op
standard library 499 ns/op +323% 2 allocs/op
apex/log 1990 ns/op +1586% 10 allocs/op
logrus 3129 ns/op +2552% 24 allocs/op
log15 3887 ns/op +3194% 23 allocs/op

Zap的特性

  • 高性能:zap 对日志输出进行了多项优化以提高它的性能
  • 日志分级:有 Debug,Info,Warn,Error,DPanic,Panic,Fatal 等
  • 日志记录结构化:日志内容记录是结构化的,比如 json 格式输出
  • 自定义格式:用户可以自定义输出的日志格式
  • 自定义公共字段:用户可以自定义公共字段,大家输出的日志内容就共同拥有了这些字段
  • 调试:可以打印文件名、函数名、行号、日志时间等,便于调试程序
  • 自定义调用栈级别:可以根据日志级别输出它的调用栈信息
  • Namespace:日志命名空间。定义命名空间后,所有日志内容就在这个命名空间下。命名空间相当于一个文件夹
  • 支持 hook 操作

基本使用

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
package main

import (
"time"

"go.uber.org/zap"
)

func main() {
logger := zap.NewExample()
defer logger.Sync()

url := "http://example.org/api"
logger.Info("failed to fetch URL",
zap.String("url", url),
zap.Int("attempt", 3),
zap.Duration("backoff", time.Second),
)

sugar := logger.Sugar()
sugar.Infow("failed to fetch URL",
"url", url,
"attempt", 3,
"backoff", time.Second,
)
sugar.Infof("Failed to fetch URL: %s", url)
}

zap库的使用与其他的日志库非常相似。先创建一个logger,然后调用各个级别的方法记录日志(Debug/Info/Error/Warn)。

zap提供了几个快速创建logger的方法

  • zap.NewExample()
  • `zap.NewDevelopment()
  • zap.NewProduction()
  • 还有高度定制化的创建方法zap.New()

创建前 3 个logger时,zap会使用一些预定义的设置,它们的使用场景也有所不同。Example适合用在测试代码中,Development在开发环境中使用,Production用在生成环境。

zap底层 API 可以设置缓存,所以一般使用defer logger.Sync()将缓存同步到文件中。


Zap提供了两种类型的日志记录器—Sugared LoggerLogger

Logger

  • 通过调用zap.NewProduction()/zap.NewDevelopment()或者zap.Example()创建一个Logger。
  • 上面的每一个函数都将创建一个logger。唯一的区别在于它将记录的信息不同。例如production logger默认记录调用函数信息、日期和时间等。
  • 通过Logger调用Info/Error等。
  • 默认情况下日志都会打印到应用程序的console界面。
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
var logger *zap.Logger

func main() {
InitLogger()
defer logger.Sync()
simpleHttpGet("www.google.com")
simpleHttpGet("http://www.google.com")
}

func InitLogger() {
logger, _ = zap.NewProduction()
}

func simpleHttpGet(url string) {
resp, err := http.Get(url)
if err != nil {
logger.Error(
"Error fetching url..",
zap.String("url", url),
zap.Error(err))
} else {
logger.Info("Success..",
zap.String("statusCode", resp.Status),
zap.String("url", url))
resp.Body.Close()
}
}

在上面的代码中,我们首先创建了一个Logger,然后使用Info/ Error等Logger方法记录消息。

Sugared Logger

现在让我们使用Sugared Logger来实现相同的功能。

  • 大部分的实现基本都相同。
  • 惟一的区别是,我们通过调用主logger的. Sugar()方法来获取一个SugaredLogger
  • 然后使用SugaredLoggerprintf格式记录语句

下面是修改过后使用SugaredLogger代替Logger的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var sugarLogger *zap.SugaredLogger

func main() {
InitLogger()
defer sugarLogger.Sync()
simpleHttpGet("www.google.com")
simpleHttpGet("http://www.google.com")
}

func InitLogger() {
logger, _ := zap.NewProduction()
sugarLogger = logger.Sugar()
}

func simpleHttpGet(url string) {
sugarLogger.Debugf("Trying to hit GET request for %s", url)
resp, err := http.Get(url)
if err != nil {
sugarLogger.Errorf("Error fetching URL %s : Error = %s", url, err)
} else {
sugarLogger.Infof("Success! statusCode = %s for URL %s", resp.Status, url)
resp.Body.Close()
}
}

为什么会有两种?

由于fmt.Printf之类的方法大量使用interface{}和反射,会有不少性能损失,并且增加了内存分配的频次。zap为了提高性能、减少内存分配次数,没有使用反射,而且默认的Logger只支持强类型的、结构化的日志。必须使用zap提供的方法记录字段。zap为 Go 语言中所有的基本类型和其他常见类型都提供了方法。这些方法的名称也比较好记忆,zap.TypeTypebool/int/uint/float64/complex64/time.Time/time.Duration/error等)就表示该类型的字段,zap.Typepp结尾表示该类型指针的字段,zap.Typess结尾表示该类型切片的字段。如:

  • zap.Bool(key string, val bool) Fieldbool字段
  • zap.Boolp(key string, val *bool) Fieldbool指针字段;
  • zap.Bools(key string, val []bool) Fieldbool切片字段。

当然也有一些特殊类型的字段:

  • zap.Any(key string, value interface{}) Field:任意类型的字段;
  • zap.Binary(key string, val []byte) Field:二进制串的字段。

当然,每个字段都用方法包一层用起来比较繁琐。zap也提供了便捷的方法SugarLogger,可以使用printf格式符的方式。调用logger.Sugar()即可创建SugaredLoggerSugaredLogger的使用比Logger简单,只是性能比Logger低 50% 左右,可以用在非热点函数中。调用SugarLoggerf结尾的方法与fmt.Printf没什么区别,如例子中的Infof。同时SugarLogger还支持以w结尾的方法,这种方式不需要先创建字段对象,直接将字段名和值依次放在参数中即可。

记录层级关系

前面记录的日志都是一层结构,没有嵌套的层级。我们可以使用 zap.Namespace(key string) Field 构建一个命名空间,后续的Filed都记录在此命名空间中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
logger := zap.NewExample()
defer logger.Sync()

logger.Info("tracked some metrics",
zap.Namespace("metrics"),
zap.Int("counter", 1),
)

logger2 := logger.With(
zap.Namespace("metrics"),
zap.Int("counter", 1),
)
logger2.Info("tracked some metrics")
}

输出结果:

1
2
{"level":"info","msg":"tracked some metrics","metrics":{"counter":1}}
{"level":"info","msg":"tracked some metrices","metrics":{"counter":1}}

上面我们演示了两种Namespace的用法,一种是直接作为字段传入Debug/Info等方法,一种是调用With()创建一个新的Logger,新的Logger记录日志时总是带上预设的字段。

定制Logger

调用NexExample()/NewDevelopment()/NewProduction()这 3 个方法,zap使用默认的配置。我们也可以手动调整,配置结构如下:

1
2
3
4
5
6
7
8
9
// src/go.uber.org/zap/config.go
type Config struct {
Level AtomicLevel `json:"level" yaml:"level"`
Encoding string `json:"encoding" yaml:"encoding"`
EncoderConfig zapcore.EncoderConfig `json:"encoderConfig" yaml:"encoderConfig"`
OutputPaths []string `json:"outputPaths" yaml:"outputPaths"`
ErrorOutputPaths []string `json:"errorOutputPaths" yaml:"errorOutputPaths"`
InitialFields map[string]interface{} `json:"initialFields" yaml:"initialFields"`
}
  • Level:日志级别;
  • Encoding:输出的日志格式,默认为 JSON;
  • OutputPaths:可以配置多个输出路径,路径可以是文件路径和stdout(标准输出);
  • ErrorOutputPaths:错误输出路径,也可以是多个;
  • InitialFields:每条日志中都会输出这些值。

其中EncoderConfig为编码配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/go.uber.org/zap/zapcore/encoder.go
type EncoderConfig struct {
MessageKey string `json:"messageKey" yaml:"messageKey"`
LevelKey string `json:"levelKey" yaml:"levelKey"`
TimeKey string `json:"timeKey" yaml:"timeKey"`
NameKey string `json:"nameKey" yaml:"nameKey"`
CallerKey string `json:"callerKey" yaml:"callerKey"`
StacktraceKey string `json:"stacktraceKey" yaml:"stacktraceKey"`
LineEnding string `json:"lineEnding" yaml:"lineEnding"`
EncodeLevel LevelEncoder `json:"levelEncoder" yaml:"levelEncoder"`
EncodeTime TimeEncoder `json:"timeEncoder" yaml:"timeEncoder"`
EncodeDuration DurationEncoder `json:"durationEncoder" yaml:"durationEncoder"`
EncodeCaller CallerEncoder `json:"callerEncoder" yaml:"callerEncoder"`
EncodeName NameEncoder `json:"nameEncoder" yaml:"nameEncoder"`
}
  • MessageKey:日志中信息的键名,默认为msg
  • LevelKey:日志中级别的键名,默认为level
  • EncodeLevel:日志中级别的格式,默认为小写,如debug/info

将日志写入文件而不是终端

在实际运行的项目中,将日志写入终端显然不是一个好的想法,即不方便查看,又不能够长时间存储。所以,将日志单独写入到一个文件夹中,是一个非常好的设计。这时,我们就要用到zap.New()方法来定制创建logger。

1
func New(core zapcore.Core, options ...Option) *Logger

zapcore.Core需要三个配置——EncoderWriteSyncerLogLevel

  1. Encoder:编码器(如何写入日志)。
  2. WriterSyncer :指定日志将写到哪里去。
  3. Log Level:哪种级别的日志将被写入。

在本项目的实际开发中,对以上三个配置都做出了相应的设计。根据日期以及日志的等级实现了相应的日志分割功能,将日志输出的时间格式也进行了更加简洁的设置。

全局Logger

为了方便使用,zap提供了两个全局的Logger,一个是*zap.Logger,可调用zap.L()获得;另一个是*zap.SugaredLogger,可调用zap.S()获得。需要注意的是,全局的Logger默认并不会记录日志!它是一个无实际效果的Logger。看源码:

1
2
3
4
5
6
// go.uber.org/zap/global.go
var (
_globalMu sync.RWMutex
_globalL = NewNop()
_globalS = _globalL.Sugar()
)

我们可以使用ReplaceGlobals(logger *Logger) func()logger设置为全局的Logger,该函数返回一个无参函数,用于恢复全局Logger设置:

1
2
3
4
5
6
7
8
9
10
func main() {
zap.L().Info("global Logger before")
zap.S().Info("global SugaredLogger before")

logger := zap.NewExample()
defer logger.Sync()

zap.ReplaceGlobals(logger)
zap.L().Info("global Logger after")
zap.S().Info("global SugaredLogger after")

输出:

1
2
{"level":"info","msg":"global Logger after"}
{"level":"info","msg":"global SugaredLogger after"}

可以看到在调用ReplaceGlobals之前记录的日志并没有输出。

总结

以上就是在本项目中已经使用的有关 Zap 库的内容,更加详细和底层的东西还是去看大佬的博客和实际源码吧,我写的博客也只是为了强化一下记忆。

呜呜呜……,暑期实习还是没有一点眉目,不知道该怎么办了,真是不想背八股文啊!!!昨天面了边无际的面试,感觉这才应该是面试该有的情况,就不要问那些要靠死记硬背的八股文了,日常工作又能用到多少,谁会在写代码的时候需要考虑三次握手怎么握呀。不过不问八股好像也没什么可问的了。这就是绝大多数本科生的悲哀之处吧,上了四年学,什么都没学到。

不过边无际的面试面的好像也没有多好,不过这是我三个月以来说过最流畅的自我介绍了,感觉还是得深入的学习一下Redis和Docker的内容了。

参考