微服务中的温和错误处理

本文介绍了如何在Go中基于“ Made and Forgot”原理实现错误处理和日志记录。 该方法专为Go上的微服务而设计,可在Docker容器中工作,并符合Clean Architecture的原则构建。


本文是最近在喀山举行的Go会议的报告的详细版本。 如果您对Go感兴趣,并且住在喀山,Innopolis,美丽的Yoshkar-Ola或附近的其他城市,则应该访问社区页面: golangkazan.imtqy.com


在会议上,我们的团队在两份报告中展示了我们如何在Go上开发微服务-我们遵循的原则以及如何简化生活。 本文重点介绍错误处理的概念,现在将其扩展到所有新的微服务。


微服务结构协议


在探讨错误处理规则之前,有必要确定在设计和编码时要遵守哪些限制。 为此,值得告诉我们微服务的外观。


首先,我们尊重干净的架构。 我们将代码分为三个级别并遵守相关性规则:更深层次的包独立于外部包,并且没有循环依赖。 幸运的是,Go中禁止直接循环依赖包。 通过借用术语,关于行为或转换为类型的假设的间接依赖性仍然会出现,应避免使用它们。


这是我们的关卡外观:


  1. 域级别包含主题领域规定的业务逻辑规则。
    • 如果任务很简单,有时我们会没有领域
    • 规则:域级别的代码仅取决于Go的功能,标准Go库和扩展Go语言的所选库
  2. 应用程序层包含由应用程序的任务规定的业务逻辑规则。
    • 规则:应用程序级别的代码可能取决于域
  3. 基础结构级别包含将应用程序与各种技术(用于存储(MySQL,Redis),传输(GRPC,HTTP),与外部环境和其他服务的交互)连接的基础结构代码。
    • 规则:基础架构级别的代码可能取决于域和应用程序
    • 规则:每个Go软件包仅一种技术
  4. 主程序包创建所有对象-“生命周期单例”,将它们连接在一起并启动寿命长的协程-例如,它开始处理来自端口8081的HTTP请求

这是微服务目录树的外观(Go代码所在的部分):


图片:转到项目树


对于每个应用程序上下文(模块),包结构如下所示:


  • 应用程序包声明一个Service接口,其中包含在给定级别上实现服务结构接口和func NewService(...) Service函数func NewService(...) Service所有可能操作func NewService(...) Service
  • 由于域或应用程序包声明了存储库接口,因此实现了数据库工作的隔离,该接口是在包中的基础结构级别以可视名称“ mysql”实现的
  • 运输代码位于infrastructure/transport包中
    • 我们使用GRPC,因此服务器存根是从原始文件中生成的(即服务器接口,响应/请求结构以及所有客户端交互代码)

所有这些都显示在图中:


图片:Go项目包装图


错误处理原则


这里的一切都很简单:


  1. 我们认为在处理对API的请求时会发生错误和紧急情况-这意味着错误或紧急情况只会影响一个请求
  2. 我们认为日志仅用于事件分析(并且有用于调试的调试器),因此,在日志中接收到有关请求的信息,首先是处理请求时出现意外错误
  3. 我们相信,整个基础架构都是为处理日志而构建的(例如,基于ELK)-微服务在其中扮演着被动的角色,将日志写入stderr

我们不会关注于紧急情况:只是不要忘记在每个goroutine中处理紧急情况,并且在处理每个请求,每个消息,由请求启动的每个异步任务的过程中。 恐慌几乎总是会变成错误,以防止整个应用程序完成。


成语前哨错误


在业务逻辑级别,仅处理由业务规则定义的预期错误。 前哨错误将帮助您识别此类错误-我们使用此惯用语,而不是为错误编写我们自己的数据类型。 一个例子:


 package app import "errors" var ErrNoCake = errors.New("no cake found") 

这里声明了一个全局变量,根据我们的先生的同意,我们不应在任何地方进行更改。 如果您不喜欢全局变量并使用linter来检测它们,则可以使用一些常量,如Dave Cheney在“ 常量错误”帖子中建议的那样:


 package app type Error string func (e Error) Error() string { return string(e) } const ErrNoCake = Error("no cake found") 

如果您喜欢这种方法,则可能需要将ConstError类型添加到公司的Go语言库中。

错误的构成


前哨错误的主要优点是可以轻松编写错误。 特别是在创建错误或从外部接收错误时,最好向其添加stacktrace。 为此,有两种流行的解决方案。


  • xerrors软件包,该软件包在Go 1.13中将作为实验包含在标准库中
  • Dave Cheney的github.com/pkg/errors
    • 包装是冷冻的,不会膨胀,但是还是不错的

我们的团队仍然使用github.com/pkg/errorserrors.WithStack函数(当我们除了stacktrace之外没有其他可添加的东西)或errors.Wrap (当我们对这个错误有errors.Wrap时)。 这两个函数都在输入处接受错误并返回新的错误,但是具有堆栈跟踪。 基础结构层的示例:


 package mysql import "github.com/pkg/errors" func (r *repository) FindOne(...) { row := r.client.QueryRow(sql, params...) switch err := row.Scan(...) { case sql.ErrNoRows: //     stacktrace return nil, errors.WithStack(app.ErrNoCake) } } 

我们建议每个错误只包装一次。 如果遵循规则,这很容易做到:


  • 任何外部错误都将打包到一个基础结构软件包中
  • 由业务逻辑规则生成的任何错误都将在创建时由stacktrace进行补充

错误的根本原因


预期将所有错误分为预期错误和意外错误。 要处理预期的错误,您需要摆脱合成的影响。 xerrors和github.com/pkg/errors软件包具有您所需的一切:尤其是errors软件包具有errors.Cause函数,该函数返回错误的根本原因。 循环执行此函数,一个接一个,检索较早的错误,而下一个提取的错误具有Cause() error方法。


我们从中提取根本原因并将其与前哨错误直接进行比较的示例:


 func (s *service) SaveCake(...) error { state, err := s.repo.FindOne(...) if errors.Cause(err) == ErrNoCake { err = nil // No cake is OK, create a new one // ... } else if err != nil { // ... } } 

延误处理


也许您正在使用linter,这使您可以手动检查所有错误。 在这种情况下,当linter要求您使用.Close()方法和仅defer其他方法来检查错误时,您可能会很生气。 您是否曾经尝试过正确地延迟处理错误,尤其是在此之前还有其他错误的情况下? 而且我们已经尝试并急于分享食谱。


想象一下,我们与数据库的所有工作都是严格通过事务进行的。 根据依赖关系规则,应用程序和域级别不应直接或间接依赖于基础结构和SQL技术。 这意味着在应用程序和域级别没有单词“ transaction”


最简单的解决方案是将“交易”一词替换为抽象的东西。 这样就产生了工作单位模式。 在我们的实现中,应用程序包中的服务通过UnitOfWorkFactory接口接收工厂,并在每次操作期间创建一个隐藏事务的UnitOfWork对象。 UnitOfWork对象允许您获取存储库。


有关UnitOfWork的更多信息

为了更好地理解工作单元的用法,请看一下该图:


Image Go工作单元


  • 存储库表示已定义类型的对象(例如,域级聚合)的抽象持久性集合
  • UnitOfWork隐藏事务并创建存储库对象
  • UnitOfWorkFactory仅允许服务创建新交易,而无需了解任何交易。

为每个操作(即使最初是原子操作)创建事务都不过分吗? 由您决定; 我们认为,保持业务逻辑的独立性比节省创建事务更为重要。


是否可以将UnitOfWork和存储库结合在一起? 有可能,但我们认为这违反了单一责任原则。


界面如下所示:


 type UnitOfWork interface { Repository() Repository Complete(err *error) } 

UnitOfWork接口提供了Complete方法,该方法采用一个in-out参数:指向错误接口的指针。 是的,它是指针,它是in-out参数-在任何其他情况下,调用方的代码都将更加复杂。


使用unitOfWork的示例操作:


注意:错误必须声明为命名返回值。 如果您使用局部变量err而不是命名的返回值err,则不能延后使用它! 而且还没有一个短绒棉能够检测到这种情况-见批评家#801

 func (s *service) CookCake() (err error) { unitOfWork, err := s.unitOfWorkFactory.New() if err != nil { return err } defer unitOfWork.Complete(&err) repo := unitOfWork.Repository() } // ...   

这样就完成了 交易 工作单位:


 func (u *unitOfWork) Complete(err *error) { if *err == nil { //     -  commit txErr := u.tx.Commit() *err = errors.Wrap(txErr, "cannot complete transaction") } else { //    -  rollback txErr := return u.tx.Rollback() //  rollback   ,    *err = mergeErrors(*err, errors.Wrap(txErr, "cannot rollback transaction")) } } 

mergeErrors函数合并两个错误,但是它处理nil时没有问题,而不是一个或两个错误。 同时,我们认为这两个错误都发生在不同阶段执行一个操作的过程中,第一个错误更为重要-因此,当两个错误都不为零时,我们将保存第一个错误,而从第二个错误中仅保存消息:


 package errors func mergeErrors(err error, nextErr error) error { if err == nil { err = nextErr } else if nextErr != nil { err = errors.Wrap(err, nextErr.Error()) } return err } 

也许您应该将mergeErrors函数添加到Go的公司库中。

记录子系统


文章清单:在prod中启动微服务之前,您必须做什么


  • 日志以stderr编写
  • 日志应使用JSON,每行一个紧凑的JSON对象
  • 应该有一组标准的字段:
    • timestamp-事件时间(以毫秒为单位) ,最好采用RFC 3339格式(例如:“ 1985-04-12T23:20:50.52Z”)
    • 级别-重要性级别,例如“信息”或“错误”
    • app_name-应用程序名称
    • 和其他领域

我们更愿意在错误消息中添加两个字段: "error""stacktrace"


我们使用了许多Golang语言的优质日志记录库,例如sirupsen / logrus 。 但是我们不直接使用该库。 首先,在我们的log包中,我们将过度扩展的库接口简化为一个Logger接口:


 package log type Logger interface { WithField(string, interface{}) Logger WithFields(Fields) Logger Debug(...interface{}) Info(...interface{}) Error(error, ...interface{}) } 

如果程序员要编写日志,则必须从外部获取Logger接口,并且应该在基础结构级别(而不是应用程序或域)上完成此操作。 记录器界面简洁明了:


  • 正如文章所建议的,它减少了调试,信息和错误的严重性级别的数量
  • 它为Error方法引入了特殊规则:该方法始终接受错误对象

这样的严格性可以指导程序员朝正确的方向:如果有人想对日志记录系统本身进行改进,那么他应该考虑到他们收集和处理的整个基础结构,而这仅从微服务开始(通常在Kibana和Zabbix)。


但是,日志包中还有另一个接口,允许您在发生致命错误时中断程序,因此只能在主包中使用:


 package log type MainLogger interface { Logger FatalError(error, ...interface{}) } 

Jsonlog包


jsonlog包中实现Logger接口,该接口配置logrus库并对其进行抽象处理。 示意图如下所示:


图像记录仪仪包装图


专有软件包允许您连接微服务的需求(由log.Logger接口表示),logrus库的功能以及基础架构的功能(日志记录)。


例如,我们使用ELK(Elastic Search,Logstash,Kibana),因此在jsonlog包中,我们:


  • 设置logrus的logrus.JSONFormatter格式
    • 同时,我们设置FieldMap选项,通过该选项,我们将"time"字段转换为"@timestamp" ,将"msg"字段转换为"message"
  • 选择日志级别
  • 添加一个挂钩,该挂钩从传递给Error(error, ...interface{})方法的Error(error, ...interface{})对象中提取stacktrace

微服务在主要功能中初始化记录器:


 func initLogger(config Config) (log.MainLogger, error) { logLevel, err := jsonlog.ParseLevel(config.LogLevel) if err != nil { return nil, errors.Wrap(err, "failed to parse log level") } return jsonlog.NewLogger(&jsonlog.Config{ Level: logLevel, AppName: "cookingservice" }), nil } 

使用中间件进行错误处理和记录


我们正在Go上的微服务中切换到GRPC。 但是,即使您使用HTTP API,一般原则也适用于您。


首先,错误处理和日志记录应该在负责传输的程序包中的infrastructure级别上进行,因为正是他将传输协议规则的知识与app.Service接口app.Service知识相结合。 回想一下包关系如何:


图像GRPC封装图


使用中间件模式可以方便地处理错误和维护日志(中间件是Golang和Node.js世界中Decorator模式的名称):


在哪里添加中间件? 应该有几个?


有多种添加中间件的选项,您可以选择:


  • 您可以装饰app.Service接口,但我们不建议您这样做,因为此接口不会接收传输层信息,例如客户端IP
  • 使用GRPC,您可以在所有请求上挂一个处理程序(更确切地说,两个处理程序-一元和Steam),但是所有API方法将以相同的样式和相同的字段集记录
  • 使用GRPC,代码生成器为我们创建了一个服务器接口,在其中我们称为app.Service方法-我们修饰了该接口,因为它具有传输级信息以及以不同方式记录不同API方法的能力

示意图如下所示:


图像GRPC中间件包装图


您可以创建不同的中间件来进行错误处理(和紧急处理)以及进行日志记录。 您可以将所有内容合而为一。 我们将考虑一个示例,其中所有内容都跨入一个中间件,该中间件是这样创建的:


 func NewMiddleware(next api.BackendService, logger log.Logger) api.BackendService { server := &errorHandlingMiddleware{ next: next, logger: logger, } return server } 

我们将api.BackendService接口作为api.BackendService并进行装饰,将api.BackendService接口的实现作为api.BackendService


中间件中的任意API方法实现如下:


 func (m *errorHandlingMiddleware) ListCakes( ctx context.Context, req *api.ListCakesRequest) (*api.ListCakesResponse, error) { start := time.Now() res, err := m.next.ListCakes(ctx, req) m.logCall(start, err, "ListCakes", log.Fields{ "cookIDs": req.CookIDs, }) return res, translateError(err) } 

在这里,我们执行三个任务:


  1. 调用装饰对象的ListCakes方法
  2. 我们logCall方法,将所有重要信息传递给其中,包括单独选择的属于日志的字段集
  3. 最后,我们通过调用translateError替换错误。

错误翻译将在后面讨论。 logCalllogCall方法执行,该方法仅调用正确的Logger接口方法:


 func (m *errorHandlingMiddleware) logCall(start time.Time, err error, method string, fields log.Fields) { fields["duration"] = fmt.Sprintf("%v", time.Since(start)) fields["method"] = method logger := m.logger.WithFields(fields) if err != nil { logger.Error(err, "call failed") } else { logger.Info("call finished") } } 

错误翻译


我们必须找出错误的根本原因,并将其变成在传输级别可以理解并记录在服务API中的错误。


在GRPC中,这很简单-使用status.Errorf函数创建带有状态码的错误。 如果您具有HTTP API(REST API),则可以创建自己的错误类型, 应用程序和域级别不应该知道。


初步近似,错误翻译如下:


 // ! ! -   err  status.Error func translateError(err error) error { switch errors.Cause(err) { case app.ErrNoCake: err = status.Errorf(codes.NotFound, err.Error()) default: err = status.Errorf(codes.Internal, err.Error()) } return err } 

验证输入参数时,修饰后的接口可以返回状态错误。状态类型带有状态代码,而第一个版本的translateError将丢失此状态代码。


让我们通过强制转换为接口类型(鸭子长寿!)来改进版本:


 type statusError interface { GRPCStatus() *status.Status } func isGrpcStatusError(er error) bool { _, ok := err.(statusError) return ok } func translateError(err error) error { if isGrpcStatusError(err) { return err } switch errors.Cause(err) { case app.ErrNoCake: err = status.Errorf(codes.NotFound, err.Error()) default: err = status.Errorf(codes.Internal, err.Error()) } return err } 

为您的微服务中的每个上下文(独立模块)分别创建了translateError函数,并将业务逻辑错误转换为传输级错误。


总结一下


我们为您提供一些规则来处理错误和处理日志。 是否遵循它们取决于您。


  1. 遵循Clean Architecture的原则,不要直接或间接破坏依赖关系规则。 业务逻辑应仅取决于编程语言,而不取决于外部技术。
  2. 使用提供错误组合和堆栈跟踪创建的程序包。 例如,“ github.com/pkg/errors”或xerrors包,它将很快成为Go标准库的一部分。
  3. 请勿在微服务中使用第三方日志记录库-使用log和jsonlog包创建自己的库,这将隐藏日志记录实现的详细信息
  4. 使用中间件模式来处理错误并在程序的基础结构级别的传输方向上写日志

在这里,我们没有提及查询跟踪技术(例如OpenTracing),指标监视(例如数据库查询性能)以及其他类似日志记录的内容。 您自己会处理的,我们相信您。

Source: https://habr.com/ru/post/zh-CN459130/


All Articles