5个用于Go的结构化日志包

857 阅读13分钟

从表面上看,日志似乎是一个非常简单的任务,只需要你向控制台或文件写一条信息。但是,当你遵循日志的最佳实践时,你必须考虑日志级别,结构化你的日志,日志到不同的位置,为你的日志添加适量的上下文,等等。所有这些细节加在一起,使日志成为一项复杂的任务。

结构化日志背后的想法是让你的日志条目有一个一致的格式,可以很容易地处理,通常是JSON,允许你以各种方式过滤日志条目。例如,你可以搜索包含特定用户ID或错误信息的日志,或者你可以过滤掉与某个服务有关的条目。当你的日志被结构化后,也会很容易从中得出相关的指标,如计费信息。

在这篇文章中,我们将检查和比较五个软件包,它们使Go中的结构化日志变得轻而易举。让我们开始吧!

1.Zap

Zap是一个流行的Go结构化日志库。由Uber开发,Zap承诺比其他可比的日志包有更高的性能,甚至是标准库中的log 包。

Zap提供了两个独立的记录器,Logger ,用于对性能要求很高的情况,而SugaredLogger ,优先考虑人机工程学和灵活性,同时还提供了一个快速的速度。

在下面的例子中,我们使用zap.SugaredLogger 结构的一个实例来记录程序执行时的信息,产生一个结构化的JSON输出,其中包含日志级别信息、时间戳、文件名、行数和日志信息。

package main

import (
    "log"

    "go.uber.org/zap"
)

func main() {
    logger, err := zap.NewProduction()
    if err != nil {
        log.Fatal(err)
    }

    sugar := logger.Sugar()

    sugar.Info("Hello from zap logger")
}

// Output:
// {"level":"info","ts":1639847245.7665887,"caller":"go-logging/main.go:21","msg":"Hello from zap logger"}

通过修改编码器配置或从头开始创建自己的配置,你可以定制你想在日志中出现的确切字段。例如,你可以通过设置以下配置选项,将ts 字段改为timestamp ,并使用更加人性化的日期格式。

func main() {
    loggerConfig := zap.NewProductionConfig()
    loggerConfig.EncoderConfig.TimeKey = "timestamp"
    loggerConfig.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(time.RFC3339)

    logger, err := loggerConfig.Build()
    if err != nil {
        log.Fatal(err)
    }

    sugar := logger.Sugar()

    sugar.Info("Hello from zap logger")
}

// Output:
// {"level":"info","timestamp":"2021-12-18T18:21:34+01:00","caller":"go-logging/main.go:23","msg":"Hello from zap logger"}

如果你需要为你的日志添加额外的结构化上下文,你可以使用任何以w 结尾的SugaredLogger 方法,如InfowErrorwFatalw ,等等。SugaredLogger 类型还提供了通过其printf-风格的方法来记录模板化信息的能力,包括Infof,Errorf, 和Fatalf

sugar.Infow("Hello from zap logger",
  "tag", "hello_zap",
  "service", "logger",
)

// Output:
// {"level":"info","timestamp":"2021-12-18T18:50:25+01:00","caller":"go-logging/main.go:23","msg":"Hello from zap logger","tag":"hello_zap","service":"logger"}

当在你的应用程序的性能敏感部分进行日志记录时,你可以在任何时候通过调用DeSugar()SugaredLogger 来切换到标准的、更快的Logger API。然而,在这样做之后,你只能使用显式键入的字段向你的日志添加额外的结构化上下文,如下所示。

l := sugar.Desugar()

l.Info("Hello from zap logger",
  zap.String("tag", "hello_zap"),
  zap.Int("count", 10),
)

2.Zerolog

Zerolog是一个专门用于结构化JSON日志的库。Zerolog被设计为使用更简单的API来优先考虑性能;默认情况下,提供了一个全局日志器,你可以使用它来进行简单的日志记录。要访问这个日志器,请导入log 子包,如下所示。

package main

import (
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

func main() {
    zerolog.SetGlobalLevel(zerolog.InfoLevel)

    log.Error().Msg("Error message")
    log.Warn().Msg("Warning message")
    log.Info().Msg("Info message")
    log.Debug().Msg("Debug message")
    log.Trace().Msg("Trace message")
}

// Output:
// {"level":"error","time":"2021-12-19T17:38:12+01:00","message":"Error message"}
// {"level":"warn","time":"2021-12-19T17:38:12+01:00","message":"Warning message"}
// {"level":"info","time":"2021-12-19T17:38:12+01:00","message":"Info message"}

Zerolog允许七个日志级别,从trace ,最不严重,到panic ,最严重。你可以使用SetGlobalLevel() 方法来为全局日志器设置你喜欢的日志级别。在上面的例子中,日志级别被设置为info ,所以只有级别大于或等于infolog 事件将被写入。

Zerolog也支持上下文日志。通过对代表log 事件的zerolog.Event 类型的方法,Zerolog可以很容易地在每个JSON日志中添加额外的字段。

Event 的一个实例是通过Logger ,如Error() ,然后由Msg()Msgf() 最终确定的水平方法之一创建。在下面的例子中,我们用这个过程来给一个log 事件添加上下文。

log.Info().Str("tag", "a tag").Int("count", 123456).Msg("info message")

// Output:
// {"level":"info","tag":"a tag","count":123456,"time":"2021-12-20T09:01:33+01:00","message":"info message"}

记录错误也可以通过一个Event 上的特殊Err() 方法来进行,如果错误不是nil ,它就会在日志消息中添加一个error 字段。如果你想把这个字段的名称改为除error 以外的其他名称,请按如下方法设置zerolog.ErrorFieldName 属性。

err := fmt.Errorf("An error occurred")

log.Error().Err(err).Int("count", 123456).Msg("error message")

// Output:
// {"level":"error","error":"An error occurred","count":123456,"time":"2021-12-20T09:07:08+01:00","message":"error message"}

你可以查看文档,了解更多关于在错误日志中添加堆栈轨迹的信息。

除了可以通过log 子包访问的全局记录器外,你还可以用自定义设置创建其他记录器实例。这些日志器可以基于全局日志器或通过zerolog.New() 创建的另一个日志器。

在下面的例子中,我们将把服务的名称添加到通过childLogger 创建的每个log 事件中,这将有助于在日志聚合服务中过滤来自特定应用程序的log 事件。

chidLogger := log.With().Str("service", "foo").Logger()

chidLogger.Info().Msg("An info message")

// Output:
// {"level":"info","service":"foo","time":"2021-12-20T13:45:03+01:00","message":"An info message"}

3.Logrus

Logrus通过一个与标准库logger兼容的API为Go应用程序提供结构化日志。如果你已经在使用stdliblog,但你需要结构化日志来扩展你的日志进程,那么切换到Logrus很容易。只需将logrus 包别名为log ,如下面的代码所示。

package main

import (
  log "github.com/sirupsen/logrus"
)

func main() {
  log.WithFields(log.Fields{
    "tag": "a tag",
  }).Info("An info message")
}

// Output:
// INFO[0000] An info message                               tag="a tag"

与 Zap 和 Zerolog 不同,Logrus 默认不输出 JSON,但你可以通过SetFormatter() 方法轻松改变。你也可以将输出从默认的标准错误改为任何io.Writer ,如os.File 。你也可以改变默认的严重程度,其范围从tracepanic

func main() {
    log.SetFormatter(&log.JSONFormatter{})
    log.SetOutput(os.Stdout)
    log.SetLevel(log.InfoLevel)

    log.WithFields(log.Fields{
        "tag": "a tag",
    }).Info("An info message")
}

// Output: {"level":"info","msg":"An info message","tag":"a tag","time":"2021-12-20T14:07:43+01:00"}

标准文本和JSON格式化器支持几个选项,你可以随心所欲地配置。你也可以利用支持的第三方格式化器之一,如果它们更适合你的需求。

Logrus使用WithFields() 方法支持上下文日志,如前面的代码片断所示。如果你想在日志语句之间重复使用字段,你可以将WithFields() 的返回值保存在一个变量中。随后通过该变量进行的日志调用将输出这些字段。

childLogger := log.WithFields(log.Fields{
  "service": "foo-service",
})

childLogger.Info("An info message")
childLogger.Warn("A warning message")

// Output:
// {"level":"info","msg":"An info message","service":"foo-service","time":"2021-12-20T14:18:08+01:00"}
// {"level":"warning","msg":"A warning message","service":"foo-service","time":"2021-12-20T14:18:08+01:00"}

虽然Logrus在功能上与本列表中的其他选项相比具有竞争力,但在性能上却落后了。在撰写本文时,Logrus目前正处于维护模式,所以它可能不是新项目的最佳选择。然而,它肯定是一个我将继续关注的工具。

4. apex/log

apex/log是一个用于 Go 应用程序的结构化日志包,其灵感来自 Logrus。作者 TJ Holowaychuk 创建了这个包,以简化 Logrus 的 API,并为常见的使用情况提供更多的处理程序。一些默认的处理程序包括text,json,cli,kinesis,graylog, 和elastic search 。要查看默认处理程序的整个列表,你可以浏览处理程序目录,你可以通过满足日志处理程序界面来创建自定义处理程序。

下面的例子演示了 apex/log 的基本功能。我们将使用内置的 JSON 处理程序,它写到标准输出,可以是任何io.Writer 。 apex/log 使用WithFields() 方法来给日志条目添加上下文。你还可以通过Logger 类型设置一个自定义的日志器,允许你配置处理程序和日志级别。

package main

import (
    "os"

    "github.com/apex/log"
    "github.com/apex/log/handlers/json"
)

func main() {
    log.SetHandler(json.New(os.Stdout))

    entry := log.WithFields(log.Fields{
        "service":  "image-service",
        "type":     "image/png",
        "filename": "porsche-carrera.png",
    })

    entry.Info("upload complete")
    entry.Error("upload failed")
}

// Output:
// {"fields":{"filename":"porsche-carrera.png","service":"image-service","type":"image/png"},"level":"info","timestamp":"2022-01-01T11:48:40.8220231+01:00","message":"upload complete"}
// {"fields":{"filename":"porsche-carrera.png","service":"image-service","type":"image/png"},"level":"error","timestamp":"2022-01-01T11:48:40.8223257+01:00","message":"upload failed"}

apex/log 包的设计考虑到了日志的集中化。你可以从多个服务中原封不动地调集和取消调集 JSON 日志条目,而不必因为字段名的不同而单独处理每个日志条目。

apex/log 通过将上下文字段放在fields 属性中,而不是像 Logrus 那样在 JSON 对象的根层折叠它们,来促进这一行动。这一简单的变化使得在生产者一方和消费者一方无缝利用相同的处理程序成为可能。

package main

import (
    "os"

    "github.com/apex/log"
    "github.com/apex/log/handlers/cli"
)

func main() {
    logger := log.Logger{
        Handler: cli.New(os.Stdout),
        Level:   1, // info
    }

    entry := logger.WithFields(log.Fields{
        "service":  "image-service",
        "type":     "image/png",
        "filename": "porsche-carrera.png",
    })

    entry.Debug("uploading...")
    entry.Info("upload complete")
    entry.Error("upload failed")
}

// Output:
// • upload complete           filename=porsche-carrera.png service=image-service type=image/png
// ⨯ upload failed             filename=porsche-carrera.png service=image-service type=image/png

5.5.Log15

Log15的目标是产生人类和机器都能轻松阅读的日志,使其易于遵循最佳实践。Log15软件包使用一个简化的API,迫使你只记录键/值对,其中键必须是字符串,而值可以是你想要的任何类型。它还将输出格式默认为logfmt,但这可以很容易地改变为JSON。

package main

import (
    log "github.com/inconshreveable/log15"
)

func main() {
    srvlog := log.New("service", "image-service")

    srvlog.Info("Image upload was successful", "name", "mercedes-benz.png", "size", 102382)
}

// Output:
// INFO[01-01|13:18:24] Image upload was successful              service=image-service name=mercedes-benz.png size=102382

当创建一个新的记录器时,你可以添加上下文字段,这些字段将被包含在记录器产生的每个日志条目中。所提供的日志级别方法,如Info()Error() ,也提供了一种简单的方法,在强制性的第一个参数(即日志信息)之后添加上下文信息。要改变用于写日志的处理程序,请在Logger ,调用SetHandler() 方法。

Log15提供的处理程序是可组合的,所以你可以把它们组合起来,创建一个适合你的应用的日志结构。例如,除了将所有条目记录到标准输出外,你还可以将错误和更高层次的内容记录到JSON格式的文件中。

package main

import (
    log "github.com/inconshreveable/log15"
)

func main() {
    srvlog := log.New("service", "image-service")

    handler := log.MultiHandler(
        log.LvlFilterHandler(log.LvlError, log.Must.FileHandler("image-service.json", log.JsonFormat())),
        log.CallerFileHandler(log.StdoutHandler),
    )

    srvlog.SetHandler(handler)

    srvlog.Info("Image upload was successful")
    srvlog.Error("Image upload failed")
}

// Output:
// INFO[01-01|13:49:29] Image upload was successful              service=image-service caller=main.go:17
// EROR[01-01|13:49:29] Image upload failed                      service=image-service caller=main.go:18

MultiHandler() 方法被用来将每个日志条目分派给所有注册的处理程序。

在我们的例子中,LvlFilterHandler() 将严重程度为error 或更高的JSON格式的条目写入文件。CallerFileHandler 在日志输出中添加一个caller 字段,其中包含调用函数的行号和文件。CallerFileHandler 包裹了StdoutHandler ,以便条目在修改后被打印到标准输出。

除了CallerFileHandler() ,还提供了CallerFuncHandler()CallerStackHandler() 方法,分别用于在每个日志输出中添加调用函数名称和堆栈跟踪。

如果你需要一个任何默认处理程序都没有提供的函数,你也可以通过实现处理程序接口来创建你自己的处理程序。

性能比较

使用Zap资源库中的基准测试套件,观察到以下结果

记录一条消息和十个字段。

.tg {border-collapse:collapse;border-spacing:0;}
.tg td{border-color:black;border-style:solid;border-width:1px;font-family:Arial, sans-serif;font-size:14px;
overflow:hidden; padding:10px 5px;word-break:normal;}
.tg th{border-color:black;border-style:solid;border-width:1px;font-family:Arial, sans-serif;font-size:14px;
font-weight:normal;overflow:hidden;padding:10px 5px;word-break:normal;}
.tg .tg-0lax{text-align:left; vertical-align:top}

图书馆时间分配的字节数分配的对象
Zerolog767 ns/op552 B/op6个分配数/次
:zap: zap848 ns/op704 B/op2次分配/操作
:zap: zap (sugared)1363 ns/op1610 B/op20次分配/次
ログルス5661 ns/op6092 B/op78次分配/次
apex/log15332 ns/op3832 B/op65次分配/次
15号日志20657 ns/op5632 B/op93 allocs/op

用已经有十个字段上下文的记录器来记录一条消息。

图书馆时间分配的字节数分配的对象
Zerolog52 ns/op0 B/op0个分配数/次
:zap: zap283 ns/op0 B/op0次分配/操作
:Zap: zap (sugared)337 ns/op80 B/op2次分配/次
ログルス4309 ns/op4564 B/op63次分配/次
apex/log13456 ns/op2898 B/op51次分配/操作
15号日志14179 ns/op2642 B/op44 allocs/op

记录一个静态字符串,没有任何上下文或printf-style templating。

时间分配的字节数分配的对象
Zerolog50 ns/op0 B/op0个分配数/次
:zap: zap236 ns/op0 B/op0次分配/操作
标准库453 ns/op80 B/op2次分配/操作
:zap: zap (sugared)337 ns/op80 B/op2次分配/次
ログルス1244 ns/op1505 B/op27次分配/次
顶点/日志2751 ns/op584 B/op11次分配/次
15号日志5181 ns/op1592 B/op26次分配/操作

正如你所看到的,Zerolog和Zap是在撰写本文时性能最好的两个解决方案。为了验证这些结果,你应该在你的机器上用每个库的最新版本运行基准测试套件。

总结

在这篇文章中,我们研究了在 Go 应用程序中实现结构化日志方法的五个库,包括 Zap、Zerolog、Logrus、 apex/log 和 Log15。每个库都提供了诸如JSON日志、日志级别、向多个位置记录日志的能力等功能,使它们成为适合任何项目的日志解决方案。

如果性能是一个决定性的因素,那么你选择Zerolog或不加糖的Zap就不会错。否则,我建议为你的项目选择具有最佳API的库。谢谢你的阅读,并祝你编码愉快

The post5 structured logging packages for Goappeared first onLogRocket Blog.