开源丨GOKU文件日志功能设计
2021,9,27 教程

 

风靡全球的小说人物夏洛克·福尔摩斯,善于用观察与演绎推理和法学知识来解决问题,无论大小案件他总能快速的找到线索,并顺藤摸瓜找到幕后凶手。

而在现实的软件开发过程中,程序员会在代码里打上”线索”,在程序出现错误时,通过查看这些”线索”来快速定位到问题所在并继而解决——这些”线索”就是日志。

日志是开发为了跟踪用户行为和代码异常而打的记录,而文件日志则是指将日志存入到指定文件这一功能。

在开发环境中,我们会在程序中设置断点或打印日志的方式进行调试。有人只用设置断点的方式,也有人会结合两种方法,通过分析日志文件缩小断点范围来快速定位问题代码。但在生产环境里,我们无法设置断点来debug,只能通过日志信息来捕捉错误。

GOKU文件日志的实现考虑

golang实现的日志框架有很多,包括logruszapzerologseelog等,其中logrus是目前go生态最流行的日志框架。logrus兼容golang标准库log,囊括了标准库的所有日志等级,并且具有可扩展的hook机制、能选择日志的格式、内置JSONFormatter和TextFormatter。当然开发者也能自己实现接口Formatter,同时还有Field机制能够进行精细化、结构化的日志记录。美中不足的是,logrus自身没有实现日志分割的功能,而是通过hook机制间接实现。

虽然logrus框架功能丰富,性能也不错,但每个项目都有自身的需求,即使是再好的日志框架也不能生搬硬套到项目里。于是GOKU在logrus框架的基础上进行二次开发,只保留基本功能。

我们比较常见的日志框架以及根据自己的需求,归纳了以下需求点:

事有轻重缓急,日志信息也有重要与不重要之分。设置合理的日志等级能够帮助我们过滤不重要的日志信息,快速排查问题。

按照重要程度,将日志等级分成了五个等级:

日志等级 含义
fatal 会导致程序退出的严重错误
error 一般的错误信息
warn 警告信息
info 一般信息
debug 调试信息

当文件日志设置了某个日志等级,那么程序只会输出不低于当前等级的日志信息到文件中。

在产品上线时只需要开启error等级的日志,在程序出现了不知名的错误时,则启动debug模式对程序进行排查调试,可以避免程序运行时的日志冗余。

日志分割即每隔一段时间产生一个新的日志文件,防止所有日志记录全部堆积在一个日志文件里。

实行分割的好处有两个:

  1. 避免单个日志文件占用磁盘空间过大,查看时影响服务器IO;
  2. 能够根据时间间隔更快地定位日志;

常见的日志分割的周期有小时和天,而月、年可能会造成单个日志数据量过大,增加排查难度,所以一般不会使用。

随着时间的推移,旧日志文件会越积越多,而磁盘空间有限,一旦空间消耗完,可能会导致某些应用奔溃,影响生产。

定时清理日志的优点:

  1. 无需手动删除旧日志文件,减少人力维护成本;
  2.  避免过期日志文件的堆积,既减少磁盘占用又可更快定位所需的日志文件;

 

文件日志的配置参数

参考功能的设计,需要有个字段来确定日志等级,同时日志文件的分割周期和保存时间也需要两个参数来配置。

接下来对文件日志功能需要配置的参数进行说明:

参数名 参数类型 参数取值范围 说明
dir 字符串 任意字符串 日志文件的目录路径(支持绝对路径和相对路径)
file 字符串 任意字符串 日志文件的文件名
level 枚举 [“fatal”,”error”,”warn”,”info”,”debug”] 日志等级
perio 枚举 [“day”,”hour”] 日志文件的分割周期
expire 整型 大于0的整数 旧日志文件的保存时间,单位为天

period参数:用来设置日志文件的分割周期,即每隔一段时间就生成新的日志文件,对日志记录进行分流,以此来实现日志分割功能。可以是一天生成一个新日志文件,也可以是每小时。

expire参数:设定旧日志文件的保存时间,可以设置若干天。保存时间从新日志文件生成的那一刻开始生效,旧文件名后缀加上时间戳。程序定时检查旧文件是否过期,以此达到日志定时清理的目的。

下图以文件分割周期:day,旧文件保存时间:3天为例,对日志文件的分割及清理流程进行解释说明。

文件日志的核心代码实现

日志分级

在调用打印日志接口后最终会进入到这个方法里,entry入参是日志记录的结构体变量,而transporter是日志输出器:

//Transport 能将判断日志记录entry的等级并格式化输出
func (t *Transporter) Transport(entry *Entry) error {
       output := t.output
       if output == nil {
              return nil
       }
     //当配置的日志等级大于或等于日志记录的等级时进行输出
       if t.Level() >= entry.Level {
    //对日志记录格式化
              data, err := t.formatter.Format(entry)
              if err != nil {
                     return err
              }
    //输出日志记录
              _, err = output.Write(data)
              return err
       }
       return nil
}

 

日志定时清理

日志定时清理的逻辑很简单:获取日志目录下的旧日志文件,逐个判断是否过期。

在文件分割时会执行以下的方法:

//dropHistory 检查日志目录下的历史日志文件,若过期则删除
func (w *FileWriterByPeriod) dropHistory() {
       expire := w.getExpire()
       expireTime := time.Now().Add(-expire)
       pathPatten := filepath.Join(w.dir, fmt.Sprintf(“%s-*”, w.file))
       files, err := filepath.Glob(pathPatten)
       if err == nil {
              for _, f := range files {
                     if info, e := os.Stat(f); e == nil {

                            if expireTime.After(info.ModTime()) {
                                   _ = os.Remove(f)
                            }
                     }
              }
       }
}

 

日志分割

实现日志分割的重点在于处理的时机。定义一个时间戳变量,并且设置一个定时器,定时获取当前时间戳,当新旧时间戳不一致时才进行文件处理。核心代码如下:

case <-t.C:
   {
      if buf.Buffered() > 0 {
         buf.Flush()
         tflusth.Reset(time.Second)
      }
          //获取新时间戳,并与旧时间戳进行比较,若不一致则进行日志分割
      if lastTag != w.timeTag(time.Now()) {
                                  
         //关闭旧日志文件
         f.Close()
         //对旧日志文件重命名
         w.history(lastTag)
         //创建新日志文件
         fnew, tag, err := w.openFile()
         if err != nil {
            return
         }
         //保存新时间戳
         lastTag = tag
         f = fnew
         buf.Reset(f)

         go w.dropHistory()
      }

   }

 

那么时间戳是怎样的,又是如何获取的呢?

事实上,上面的时间戳是由完整时间戳格式化得到的,而具体的格式化字符串又是依据period的参数而不同。这样就能轻易获取截止到小时或天的时间戳,以此做到隔天或间隔小时生成新日志文件。

代码如下:

func (w *FileWriterByPeriod) timeTag(t time.Time) string {

   w.locker.Lock()
   //w.period.FormatLayout 获取period的格式化字符串
   tag := t.Format(w.period.FormatLayout())
   w.locker.Unlock()
   return tag
}

func (period LogPeriodType) FormatLayout() string {
   switch period {
   case PeriodHour:
      {
         return “2006-01-02-15”
      }
   case PeriodDay:
      {
         return “2006-01-02”
      }
   default:
      return “2006-01-02-15”
   }
}

 

最后

综上所述,goku网关基于logrus框架进行二次开发,对日志分割功能进行了完善,同时提供了日志定时清理、日志分级等操作,满足了goku网关文件日志打印的需求。另外,除了文件日志,goku还支持httplog、syslog、stdlog等多样的日志输出,这些离不开我们goku自己实现的日志框架,对多样的日志输出进行分流,下一篇我们再给大家介绍我们的日志框架的实现细节及相关考虑。

什么是GoKu网关?


Goku API Gateway (中文名:悟空 API 网关)是一个基于 Golang开发的微服务网关,能够实现高性能 HTTP API 转发、服务编排、多租户管理、API 访问权限控制等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行 API 服务治理、提高 API 服务的稳定性和安全性。

GoKu Github地址:https://github.com/eolinker/goku

💡关于 Eolinker

📞联系我们

🏠部分客户

💎投资机构