天天看點

zap 自定義日志格式_Go 每日一庫之 zap

zap 自定義日志格式_Go 每日一庫之 zap

簡介

在很早之前的文章中,我們介紹過 Go 标準日志庫

log

和結構化的日志庫

logrus

。在熱點函數中記錄日志對日志庫的執行性能有較高的要求,不能影響正常邏輯的執行時間。

uber

開源的日志庫

zap

,對性能和記憶體配置設定做了極緻的優化。

快速使用

先安裝:

$ go get go.uber.org/zap
           

後使用:

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()

将緩存同步到檔案中。

由于

fmt.Printf

之類的方法大量使用

interface{}

和反射,會有不少性能損失,并且增加了記憶體配置設定的頻次。

zap

為了提高性能、減少記憶體配置設定次數,沒有使用反射,而且預設的

Logger

隻支援強類型的、結構化的日志。必須使用

zap

提供的方法記錄字段。

zap

為 Go 語言中所有的基本類型和其他常見類型都提供了方法。這些方法的名稱也比較好記憶,

zap.Type

Type

bool/int/uint/float64/complex64/time.Time/time.Duration/error

等)就表示該類型的字段,

zap.Typep

p

結尾表示該類型指針的字段,

zap.Types

s

結尾表示該類型切片的字段。如:

  • zap.Bool(key string, val bool) Field

    bool

    字段
  • zap.Boolp(key string, val *bool) Field

    bool

    指針字段;
  • zap.Bools(key string, val []bool) Field

    bool

    切片字段。

當然也有一些特殊類型的字段:

  • zap.Any(key string, value interface{}) Field

    :任意類型的字段;
  • zap.Binary(key string, val []byte) Field

    :二進制串的字段。

當然,每個字段都用方法包一層用起來比較繁瑣。

zap

也提供了便捷的方法

SugarLogger

,可以使用

printf

格式符的方式。調用

logger.Sugar()

即可建立

SugaredLogger

SugaredLogger

的使用比

Logger

簡單,隻是性能比

Logger

低 50% 左右,可以用在非熱點函數中。調用

SugarLogger

f

結尾的方法與

fmt.Printf

沒什麼差別,如例子中的

Infof

。同時

SugarLogger

還支援以

w

結尾的方法,這種方式不需要先建立字段對象,直接将字段名和值依次放在參數中即可,如例子中的

Infow

預設情況下,

Example

輸出的日志為 JSON 格式:

{"level":"info","msg":"failed to fetch URL","url":"http://example.org/api","attempt":3,"backoff":"1s"}
{"level":"info","msg":"failed to fetch URL","url":"http://example.org/api","attempt":3,"backoff":"1s"}
{"level":"info","msg":"Failed to fetch URL: http://example.org/api"}
           

記錄層級關系

前面我們記錄的日志都是一層結構,沒有嵌套的層級。我們可以使用

zap.Namespace(key string) Field

建構一個

命名空間

,後續的

Field

都記錄在此命名空間中:

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")
}
           

輸出:

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

上面我們示範了兩種

Namespace

的用法,一種是直接作為字段傳入

Debug/Info

等方法,一種是調用

With()

建立一個新的

Logger

,新的

Logger

記錄日志時總是帶上預設的字段。

With()

方法實際上是建立了一個新的

Logger

// src/go.uber.org/zap/logger.go
func (log *Logger) With(fields ...Field) *Logger {
  if len(fields) == 0 {
    return log
  }
  l := log.clone()
  l.core = l.core.With(fields)
  return l
}
           

定制

Logger

調用

NexExample()/NewDevelopment()/NewProduction()

這 3 個方法,

zap

使用預設的配置。我們也可以手動調整,配置結構如下:

// 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

為編碼配置:

// 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.Config

Build()

方法即可使用該配置對象建立一個

Logger

func main() {
  rawJSON := []byte(`{
    "level":"debug",
    "encoding":"json",
    "outputPaths": ["stdout", "server.log"],
    "errorOutputPaths": ["stderr"],
    "initialFields":{"name":"dj"},
    "encoderConfig": {
      "messageKey": "message",
      "levelKey": "level",
      "levelEncoder": "lowercase"
    }
  }`)

  var cfg zap.Config
  if err := json.Unmarshal(rawJSON, &cfg); err != nil {
    panic(err)
  }
  logger, err := cfg.Build()
  if err != nil {
    panic(err)
  }
  defer logger.Sync()

  logger.Info("server start work successfully!")
}
           

上面建立一個輸出到标準輸出

stdout

和檔案

server.log

Logger

。觀察輸出:

{"level":"info","message":"server start work successfully!","name":"dj"}
           

使用

NewDevelopment()

建立的

Logger

使用的是如下的配置:

// src/go.uber.org/zap/config.go
func NewDevelopmentConfig() Config {
  return Config{
    Level:            NewAtomicLevelAt(DebugLevel),
    Development:      true,
    Encoding:         "console",
    EncoderConfig:    NewDevelopmentEncoderConfig(),
    OutputPaths:      []string{"stderr"},
    ErrorOutputPaths: []string{"stderr"},
  }
}

func NewDevelopmentEncoderConfig() zapcore.EncoderConfig {
  return zapcore.EncoderConfig{
    // Keys can be anything except the empty string.
    TimeKey:        "T",
    LevelKey:       "L",
    NameKey:        "N",
    CallerKey:      "C",
    MessageKey:     "M",
    StacktraceKey:  "S",
    LineEnding:     zapcore.DefaultLineEnding,
    EncodeLevel:    zapcore.CapitalLevelEncoder,
    EncodeTime:     zapcore.ISO8601TimeEncoder,
    EncodeDuration: zapcore.StringDurationEncoder,
    EncodeCaller:   zapcore.ShortCallerEncoder,
  }
}
           

NewProduction()

的配置可自行檢視。

選項

NewExample()/NewDevelopment()/NewProduction()

這 3 個函數可以傳入若幹類型為

zap.Option

的選項,進而定制

Logger

的行為。又一次見到了

選項模式

!!

zap

提供了豐富的選項供我們選擇。

輸出檔案名和行号

調用

zap.AddCaller()

傳回的選項設定輸出檔案名和行号。但是有一個前提,必須設定配置對象

Config

中的

CallerKey

字段。也是以

NewExample()

不能輸出這個資訊(它的

Config

沒有設定

CallerKey

)。

func main() {
  logger, _ := zap.NewProduction(zap.AddCaller())
  defer logger.Sync()

  logger.Info("hello world")
}
           

輸出:

{"level":"info","ts":1587740198.9508286,"caller":"caller/main.go:9","msg":"hello world"}
           

Info()

方法在

main.go

的第 9 行被調用。

AddCaller()

zap.WithCaller(true)

等價。

有時我們稍微封裝了一下記錄日志的方法,但是我們希望輸出的檔案名和行号是調用封裝函數的位置。這時可以使用

zap.AddCallerSkip(skip int)

向上跳 1 層:

func Output(msg string, fields ...zap.Field) {
  zap.L().Info(msg, fields...)
}

func main() {
  logger, _ := zap.NewProduction(zap.AddCaller(), zap.AddCallerSkip(1))
  defer logger.Sync()

  zap.ReplaceGlobals(logger)

  Output("hello world")
}
           

輸出:

{"level":"info","ts":1587740501.5592482,"caller":"skip/main.go:15","msg":"hello world"}
           

輸出在

main

函數中調用

Output()

的位置。如果不指定

zap.AddCallerSkip(1)

,将輸出

"caller":"skip/main.go:6"

,這是在

Output()

函數中調用

zap.Info()

的位置。因為這個

Output()

函數可能在很多地方被調用,是以這個位置參考意義并不大。試試看!

輸出調用堆棧

有時候在某個函數進行中遇到了異常情況,因為這個函數可能在很多地方被調用。如果我們能輸出此次調用的堆棧,那麼分析起來就會很友善。我們可以使用

zap.AddStackTrace(lvl zapcore.LevelEnabler)

達成這個目的。該函數指定

lvl

和之上的級别都需要輸出調用堆棧:

func f1() {
  f2("hello world")
}

func f2(msg string, fields ...zap.Field) {
  zap.L().Warn(msg, fields...)
}

func main() {
  logger, _ := zap.NewProduction(zap.AddStacktrace(zapcore.WarnLevel))
  defer logger.Sync()

  zap.ReplaceGlobals(logger)

  f1()
}
           

zapcore.WarnLevel

傳入

AddStacktrace()

,之後

Warn()/Error()

等級别的日志會輸出堆棧,

Debug()/Info()

這些級别不會。運作結果:

{"level":"warn","ts":1587740883.4965692,"caller":"stacktrace/main.go:13","msg":"hello world","stacktrace":"main.f2ntd:/code/golang/src/github.com/darjun/go-daily-lib/zap/option/stacktrace/main.go:13nmain.f1ntd:/code/golang/src/github.com/darjun/go-daily-lib/zap/option/stacktrace/main.go:9nmain.mainntd:/code/golang/src/github.com/darjun/go-daily-lib/zap/option/stacktrace/main.go:22nruntime.mainntC:/Go/src/runtime/proc.go:203"}
           

stacktrace

單獨拉出來:

main.f2
d:/code/golang/src/github.com/darjun/go-daily-lib/zap/option/stacktrace/main.go:13
  main.f1
  d:/code/golang/src/github.com/darjun/go-daily-lib/zap/option/stacktrace/main.go:9
    main.main
    d:/code/golang/src/github.com/darjun/go-daily-lib/zap/option/stacktrace/main.go:22
      runtime.main
      C:/Go/src/runtime/proc.go:203
           

很清楚地看到調用路徑。

全局

Logger

為了友善使用,

zap

提供了兩個全局的

Logger

,一個是

*zap.Logger

,可調用

zap.L()

獲得;另一個是

*zap.SugaredLogger

,可調用

zap.S()

獲得。需要注意的是,全局的

Logger

預設并不會記錄日志!它是一個無實際效果的

Logger

。看源碼:

// go.uber.org/zap/global.go
var (
  _globalMu sync.RWMutex
  _globalL  = NewNop()
  _globalS  = _globalL.Sugar()
)
           

我們可以使用

ReplaceGlobals(logger *Logger) func()

logger

設定為全局的

Logger

,該函數傳回一個無參函數,用于恢複全局

Logger

設定:

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")
}
           

輸出:

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

可以看到在調用

ReplaceGlobals

之前記錄的日志并沒有輸出。

預設日志字段

如果每條日志都要記錄一些共用的字段,那麼使用

zap.Fields(fs ...Field)

建立的選項。例如在伺服器日志中記錄可能都需要記錄

serverId

serverName

func main() {
  logger := zap.NewExample(zap.Fields(
    zap.Int("serverId", 90),
    zap.String("serverName", "awesome web"),
  ))

  logger.Info("hello world")
}
           

輸出:

{"level":"info","msg":"hello world","serverId":90,"serverName":"awesome web"}
           

與标準日志庫搭配使用

如果項目一開始使用的是标準日志庫

log

,後面想轉為

zap

。這時不必修改每一個檔案。我們可以調用

zap.NewStdLog(l *Logger) *log.Logger

傳回一個标準的

log.Logger

,内部實際上寫入的還是我們之前建立的

zap.Logger

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

  std := zap.NewStdLog(logger)
  std.Print("standard logger wrapper")
}
           

輸出:

{"level":"info","msg":"standard logger wrapper"}
           

很友善不是嗎?我們還可以使用

NewStdLogAt(l *logger, level zapcore.Level) (*log.Logger, error)

讓标準接口以

level

級别寫入内部的

*zap.Logger

如果我們隻是想在一段代碼内使用标準日志庫

log

,其它地方還是使用

zap.Logger

。可以調用

RedirectStdLog(l *Logger) func()

。它會傳回一個無參函數恢複設定:

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

  undo := zap.RedirectStdLog(logger)
  log.Print("redirected standard library")
  undo()

  log.Print("restored standard library")
}
           

看前後輸出變化:

{"level":"info","msg":"redirected standard library"}
2020/04/24 22:13:58 restored standard library
           

當然

RedirectStdLog

也有一個對應的

RedirectStdLogAt

以特定的級别調用内部的

*zap.Logger

方法。

總結

zap

用在日志性能和記憶體配置設定比較關鍵的地方。本文僅介紹了

zap

庫的基本使用,子包

zapcore

中有更底層的接口,可以定制豐富多樣的

Logger

大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上送出 issue

參考

  1. zap GitHub:https://github.com/jordan-wright/zap
  2. Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib

我的部落格:https://darjun.github.io

歡迎關注我的微信公衆号【GoUpUp】,共同學習,一起進步~

zap 自定義日志格式_Go 每日一庫之 zap