天天看點

golang使用logrus生成日志

作者:幹飯人小羽
golang使用logrus生成日志

1. Go中的log包#

1. 基本使用#

log 結構的定義如下:

type Logger struct {
	mu     sync.Mutex // ensures atomic writes; protects the following fields
	prefix string     // prefix to write at beginning of each line
	flag   int        // properties
	out    io.Writer  // destination for output
	buf    []byte     // for accumulating text to write
}
           

可見在結構體中有sync.Mutex類型字段,是以log中所有的操作都是支援并發的。

下面看一下這三種log列印的用法:

package main

import (
	"log"
)


func main() {
	log.Print("我就是一條日志")
	log.Printf("%s,","誰說我是日志了,我是錯誤")
	log.Panic("哈哈,我好痛")
}
           

輸出:

2019/05/23 22:14:36 我就是一條日志
2019/05/23 22:14:36 誰說我是日志了,我是錯誤,
2019/05/23 22:14:36 哈哈,我好痛
panic: 哈哈,我好痛

goroutine 1 [running]:
log.Panic(0xc00007bf78, 0x1, 0x1)
	D:/soft/go/src/log/log.go:333 +0xb3
main.main()
	E:/go_path/src/webDemo/demo.go:12 +0xfd
           

使用非常簡單,可以看到log的預設輸出帶了時間,非常的友善。Panic方法在輸出後調用了Panic方法,是以抛出了異常資訊。上面的示例中沒有示範Fatal方法,你可以試着把log.Fatal()放在程式的第一行,你會發現下面的代碼都不會執行。因為上面說過,它在列印完日志之後會調用os.exit(1)方法,是以系統就退出了。

2. 定制列印參數#

上面說到log列印的時候預設是自帶時間的,那如果除了時間以外,我們還想要别的資訊呢,當然log也是支援的。

SetFlags(flag int)方法提供了設定列印預設資訊的能力,下面的字段是log中自帶的支援的列印類型:

Ldate         = 1 << iota     // the date in the local time zone: 2009/01/23
Ltime                         // the time in the local time zone: 01:23:23
Lmicroseconds                 // microsecond resolution: 01:23:23.123123.  assumes Ltime.
Llongfile                     // full file name and line number: /a/b/c/d.go:23
Lshortfile                    // final file name element and line number: d.go:23. overrides Llongfile
LUTC                          // if Ldate or Ltime is set, use UTC rather than the local time zone
LstdFlags     = Ldate | Ltime // initial values for the standard logger
           

這是log包定義的一些擡頭資訊,有日期、時間、毫秒時間、絕對路徑和行号、檔案名和行号等,在上面都有注釋說明,這裡需要注意的是:如果設定了Lmicroseconds,那麼Ltime就不生效了;設定了Lshortfile, Llongfile也不會生效,大家自己可以測試一下。

LUTC比較特殊,如果我們配置了時間标簽,那麼如果設定了LUTC的話,就會把輸出的日期時間轉為0時區的日期時間顯示。

最後一個LstdFlags表示标準的日志擡頭資訊,也就是預設的,包含日期和具體時間。

使用方法:

func init(){
    log.SetFlags(log.Ldate|log.Lshortfile)
}
           

使用init方法,可以在main函數執行之前初始化代碼。另外,雖然參數是int類型,但是上例中使用位運算符傳遞了多個常量為什麼會被識别到底傳了啥進去了呢。這是因為源碼中去做解析的時候,也是根據不同的常量組合的位運算去判斷你傳了啥的。是以先看源碼,你就可以大膽的傳了。

package main

import (
"log"
)


func main() {
	log.SetFlags(log.Ldate|log.Lshortfile)
	log.Print("我就是一條日志")
	log.Printf("%s,","誰說我是日志了,我是錯誤")

}

輸出:
2019/05/23 demo.go:11: 我就是一條日志
2019/05/23 demo.go:12: 誰說我是日志了,我是錯誤,
           

3. 如何傳自定義參數進日志#

在Java開發中我們會有這樣的日志需求:為了查日志更友善,我們需要在一個http請求或者rpc請求進來到結束的作用鍊中用一個唯一id将所有的日志串起來,這樣可以在日志中搜尋這個唯一id就能拿到這次請求的所有日志記錄。

是以現在的任務是如何在Go的日志中去定義這樣的一個id。Go中提供了這樣的一個方法:SetPrefix(prefix string),通過log.SetPrefix可以指定輸出日志的字首。

package main

import (
	uuid "github.com/satori/go.uuid"
	"log"
)


func main() {
	uuids, _ := uuid.NewV1()
	log.SetPrefix(uuids.String() +" ")
	log.SetFlags(log.Ldate|log.Lshortfile)
	log.Print("我就是一條日志")
	log.Printf("%s,","誰說我是日志了,我是錯誤")

}

輸出:
1791d770-7d6a-11e9-b2ee-00fffa4e4d0c 2019/05/23 demo.go:13: 我就是一條日志
1791d770-7d6a-11e9-b2ee-00fffa4e4d0c 2019/05/23 demo.go:14: 誰說我是日志了,我是錯誤,
           

4. log 輸出的底層實作#

從源碼中我們可以看到,無論是Print,Panic,還是Fatal他們都是使用std.Output(calldepth int, s string)方法。std的定義如下:

func New(out io.Writer, prefix string, flag int) *Logger {
	return &Logger{out: out, prefix: prefix, flag: flag}
}
var std = New(os.Stderr, "", LstdFlags)
           

即每一次調用log的時候都會去建立一個Logger對象。另外New中傳入的第一個參數是os.Stderr,os.Stderr對應的是UNIX裡的标準錯誤警告資訊的輸出裝置,同時被作為預設的日志輸出目的地。初次之外,還有标準輸出裝置os.Stdout以及标準輸入裝置os.Stdin。

var (
	Stdin  = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
	Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
	Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)
           

前兩種分别用于輸入、輸出和警告錯誤資訊。

我們再來看一下,所有的輸出都會調用的方法:std.Output(calldepth int, s string)

func (l *Logger) Output(calldepth int, s string) error {
   now := time.Now()
   var file string
   var line int
   //加鎖,保證多goroutine下的安全
   l.mu.Lock()
   defer l.mu.Unlock()
   //如果配置了擷取檔案和行号的話
   if l.flag&(Lshortfile|Llongfile) != 0 {
       //因為runtime.Caller代價比較大,先不加鎖
       l.mu.Unlock()
       var ok bool
       _, file, line, ok = runtime.Caller(calldepth)
       if !ok {
           file = "???"
           line = 0
       }
       //擷取到行号等資訊後,再加鎖,保證安全
       l.mu.Lock()
   }
   //把我們的日志資訊和設定的日志擡頭進行拼接
   l.buf = l.buf[:0]
   l.formatHeader(&l.buf, now, file, line)
   l.buf = append(l.buf, s...)
   if len(s) == 0 || s[len(s)-1] != '\n' {
       l.buf = append(l.buf, '\n')
   }
   //輸出拼接好的緩沖buf裡的日志資訊到目的地
   _, err := l.out.Write(l.buf)
   return err
}
           

formatHeader方法主要是格式化日志擡頭資訊,就是我們上面提到設定的日志列印格式,解析完之後存儲在buf這個緩沖中,最後再把我們自己的日志資訊拼接到緩沖buf的後面,然後為一次log日志輸出追加一個換行符,這樣每次日志輸出都是一行一行的。

上面我們提到過runtime.Caller(calldepth)這個方法,runtime包非常有意思,後面也會去說,他提供了一個運作時環境,可以在運作時去管理記憶體配置設定,垃圾回收,時間片切換等等,類似于Java中虛拟機做的活。(是不是很疑惑為什麼在Go中竟然可以去做Java中虛拟機能做的事情,其實想想協程的概念,再對比線程的概念,就不會疑惑為啥會給你提供這麼個包)。

Caller方法的解釋是:

Caller方法查詢有關函數調用的檔案和行号資訊,通過調用Goroutine的堆棧。參數skip是堆棧幀架構升序方式排列的數字值,0辨別Caller方法的調用。(出于曆史原因,Skip的含義在調用者和調用者之間有所不同。)

傳回值報告程式計數器、檔案名和相應檔案中行号的查詢。如果無法恢複資訊,則Boolean OK為 fasle。

Caller方法的定義:

func Caller(skip int) (pc uintptr, file string, line int, ok bool) {
}
           

參數skip表示跳過棧幀數,0表示不跳過,也就是runtime.Caller的調用者。1的話就是再向上一層,表示調用者的調用者。

log日志包裡使用的是2,也就是表示我們在源代碼中調用log.Print、log.Fatal和log.Panic這些函數的調用者。

以main函數調用log.Println為例,main->log.Println->*Logger.Output->runtime.Caller這麼一個方法調用棧,是以這時候,skip的值分别代表:

  1. 0表示*Logger.Output中調用runtime.Caller的源代碼檔案和行号
  2. 1表示log.Println中調用*Logger.Output的源代碼檔案和行号
  3. 2表示main中調用log.Println的源代碼檔案和行号

是以這也是log包裡的這個skip的值為什麼一直是2的原因。

5. 如何自定義自己的日志架構#

通過上面的學習,你其實知道了,日志的實作是通過New()函數構造了Logger對象來處理的。那我們隻用構造不同的Logger對象來處理不同類型的日記即可。下面是一個簡單的實作:

package main

import (
	"io"
	"log"
	"os"
)

var (
	Info *log.Logger
	Warning *log.Logger
	Error * log.Logger
)

func init(){
	infoFile,err:=os.OpenFile("/data/service_logs/info.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666)
	warnFile,err:=os.OpenFile("/data/service_logs/warn.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666)
	errFile,err:=os.OpenFile("/data/service_logs/errors.log",os.O_CREATE|os.O_WRONLY|os.O_APPEND,0666)

	if infoFile!=nil || warnFile != nil || err!=nil{
		log.Fatalln("打開日志檔案失敗:",err)
	}

	Info = log.New(os.Stdout,"Info:",log.Ldate | log.Ltime | log.Lshortfile)
	Warning = log.New(os.Stdout,"Warning:",log.Ldate | log.Ltime | log.Lshortfile)
	Error = log.New(io.MultiWriter(os.Stderr,errFile),"Error:",log.Ldate | log.Ltime | log.Lshortfile)

	Info = log.New(io.MultiWriter(os.Stderr,infoFile),"Info:",log.Ldate | log.Ltime | log.Lshortfile)
	Warning = log.New(io.MultiWriter(os.Stderr,warnFile),"Warning:",log.Ldate | log.Ltime | log.Lshortfile)
	Error = log.New(io.MultiWriter(os.Stderr,errFile),"Error:",log.Ldate | log.Ltime | log.Lshortfile)


}

func main() {
	Info.Println("我就是一條日志啊")
	Warning.Printf("我真的是一條日志喲%s\n","别騙我")
	Error.Println("好了,我要報錯了")
}

           

2. 第三方日志包logrus#

上面介紹了Go中的log包,Go标準庫的日志架構非常簡單,僅僅提供了Print,Panic和Fatal三個函數。對于更精細的日志級别、日志檔案分割,以及日志分發等方面,并沒有提供支援 。也有很多第三方的開源愛好者貢獻了很多好用的日志架構,畢竟Go是新興預言,目前為止沒有哪個日志架構能産生與Java中的slf4j一樣的地位,目前流行的日志架構有seelog,zap,logrus,還有beego中的日志架構部分。

這些日志架構可能在某些方面不能滿足你的需求,是以使用之前先了解清楚。因為logrus目前在GitHub上的star最高,11011。是以本篇文章介紹logrus的使用,大家可以舉一反三。 logrus的GitHub位址:

1. logrus特性#

logrus支援如下特性:

  1. 完全相容Go标準庫日志子產品。logrus擁有六種日志級别:debug、info、warn、error、fatal和panic,這是Go标準庫日志子產品的API的超集。如果你的項目使用标準庫日志子產品,完全可以用最低的代價遷移到logrus上。
  2. 可擴充的Hook機制。允許使用者通過hook方式,将日志分發到任意地方,如本地檔案系統、标準輸出、logstash、elasticsearch或者mq等,或者通過hook定義日志内容和格式等。
  3. 可選的日志輸出格式。**logrus内置了兩種日志格式,JSONFormatter和TextFormatter。**如果這兩個格式不滿足需求,可以自己動手實作接口Formatter,來定義自己的日志格式。
  4. Field機制。logrus鼓勵通過Field機制進行精細化、結構化的日志記錄,而不是通過冗長的消息來記錄日志。
  5. logrus是一個可插拔的、結構化的日志架構。

logrus不提供的功能:

  1. 沒有提供行号和檔案名的支援
  2. 輸出到本地檔案系統沒有提供日志分割功能
  3. 沒有提供輸出到ELK等日志進行中心的功能

這些功能都可以通過自定義hook來實作 。

2. 簡單的入門#

安裝:

go get github.com/sirupsen/logrus
           

2.1 一個簡單的入門:

package main

import log "github.com/sirupsen/logrus"

func main() {
	log.Info("我是一條日志")
	log.WithFields(log.Fields{"key":"value"}).Info("我要列印了")
}

輸出:
time="2019-05-24T08:13:47+08:00" level=info msg="我是一條日志"
time="2019-05-24T08:13:47+08:00" level=info msg="我要列印了" key=value
           

2.2 設定log的日志輸出為json格式

将日志輸出格式設定為JSON格式:

log.SetFormatter(&log.JSONFormatter{})
           
package main

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


func initLog() {
	// 設定日志格式為json格式
	log.SetFormatter(&log.JSONFormatter{})
}

func main() {
	initLog()
	log.WithFields(log.Fields{
		"age": 12,
		"name":   "xiaoming",
		"sex": 1,
	}).Info("小明來了")

	log.WithFields(log.Fields{
		"age": 13,
		"name":   "xiaohong",
		"sex": 0,
	}).Error("小紅來了")

	log.WithFields(log.Fields{
		"age": 14,
		"name":   "xiaofang",
		"sex": 1,
	}).Fatal("小芳來了")
}

輸出:
{"age":12,"level":"info","msg":"小明來了","name":"xiaoming","sex":1,"time":"2019-05-24T08:20:19+08:00"}
{"age":13,"level":"error","msg":"小紅來了","name":"xiaohong","sex":0,"time":"2019-05-24T08:20:19+08:00"}
{"age":14,"level":"fatal","msg":"小芳來了","name":"xiaofang","sex":1,"time":"2019-05-24T08:20:19+08:00"}
           

看到這裡輸出的日志格式與上面的差別,這裡是json格式,上面是純文字。

2.3 設定日志列印級别

logrus 提供 6 檔日志級别,分别是:

PanicLevel
FatalLevel
ErrorLevel
WarnLevel
InfoLevel
DebugLevel
           

設定日志輸出級别:

log.SetLevel(log.WarnLevel)
           

2.4 自定義輸出字段

logrus 預設的日志輸出有 time、level 和 msg 3個 Field,其中 time 可以不顯示,方法如下:

log.SetFormatter(&log.TextFormatter{DisableTimestamp: true})
           

自定義 Field 的方法如下:

log.WithFields(log.Fields{
		"age": 14,
		"name":   "xiaofang",
		"sex": 1,
	}).Fatal("小芳來了")
           

2.5 自定義日志輸出路徑

logrus預設日志輸出為stderr,你可以修改為任何的io.Writer。比如os.File檔案流。

func init() {
    //設定輸出樣式,自帶的隻有兩種樣式logrus.JSONFormatter{}和logrus.TextFormatter{}
    logrus.SetFormatter(&logrus.JSONFormatter{})
    //設定output,預設為stderr,可以為任何io.Writer,比如檔案*os.File
    file, _ := os.OpenFile("1.log", os.O_CREATE|os.O_WRONLY, 0666)
	log.SetOutput(file)
    //設定最低loglevel
    logrus.SetLevel(logrus.InfoLevel)
}
           

3. 進階功能-hook機制#

上面說過logrus是一個支援可插拔,結構化的日志架構,可插拔的特性就在于它的hook機制。一些功能需要使用者自己通過hook機制去實作定制化的開發。比如說在log4j中常見的日志按天按小時做切分的功能官方并沒有提供支援,你可以通過hook機制實作它。

Hook接口定義如下:

type Hook interface {
	Levels() []Level
	Fire(*Entry) error
}
           

logrus的hook原理是:在每次寫入日志時攔截,修改logrus.Entry 。logrus在記錄Levels()傳回的日志級别的消息時,會觸發HOOK, 然後按照Fire方法定義的内容,修改logrus.Entry 。logrus.Entry裡面就是記錄的每一條日志的内容。

是以在Hook中你需要做的就是在Fire方法中定義你想如何操作這一條日志的方法,在Levels方法中定義你想展示的日志級别。

如下是一個在所有日志中列印一個特殊字元串的Hook:

TraceIdHook

package hook

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


type TraceIdHook struct {
	TraceId  string
}

func NewTraceIdHook(traceId string) logrus.Hook {
	hook := TraceIdHook{
		TraceId:  traceId,
	}
	return &hook
}

func (hook *TraceIdHook) Fire(entry *logrus.Entry) error {
	entry.Data["traceId"] = hook.TraceId
	return nil
}

func (hook *TraceIdHook) Levels() []logrus.Level {
	return logrus.AllLevels
}
           

主程式:

package main

import (
	uuid "github.com/satori/go.uuid"
	log "github.com/sirupsen/logrus"
	"webDemo/hook"
)


func initLog() {
	uuids, _ := uuid.NewV1()
	log.AddHook(hook.NewTraceIdHook(uuids.String() +" "))
}

func main() {
	initLog()
	log.WithFields(log.Fields{
		"age": 12,
		"name":   "xiaoming",
		"sex": 1,
	}).Info("小明來了")

	log.WithFields(log.Fields{
		"age": 13,
		"name":   "xiaohong",
		"sex": 0,
	}).Error("小紅來了")

	log.WithFields(log.Fields{
		"age": 14,
		"name":   "xiaofang",
		"sex": 1,
	}).Fatal("小芳來了")
}
           

該hook會在日志中列印出一個uuid字元串。