go日志框架之logrus

本文转载自姜总

golang日志库

golang标准库的日志框架非常简单,仅仅提供了print,panic和fatal三个函数对于更精细的日志级别、日志文件分割以及日志分发等方面并没有提供支持。所以催生了很多第三方的日志库,但是在golang的世界里,没有一个日志库像slf4j那样在Java中具有绝对统治地位。golang中,流行的日志框架包括logrus、zap、zerolog、seelog等。
logrus是目前Github上star数量最多的日志库,目前(2018.08,下同)star数量为8119,fork数为1031。logrus功能强大,性能高效,而且具有高度灵活性,提供了自定义插件的功能。很多开源项目,如docker,prometheus等,都是用了logrus来记录其日志。
zap是Uber推出的一个快速、结构化的分级日志库。具有强大的ad-hoc分析功能,并且具有灵活的仪表盘。zap目前在GitHub上的star数量约为4.3k。
seelog提供了灵活的异步调度、格式化和过滤功能。目前在GitHub上也有约1.1k。

logrus特性

logrus具有以下特性:

  • 完全兼容golang标准库日志模块:logrus拥有六种日志级别:debug、info、warn、error、fatal和panic,这是golang标准库日志模块的API的超集。如果您的项目使用标准库日志模块,完全可以以最低的代价迁移到logrus上。
  • 可扩展的Hook机制:允许使用者通过hook的方式将日志分发到任意地方,如本地文件系统、标准输出、logstash、elasticsearch或者mq等,或者通过hook定义日志内容和格式等。
  • 可选的日志输出格式:logrus内置了两种日志格式,JSONFormatterTextFormatter,如果这两个格式不满足需求,可以自己动手实现接口Formatter,来定义自己的日志格式。
  • Field机制:logrus鼓励通过Field机制进行精细化的、结构化的日志记录,而不是通过冗长的消息来记录日志。
  • logrus是一个可插拔的、结构化的日志框架。

logrus的使用

第一个示例

最简单的使用logrus的示例如下:

package main

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

func main() {
  log.WithFields(log.Fields{
    "animal": "walrus",
  }).Info("A walrus appears")
}

上面代码执行后,标准输出上输出如下:

time="2018-08-11T15:42:22+08:00" level=info msg="A walrus appears" animal=walrus

logrus与golang标准库日志模块完全兼容,因此您可以使用log“github.com/sirupsen/logrus”替换所有日志导入。
logrus可以通过简单的配置,来定义输出、格式或者日志级别等。

package main

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

func init() {
    // 设置日志格式为json格式
    log.SetFormatter(&log.JSONFormatter{})

    // 设置将日志输出到标准输出(默认的输出为stderr,标准错误)
    // 日志消息输出可以是任意的io.writer类型
    log.SetOutput(os.Stdout)

    // 设置日志级别为warn以上
    log.SetLevel(log.WarnLevel)
}

func main() {
    log.WithFields(log.Fields{
        "animal": "walrus",
        "size":   10,
    }).Info("A group of walrus emerges from the ocean")

    log.WithFields(log.Fields{
        "omg":    true,
        "number": 122,
    }).Warn("The group's number increased tremendously!")

    log.WithFields(log.Fields{
        "omg":    true,
        "number": 100,
    }).Fatal("The ice breaks!")
}

Logger

logger是一种相对高级的用法, 对于一个大型项目, 往往需要一个全局的logrus实例,即logger对象来记录项目所有的日志。如:

package main

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

// logrus提供了New()函数来创建一个logrus的实例。
// 项目中,可以创建任意数量的logrus实例。
var log = logrus.New()

func main() {
    // 为当前logrus实例设置消息的输出,同样地,
    // 可以设置logrus实例的输出到任意io.writer
    log.Out = os.Stdout

    // 为当前logrus实例设置消息输出格式为json格式。
    // 同样地,也可以单独为某个logrus实例设置日志级别和hook,这里不详细叙述。
    log.Formatter = &logrus.JSONFormatter{}

    log.WithFields(logrus.Fields{
        "animal": "walrus",
        "size":   10,
    }).Info("A group of walrus emerges from the ocean")
}

Fields

前一章提到过,logrus不推荐使用冗长的消息来记录运行信息,它推荐使用Fields来进行精细化的、结构化的信息记录。
例如下面的记录日志的方式:

log.Fatalf("Failed to send event %s to topic %s with key %d", event, topic, key)

在logrus中不太提倡,logrus鼓励使用以下方式替代之:

<div class="se-preview-section-delimiter"></div>

log.WithFields(log.Fields{
  "event": event,
  "topic": topic,
  "key": key,
}).Fatal("Failed to send event")

前面的WithFields API可以规范使用者按照其提倡的方式记录日志。但是WithFields依然是可选的,因为某些场景下,使用者确实只需要记录仪一条简单的消息。

通常,在一个应用中、或者应用的一部分中,都有一些固定的Field。比如在处理用户http请求时,上下文中,所有的日志都会有request_iduser_ip。为了避免每次记录日志都要使用log.WithFields(log.Fields{"request_id": request_id, "user_ip": user_ip}),我们可以创建一个logrus.Entry实例,为这个实例设置默认Fields,在上下文中使用这个logrus.Entry实例记录日志即可。

requestLogger := log.WithFields(log.Fields{"request_id": request_id, "user_ip": user_ip})
requestLogger.Info("something happened on that request") # will log request_id and user_ip
requestLogger.Warn("something not great happened")

Hook

logrus最令人心动的功能就是其可扩展的HOOK机制了,通过在初始化时为logrus添加hook,logrus可以实现各种扩展功能。

Hook接口

logrus的hook接口定义如下,其原理是每此写入日志时拦截,修改logrus.Entry。

// logrus在记录Levels()返回的日志级别的消息时会触发HOOK,
// 按照Fire方法定义的内容修改logrus.Entry。
type Hook interface {
    Levels() []Level
    Fire(*Entry) error
}

一个简单自定义hook如下,DefaultFieldHook定义会在所有级别的日志消息中加入默认字段appName="myAppName"

type DefaultFieldHook struct {
}

func (hook *DefaultFieldHook) Fire(entry *log.Entry) error {
    entry.Data["appName"] = "MyAppName"
    return nil
}

func (hook *DefaultFieldHook) Levels() []log.Level {
    return log.AllLevels
}

hook的使用也很简单,在初始化前调用log.AddHook(hook)添加相应的hook即可。

logrus官方仅仅内置了syslog的hook
此外,但Github也有很多第三方的hook可供使用,文末将提供一些第三方HOOK的连接。

问题与解决方案

尽管logrus有诸多优点,但是为了灵活性和可扩展性,官方也削减了很多实用的功能,例如:

  • 没有提供行号和文件名的支持
  • 输出到本地文件系统没有提供日志分割功能
  • 官方没有提供输出到ELK等日志处理中心的功能

但是这些功能都可以通过自定义hook来实现。

记录文件名和行号

logrus的一个很致命的问题就是没有提供文件名和行号,这在大型项目中通过日志定位问题时有诸多不便。Github上的logrus的issue#63:Log filename and line number创建于2014年,四年过去了仍是open状态~~~
网上给出的解决方案分位两类,一就是自己实现一个hook;二就是通过装饰器包装logrus.Entry。两种方案网上都有很多代码,但是大多无法正常工作。但总体来说,解决问题的思路都是对的:通过标准库的runtime模块获取运行时信息,并从中提取文件名,行号和调用函数名。

标准库runtime模块的Caller(skip int)函数可以返回当前goroutine调用栈中的文件名,行号,函数信息等,参数skip表示表示返回的栈帧的层次,0表示runtime.Caller的调用着。返回值包括响应栈帧层次的pc(程序计数器),文件名和行号信息。为了提高效率,我们先通过跟踪调用栈发现,从runtime.Caller()的调用者开始,到记录日志的生成代码之间,大概有8到11层左右,所有我们在hook中循环第8到11层调用栈应该可以找到日志记录的生产代码。

这里写图片描述

此外,runtime.FuncForPC(pc uintptr) *Func可以返回指定pc的函数信息。
所有我们要实现的hook也是基于以上原理,使用runtime.Caller()依次循环调用栈的第7~11层,过滤掉sirupsen包内容,那么第一个非siupsenr包就认为是我们的生产代码了,并返回pc以便通过runtime.FuncForPC()获取函数名称。然后将文件名、行号和函数名组装为source字段塞到logrus.Entry中即可。

time="2018-08-11T19:10:15+08:00" level=warning msg="postgres_exporter is ready for scraping on 0.0.0.0:9295..." source="postgres_exporter/main.go:60:main()"
time="2018-08-11T19:10:17+08:00" level=error msg="!!!msb info not found" source="postgres/postgres_query.go:63:QueryPostgresInfo()"
time="2018-08-11T19:10:17+08:00" level=error msg="get postgres instances info failed, scrape metrics failed, error:msb env not found" source="collector/exporter.go:71:Scrape()"

日志本地文件分割

logrus本身不带日志本地文件分割功能,但是我们可以通过file-rotatelogs进行日志本地文件分割。 每次当我们写入日志的时候,logrus都会调用file-rotatelogs来判断日志是否要进行切分。关于本地日志文件分割的例子网上很多,这里不再详细介绍,奉上代码:

import (
    "github.com/lestrrat-go/file-rotatelogs"
    "github.com/rifflock/lfshook"
    log "github.com/sirupsen/logrus"
    "time"
)

func newLfsHook(logLevel *string, maxRemainCnt uint) log.Hook {
    writer, err := rotatelogs.New(
        logName+".%Y%m%d%H",
        // WithLinkName为最新的日志建立软连接,以方便随着找到当前日志文件
        rotatelogs.WithLinkName(logName),

        // WithRotationTime设置日志分割的时间,这里设置为一小时分割一次
        rotatelogs.WithRotationTime(time.Hour),

        // WithMaxAge和WithRotationCount二者只能设置一个,
        // WithMaxAge设置文件清理前的最长保存时间,
        // WithRotationCount设置文件清理前最多保存的个数。
        //rotatelogs.WithMaxAge(time.Hour*24),
        rotatelogs.WithRotationCount(maxRemainCnt),
    )

    if err != nil {
        log.Errorf("config local file system for logger error: %v", err)
    }

    level, ok := logLevels[*logLevel]

    if ok {
        log.SetLevel(level)
    } else {
        log.SetLevel(log.WarnLevel)
    }

    lfsHook := lfshook.NewHook(lfshook.WriterMap{
        log.DebugLevel: writer,
        log.InfoLevel:  writer,
        log.WarnLevel:  writer,
        log.ErrorLevel: writer,
        log.FatalLevel: writer,
        log.PanicLevel: writer,
    }, &log.TextFormatter{DisableColors: true})

    return lfsHook
}

使用上述本地日志文件切割的效果如下:


这里写图片描述

将日志发送到elasticsearch

将日志发送到elasticsearch是很多日志监控系统的选择,将logrus日志发送到elasticsearch的原理是在hook的每次fire调用时,使用golang的es客户端将日志信息写到elasticsearch。elasticsearch官方没有提供golang客户端,但是有很多第三方的go语言客户端可供使用,我们选择elastic。elastic提供了丰富的文档,以及Java中的流式接口,使用起来非常方便。

client, err := elastic.NewClient(elastic.SetURL("http://localhost:9200"))
    if err != nil {
        log.Panic(err)
    }

// Index a tweet (using JSON serialization)
tweet1 := Tweet{User: "olivere", Message: "Take Five", Retweets: 0}
put1, err := client.Index().
    Index("twitter").
    Type("tweet").
    Id("1").
    BodyJson(tweet1).
    Do(context.Background())

考虑到logrus的Fields机制,可以实现如下数据格式:

msg := struct {
    Host      string
    Timestamp string `json:"@timestamp"`
    Message   string
    Data      logrus.Fields
    Level     string
}

其中Host记录产生日志主机信息,在创建hook是指定。其他数据需要从logrus.Entry中取得。测试过程我们选择按照此原理实现的第三方HOOK:elogrus。其使用如下:

import (
    "github.com/olivere/elastic"
    "gopkg.in/sohlich/elogrus"
)

func initLog() {
    client, err := elastic.NewClient(elastic.SetURL("http://localhost:9200"))
    if err != nil {
        log.Panic(err)
    }
    hook, err := elogrus.NewElasticHook(client, "localhost", log.DebugLevel, "mylog")
    if err != nil {
        log.Panic(err)
    }
    log.AddHook(hook)
}

从Elasticsearch查询得到日志存储,效果如下:

GET http://localhost:9200/mylog/_search

HTTP/1.1 200 OK
content-type: application/json; charset=UTF-8
transfer-encoding: chunked

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  },
  "hits": {
    "total": 2474,
    "max_score": 1.0,
    "hits": [
      {
        "_index": "mylog",
        "_type": "log",
        "_id": "AWUw13jWnMZReb-jHQup",
        "_score": 1.0,
        "_source": {
          "Host": "localhost",
          "@timestamp": "2018-08-13T01:12:32.212818666Z",
          "Message": "!!!msb info not found",
          "Data": {},
          "Level": "ERROR"
        }
      },
      {
        "_index": "mylog",
        "_type": "log",
        "_id": "AWUw13jgnMZReb-jHQuq",
        "_score": 1.0,
        "_source": {
          "Host": "localhost",
          "@timestamp": "2018-08-13T01:12:32.223103348Z",
          "Message": "get postgres instances info failed, scrape metrics failed, error:msb env not found",
          "Data": {
            "source": "collector/exporter.go:71:Scrape()"
          },
          "Level": "ERROR"
        }
      },
      //...
      {
        "_index": "mylog",
        "_type": "log",
        "_id": "AWUw2f1enMZReb-jHQu_",
        "_score": 1.0,
        "_source": {
          "Host": "localhost",
          "@timestamp": "2018-08-13T01:15:17.212546892Z",
          "Message": "!!!msb info not found",
          "Data": {
            "source": "collector/exporter.go:71:Scrape()"
          },
          "Level": "ERROR"
        }
      },
      {
        "_index": "mylog",
        "_type": "log",
        "_id": "AWUw2NhmnMZReb-jHQu1",
        "_score": 1.0,
        "_source": {
          "Host": "localhost",
          "@timestamp": "2018-08-13T01:14:02.21276903Z",
          "Message": "!!!msb info not found",
          "Data": {},
          "Level": "ERROR"
        }
      }
    ]
  }
}

Response code: 200 (OK); Time: 16ms; Content length: 3039 bytes

将日志发送到其他位置

将日志发送到日志中心也是logrus所提倡的,虽然没有提供官方支持,但是目前Github上有很多第三方hook可供使用:

等等,上述第三方hook我这里没有具体验证,大家可以根据需要自行尝试。

其他注意事项

Fatal处理

和很多日志框架一样,logrus的Fatal系列函数会执行os.Exit(1)。但是logrus提供可以注册一个或多个fatal handler函数的接口logrus.RegisterExitHandler(handler func() {} ),让logrus在执行os.Exit(1)之前进行相应的处理。fatal handler可以在系统异常时调用一些资源释放api等,让应用正确的关闭。

线程安全

默认情况下,logrus的api都是线程安全的,其内部通过互斥锁来保护并发写。互斥锁工作于调用hooks或者写日志的时候,如果不需要锁,可以调用logger.SetNoLock()来关闭之。可以关闭logrus互斥锁的情形包括:

  • 没有设置hook,或者所有的hook都是线程安全的实现。
  • 写日志到logger.Out已经是线程安全的了,如logger.Out已经被锁保护,或者写文件时,文件是以O_APPEND方式打开的,并且每次写操作都小于4k。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容

  • (http://www.cnblogs.com/zhangchenliang/p/4546352.html) 1、...
    凌雲木阅读 2,401评论 0 2
  • 在我们开发程序后,如果有一些问题需要对程序进行调试的时候,日志是必不可少的,这是我们分析程序问题常用的手段。 日志...
    豆瓣奶茶阅读 18,352评论 0 22
  • 在应用程序中添加日志记录总的来说基于三个目的:监视代码中变量的变化情况,周期性的记录到文件中供其他应用进行统计分析...
    时待吾阅读 4,961评论 0 6
  • 爸爸年轻的时候是个货车司机。去过很多地方,也很少管我。那些年父母都很忙,忙于养家糊口。一转眼到了我忙于奔命的...
    蘇肉阅读 792评论 3 0
  • 当一个人游走在死神的边缘,他会想些什么呢? 2004年,杨绛先生生病入院,在病床上的时候,她就一直在思索一个题目,...
    林子说心理阅读 849评论 0 0