清晰架构(Clean Architecture)的Go微服务: 日志管理

良好的日志记录能够提供丰富的日志数据,便于在调试时发现问题,从而大大提升编码效率。 记录器提供的自动化信息越多越好,日志信息也须要以简洁的方式呈现,便于找到重要的数据。git

日志需求:
  1. 无需修改业务代码便可切换到其余日志库程序员

  2. 不需直接依赖任何日志库github

  3. 整个应用程序只有一个日志库的全局实例,所以你能够在一个位置更改日志配置并将其应用于整个程序。sql

  4. 能够在不修改代码的状况下轻松更改日志记录选项,例如,日志级别数据库

  5. 可以在程序运行时动态更改日志级别服务器

资源句柄:为何日志记录与数据库不一样

当应用程序须要处理外部资源时,例如数据库,文件系统,网络链接, SMTP服务器时,它一般须要一个资源句柄(Resource Handler)。在依赖注入中,容器建立一个资源句柄并将其注入每一个业务函数,所以它可使用资源句柄来访问底层资源。在此应用程序中,资源句柄是一个接口,所以业务层不会直接依赖于资源句柄的任何具体实现。数据库和gRPC连接都以这种方式处理。网络

可是,日志记录器稍有不一样,由于几乎每一个函数都须要它,但数据库不是。在Java中,咱们为每一个Java类初始化一个记录器(Logger)实例。 Java日志记录框架使用层次关系来管理不一样的记录器,所以它们从父日志记录器继承相同的日志配置。在Go中,不一样的记录器之间没有层次关系,所以你要么建立一个记录器,要么具备许多彼此不相关的不一样记录器。为了得到一致的日志记录配置,最好建立一个全局记录器并将其注入每一个函数。但者将须要作不少工做,因此我决定在一个中心位置建立一个全局记录器,每一个函数能够直接引用它。app

为了避免将应用程序紧密绑定到特定的记录器,我建立了一个通用的记录器接口,所以应用程序对于具体的记录器透明的。如下是记录器(Logger)接口。框架

// Log is a package level variable, every program should access logging function through "Log"
var Log Logger

// Logger represent common interface for logging function
type Logger interface {
    Errorf(format string, args ...interface{})
    Fatalf(format string, args ...interface{})
    Fatal(args ...interface{})
    Infof(format string, args ...interface{})
    Info( args ...interface{})
    Warnf(format string, args ...interface{})
    Debugf(format string, args ...interface{})
    Debug(args ...interface{})
}

由于每一个文件都依赖于日志记录,很容易产生循环依赖,因此我在“容器”包里面建立了一个单独的子包“logger”来避免这个问题。 它只有一个“Log”变量和“Logger”接口。 每一个文件都经过这个变量和接口访问日志功能。函数

记录器封装

支持一个日志库的标准方法(例如ZAP¹或Logrus²) 是建立一个封装来实现已经建立的记录器接口。 这很简单,如下是代码。

type loggerWrapper struct {
    lw *zap.SugaredLogger
}
func (logger *loggerWrapper) Errorf(format string, args ...interface{}) {
    logger.lw.Errorf(format, args)
}
func (logger *loggerWrapper) Fatalf(format string, args ...interface{}) {
    logger.lw.Fatalf(format, args)
}
func (logger *loggerWrapper) Fatal(args ...interface{}) {
    logger.lw.Fatal(args)
}
func (logger *loggerWrapper) Infof(format string, args ...interface{}) {
    logger.lw.Infof(format, args)
}
func (logger *loggerWrapper) Warnf(format string, args ...interface{}) {
    logger.lw.Warnf(format, args)
}
func (logger *loggerWrapper) Debugf(format string, args ...interface{}) {
    logger.lw.Debugf(format, args)
}
func (logger *loggerWrapper) Printf(format string, args ...interface{}) {
    logger.lw.Infof(format, args)
}
func (logger *loggerWrapper) Println(args ...interface{}) {
    logger.lw.Info(args, "\n")
}

可是日志记录存在一个问题。日志记录的一个功能是在日志消息中打印记录者名字。在对接口封装以后,方法的调用者不是打印日志的程序,而是封装程序。要解决该问题,你能够直接更改日志库的源代码,但在升级日志库时会致使兼容性问题。最终的解决方案是要求日志记录库建立一个新功能,该功能能够根据方法是否使用封装来返回合适的调用方。

为了让代码如今能正常工做,我走了捷径。由于ZAP和Logrus之间的大多数函数签名是类似的,因此我提取了经常使用的签名并建立了一个共享接口,由于两个日志库都已经有了这些函数,它们自动实现这些接口。 Go接口设计的优势在于,你能够先建立具体实现,而后再建立接口,若是函数签名相互匹配,则自动实现接口。这有点做弊,但很是有效。若是要用的记录器不支持公共的接口,则仍是要对它进行封装, 这样就只能暂时先牺牲调用者功能或修改源代码。

日志库比较:

不一样的日志库提供不一样的功能,其中一些功能对于调试很重要。

须要记录的重要信息(须要如下数据):

  1. 文件名和行号

  2. 方法名称和调用文件名

  3. 消息记录级别

  4. 时间戳

  5. 错误堆栈跟踪

  6. 自动记录每一个函数调用包括参数和结果

我但愿日志库自动提供这些数据,例如调用方法名称,而不编写显式代码来实现。对于上述6个功能,目前没有日志库提供#6,但它们都提供1到5个中的部分或所有。我尝试了两个很是流行的日志库Logrus和ZAP。 Logrus提供了全部功能,可是个人控制台上的格式不正确(它在个人Windows控制台上显示“ n  t”而不是新行)而且输出格式不像ZAP那样干净。 ZAP不提供#2,但其余一切看起来都不错,因此我决定暂时使用它。

使人惊讶的是,本程序被证实是一个很是好的工具来测试不一样的日志库,由于你能够切换到不一样的日志库来比较输出结果,而只须要更改配置文件中的一行。这不是本程序的功能,而是一个好的反作用。

实际上,我最须要的功能是自动记录每一个函数调用包括参数和结果(#6),可是尚未日志库提供该功能提供。我但愿未来可以获得它。

错误(error)处理:

错误处理与日志记录直接相关,因此我也在这里讨论一下。如下是我在处理错误时遵循的规则。

1.使用堆栈跟踪建立错误
错误消息自己须要包含堆栈跟踪信息。若是错误源自你的程序,你能够导入“github.com/pkg/errors”库来建立错误以包含堆栈跟踪。可是若是它是从另外一个库生成的而且该库没有使用“pkg/errors”,你须要用“errors.Wrap(err,message)”语句包装该错误,以获取堆栈跟踪信息。因为咱们没法控制第三方库,所以最好的解决方案是在咱们的程序中对全部错误进行包装。详情请见这里³。

2.使用堆栈跟踪打印错误
你须要使用“logger.Log.Errorf(”%+v\n“,err)”或“fmt.Printf(”%+v\n“,err)”以便打印堆栈跟踪信息,关键是“+v”选项(固然你必须已经使用#1)。

3.只有顶级函数才能处理错误
“处理”表示记录错误并将错误返回给调用者。由于只有顶级函数处理错误,因此错误只在程序中记录一次。顶层的调用者一般是面向用户的程序,它是用户界面程序(UI)或另外一个微服务。你但愿记录错误消息(所以你的程序中具备记录),而后将消息返回到UI或其余微服务,以便他们能够重试或对错误执行某些操做。

4.全部其余级别函数应只是将错误传播到较高级别
底层或中间层函数不要记录或处理错误,也不要丢弃错误。你能够向错误中添加更多数据,而后传播它。当出现错误时,你不但愿中止整个应用程序。

恐慌(Panic):

除了在本地的“main.go”以外,我从未使用过恐慌(Panic)。它更像是一个bug而不是一个功能。在让咱们谈谈日志⁴中,Dave Cheney写道“人们广泛认为应用库不该该使用恐慌”。另外一个错误是log.Fatal,它具备与恐慌相同的效果,也应该被禁止。 “log.Fatal”更糟糕,它看起来像一个日志,可是在输出日志后它“恐慌”,这违反了单一责任规则。

恐慌有两个问题。首先,它与错误的处理方式不一样,但它其实是一个错误,一个错误的子类型。如今,错误处理代码须要处理错误和恐慌,例如事务处理代码⁵中的错误处理代码。其次,它会中止应用程序,这很是糟糕。只有顶级主控制程序才能决定如何处理错误,全部其余被调用的函数应该只将错误传播到上层。特别是如今,服务网格层(Service Mesh)能够提供重试等功能,恐慌使其更加复杂。

若是你正在调用第三方库而且它在代码中产生恐慌,那么为了防止代码中止,你须要截获恐慌并从中恢复。如下是代码示例,你须要为每一个可能发生恐慌的顶级函数执行此操做(在每一个函数中放置“defer catchPanic()”)。在下面的代码中,咱们有一个函数“catchPanic”来捕获并从恐慌中恢复。函数“RegisterUser”在代码的第一行调用“defer catchPanic()”。有关恐慌的详细讨论,请参阅此处⁶。

func catchPanic() {
    if p := recover(); p != nil {
        logger.Log.Errorf("%+v\n", p)
    }
}

func (uss *UserService) RegisterUser(ctx context.Context, req *uspb.RegisterUserReq)
    (*uspb.RegisterUserResp, error) {
    
    defer catchPanic()
    ruci, err := getRegistrationUseCase(uss.container)
    if err != nil {
        logger.Log.Errorf("%+v\n", err)
        return nil, errors.Wrap(err, "")
    }
    mu, err := userclient.GrpcToUser(req.User)
...
}
结论:

良好的日志记录可使程序员更有效。你但愿使用堆栈跟踪记录错误。 只有顶级函数才能处理错误,全部其余级别函数只应将错误传播到上一级。 不要使用恐慌。

源程序:

完整的源程序连接 github: https://github.com/jfeng45/servicetmpl

索引:

[1] zap

[2] Logrus

[3][Stack traces and the errors package](https://dave.cheney.net/2016/06/12/stack-traces-and-the-errors-package)

[4][Let’s talk about logging](https://dave.cheney.net/2015/11/05/lets-talk-about-logging)

[5][database/sql Tx — detecting Commit or Rollback](https://stackoverflow.com/questions/16184238/database-sql-tx-detecting-commit-or-rollback/23502629#23502629)

[6][On the uses and misuses of panics in Go](https://eli.thegreenplace.net/2018/on-the-uses-and-misuses-of-panics-in-go/)

相关文章
相关标签/搜索