Errorx-在Go中处理错误的库

什么是Errorx及其有用


Errorx是用于处理Go中错误的库。 它提供了用于解决大型项目中与错误机制相关的问题的工具,以及用于处理它们的单一语法。


图片


自公司成立以来,大多数Joom服务器组件都是使用Go编写的。 在开发的最初阶段和服务的生命周期中,这种选择是有回报的,并且根据有关Go 2前景的公告 ,我们确信我们将来不会后悔。 Go的主要优点之一就是简单性,而错误处理方法也证明了这一原理。 并非每个项目都能达到足够的规模,因此标准库的功能还不够,这促使您在此领域中寻找自己的解决方案。 我们碰巧在处理错误的方法上进行了一些改进,而errorx库反映了这种改进的结果。 我们坚信,它对许多人都是有用的,包括那些尚未为处理项目错误而感到极大不适的人。


Go中的错误


在继续介绍有关errorx的故事之前,应进行一些澄清。 最后,这些错误是怎么回事?


type error interface { Error() string } 

很简单,对吧? 在实践中,实现通常实际上只携带错误的字符串描述。 这种极简主义与一种方法有关,根据这种方法,错误不一定意味着“异常”。 最常用的错误。标准库中的New()就是这种想法:


 func New(text string) error { return &errorString{text} } 

如果我们回想起一种语言中的错误没有特殊地位,而是普通对象,那么就会出现一个问题:与它们一起工作的特殊性是什么?


错误也不例外 。 众所周知,当许多人熟悉Go时,他们会遇到一些阻力而遇到这种分歧。 有很多出版物,对Go中选择的方法进行了解释,支持和批评。 Go中的一种或多种错误有多种用途,其中至少一种与其他某些语言中的异常完全相同:故障排除。 因此,即使与它们使用相关的方法和语法有很大不同,也很自然地期望它们具有相同的表达能力。


怎么了


许多项目按原样利用Go中的错误,对此没有丝毫困难。 但是,随着系统复杂性的增加,即使没有很高的期望,许多问题也开始引起人们的关注。 一个很好的例子是服务日志中的类似行:


Error: duplicate key


在这里,第一个问题立即变得显而易见:如果您不故意处理此问题,那么在某种程度的大型系统中,几乎不可能仅凭初始消息来了解出了什么问题。 这篇文章缺少详细信息和问题的更广泛的上下文。 这是程序员的错误,但经常发生而忽略它。 相对于与执行中断或外部问题相关的“负”代码,专用于控制图“正”分支的代码在实践中总是值得更多关注,并且可以更好地被测试覆盖。 在Go程序中重复if err != nil {return err}频率使这种监督的可能性更大。


作为一个小题外话,请考虑以下示例:


 func (m *Manager) ApplyToUsers(action func(User) (*Data, error), ids []UserID) error { users, err := m.LoadUsers(ids) if err != nil { return err } var actionData []*Data for _, user := range users { data, err := action(user) if err != nil { return err } ok, err := m.validateData(data) if err != nil { return nil } if !ok { log.Error("Validation failed for %v", data) continue } actionData = append(actionData, data) } return m.Apply(actionData) } 

您在此代码中看到错误的速度有多快? 但这可能至少由任何Go程序员完成了一次。 提示: if err != nil { return nil } ,则表达式中的if err != nil { return nil }


如果我们在日志中出现提示信息来返回问题,那么在这种情况下,当然,每个人也都会发生。 在出现问题时就已经开始修复错误处理代码,这非常令人不快。 另外,根据日志中的初始数据,完全不清楚从哪一侧开始搜索该部分代码,实际上,这有待改进。 对于代码量少且外部依赖项数量少的项目,这似乎是牵强附会的复杂性。 但是,对于大型项目,这是一个完全现实而痛苦的问题。


假设一个经验丰富的程序员想在返回的错误之前预先添加上下文。 天真的方法是这样的:


 func InsertUser(u *User) error { err := usersTable.Insert(u) if err != nil { return errors.New(fmt.Sprintf("failed to insert user %s: %v", u.Name, err) } return nil } 

好了 更广泛的上下文仍然不清楚,但是现在至少更容易找到发生错误的代码。 但是,解决了一个问题后,我们无意中创建了另一个问题。 此处创建的错误使诊断消息保持原始状态,但是其他所有信息(包括其类型和其他内容)都丢失了。


要了解为什么这样做很危险,请考虑数据库驱动程序中的类似代码:


 var ErrDuplicateKey = errors.New("duplicate key") func (t *Table) Insert(entity interface{}) error { // returns ErrDuplicateKey if a unique constraint is violated by insert } func IsDuplicateKeyError(err error) bool { return err == ErrDuplicateKey } 

现在, IsDuplicateKeyError()检查已销毁,尽管在将文本添加到错误时,我们无意更改其语义。 反过来,这将破坏依赖于此检查的代码:


 func RegisterUser(u *User) error { err := InsertUser(u) if db.IsDuplicateKeyError(err) { // find existing user, handle conflict } else { return err } } 

如果我们想做得更聪明,并添加自己的错误类型,该错误类型将存储原始错误并能够通过例如Cause() error方法将其返回,那么我们也将仅部分解决问题。


  1. 现在,您需要知道错误的真正原因在于Cause() ,而不是错误处理
  2. 无法向外部库教授此知识,并且编写在其中的帮助程序功能将无用
  3. 我们的实现可以期望Cause()返回错误的直接原因(如果不是,则返回nil),而另一个库中的实现则期望该方法返回错误的根本原因为非nil; 缺乏标准工具或普遍接受的合同会给人们带来非常不愉快的惊喜

但是,这种部分解决方案已在许多错误库中使用,其中包括某种程度上的错误库。 Go 2中有计划推广这种方法-如果发生这种情况,将更容易解决上述问题。


错误码


下面我们将讨论errorx所提供的功能,但首先尝试提出构成库基础的注意事项。


  • 诊断比节省资源更为重要。 创建和显示错误的性能很重要。 但是,它们代表的是消极而非积极的途径,并且在大多数情况下,它们充当问题的信号,因此,错误中存在诊断信息甚至更为重要。
  • 默认情况下,堆栈跟踪。 为了使错误在诊断过程中完全消失,无需付出任何努力。 相反,恰恰是为了某些信息(出于简洁或出于性能原因)排除了可能需要采取其他措施的信息。
  • 错误的语义。 应该有一种简单可靠的方法来检查错误的含义:错误的类型,种类,属性。
  • 易于添加。 向通过的错误添加诊断信息应该很简单,并且不应破坏其语义的验证。
  • 简单性。 专门用于错误的代码通常是按惯例编写的,因此对它们进行基本操作的语法应简单明了。 这减少了错误的数量,并使其更易于阅读。
  • 少即是多。 代码的可理解性和统一性比可选功能和扩展选项(可能没有人会使用)更重要。
  • 错误语义是API的一部分。 需要在调用代码中进行单独处理的错误实际上是公共API包的一部分。 您无需尝试隐藏它或使其不那么显式,但是可以使处理更方便,并且外部依赖项也较不易碎。
  • 大多数错误是不透明的。 外部用户无法区别的错误类型越多越好。 加载需要特殊处理的API类型的错误,以及将错误本身与处理错误所需的数据一起加载是设计缺陷,应避免使用。

对于我们而言,最困难的问题是可扩展性:errorx是否应提供用于创建行为上任意不同的自定义错误类型的原语,或者是否存在允许您将所需的所有东西都拿出来的实现? 我们选择了第二个选项。 首先,errorx解决了一个非常实际的问题-我们使用它的经验表明,为此目的,最好有一个解决方案,而不是制造它的备件。 其次,关于简单性的考虑非常重要:由于对错误的关注较少,因此代码的设计方式必须使其难以使用。 实践表明,为此,所有此类代码的外观和工作方式都必须相同。


TL; DR按主要库功能:


  • 默认情况下,堆栈跟踪创建位置中的所有错误
  • 对错误进行类型检查,几种类型
  • 在不破坏任何内容的情况下向现有错误添加信息的能力
  • 如果要向呼叫者隐藏原始原因,请键入可见性控件
  • 错误处理代码泛化机制(类型层次结构,特征)
  • 通过动态属性自定义错误
  • 标准错误类型
  • 语法实用程序,可提高错误处理代码的可读性

引言


如果我们使用errorx重做以上分析的示例,则会得到以下结果:


 var ( DBErrors = errorx.NewNamespace("db") ErrDuplicateKey = DBErrors.NewType("duplicate_key") ) func (t *Table) Insert(entity interface{}) error { // ... return ErrDuplicateKey.New("violated constraint %s", details) } func IsDuplicateKeyError(err error) bool { return errorx.IsOfType(err, ErrDuplicateKey) } 

 func InsertUser(u *User) error { err := usersTable.Insert(u) if err != nil { return errorx.Decorate(err, "failed to insert user %s", u.Name) } return nil } 

使用IsDuplicateKeyError()调用者代码不会更改。


在此示例中发生了什么变化?


  • ErrDuplicateKey成为一种类型,而不是错误的实例; 检查它是否可以防止复制错误;没有对精确相等性的脆弱依赖
  • 有一个用于数据库错误的名称空间。 它很可能会出现其他错误,并且这种分组有助于提高可读性,并且在某些情况下可以在代码中使用
  • 插入会为每个调用返回一个新错误:
    • 该错误包含更多详细信息。 当然,这可以在没有errorx的情况下实现,但是如果每次都返回相同的错误实例,则这是不可能的,这是IsDuplicateKeyError()以前所必需的
    • 这些错误可以带有不同的堆栈跟踪,这很有用,因为 并非对于所有对Insert函数的调用都可以接受这种情况
  • InsertUser()补充错误文本,但应用原始错误,该错误将完整保留下来以用于后续操作
  • IsDuplicateKeyError()现在可以正常工作:既不能通过复制错误也不能破坏它,也不能像使用Decorate()那样随意分层

不必总是遵循这样的方案:


  • 错误的类型远非总是唯一的:在很多地方都可以使用相同的类型
  • 如果需要,可以禁用堆栈跟踪收集,并且您不能每次都创建一个新错误,但是返回与原始示例相同的错误; 这些就是所谓的哨兵错误,我们不建议您使用它们,但是如果该错误仅用作代码中的标记,并且您希望节省创建对象的时间,那么这将很有用。
  • 如果想从窥探中隐藏根本原因的语义,有一种方法可以使errorx.IsOfType(err, ErrDuplicateKey)停止工作
  • 除了与确切类型进行比较以外,还有其他方法可以进行类型检查

Godoc包含有关所有这些的详细信息。 下面,我们将对主要功能进行更深入的介绍,这些功能足以应付日常工作。


种类


任何errorx错误都属于某种类型。 类型很重要,因为 继承的错误属性可以通过它传递; 必要时将通过他或他的特质进行语义测试。 此外,该类型的表达性名称会补充错误消息,并且在某些情况下可能会替换它。


 AuthErrors = errorx.NewNamespace("auth") ErrInvalidToken = AuthErrors.NewType("invalid_token") 

 return ErrInvalidToken.NewWithNoMessage() 

错误消息将包含auth.invalid_token 。 错误声明可能看起来有所不同:


 ErrInvalidToken = AuthErrors.NewType("invalid_token").ApplyModifiers(errorx.TypeModifierOmitStackTrace) 

在该实施例中,使用类型修饰符,堆栈跟踪收集被禁用。 该错误具有标记语义:将其类型提供给服务的外部用户,并且日志中的调用堆栈将无用,因为 这不是要修复的问题。


在这里,我们可以保留一个错误,即错误在多个方面具有双重性质。 错误的内容不仅用于诊断,有时还用作外部用户的信息:API客户端,库用户等。 错误在代码中既用作传达所发生事件的语义的手段,又用作转移控制的机制。 使用错误类型时,应牢记这一点。


错误产生


 return MyType.New("fail") 

为每个错误获取自己的类型是完全可选的。 任何项目都可以拥有自己的通用错误包,并且某些集与errorx一起作为公共命名空间的一部分提供。 它包含大多数情况下不涉及代码处理的错误,并且适合出现错误的“异常”情况。


 return errorx.IllegalArgument.New("negative value %d", value) 

在典型情况下,会设计一个调用链,以便在链的最末端创建错误,并在最开始处进行处理。 在Go语言中,并非没有理由将错误两次处理为错误形式,例如,将错误写入日志并将其返回堆栈更高的位置。 但是,您可以在错误信息本身之前添加信息:


 return errorx.Decorate(err, "failed to upload '%s' to '%s'", filename, location) 

添加到错误的文本将出现在日志中,但检查原始错误的类型不会受到损害。


有时会产生相反的需求:无论错误的性质如何,程序包的外部用户都不应该知道。 如果他有这样的机会,他可能会对实施的一部分产生脆弱的依赖。


 return service.ErrBadRequest.Wrap(err, "failed to load user data") 

使Wrap成为New的首选替代方案的一个重要区别是原始错误已完全反映在日志中。 特别是它将带来有用的初始调用堆栈。


另一个有用的技巧使您可以保存有关调用堆栈的所有可能信息,如下所示:


 return errorx.EnhanceStackTrace(err, "operation fail") 

如果原始错误来自另一个goroutine,则此调用的结果将包含两个goroutine的堆栈跟踪,这反过来会增加其实用性。 进行此调用的需求显然是由于性能问题而引起的:这种情况相对较少,而人体工程学本身会检测到它,这会使通常不需要的Wrap变慢。


Godoc包含更多信息,还介绍了DecorateMany等其他功能。


错误处理


最好将错误处理归结为以下内容:


 log.Error("Error: %+v", err) 

除了将错误打印到项目系统层的日志中之外,您需要执行的错误越少越好。 实际上,这有时还不够,您必须这样做:


 if errorx.IsOfType(err, MyType) { /* handle */ } 

此检查将在类型为MyType的错误及其子类型上均成功,并且可以抵抗errorx.Decorate() 。 但是,这里直接依赖于错误的类型,这在包内是很正常的,但是如果在包外使用可能会令人不快。 在某些情况下,此类错误的类型是稳定的外部API的一部分,有时我们希望将其替换为属性检查,而不是确切的错误类型。


在经典的Go错误中,这将通过接口执行,该接口的类型强制转换为错误类型的指示。 Errorx类型不支持此扩展,但是可以改用Trait机制。 例如:


 func IsTemporary(err error) bool { return HasTrait(err, Temporary()) } 

errorx内置的此功能可检查错误是否具有标准属性Temporary ,即 是否是临时的。 用特征标记错误的类型是错误源的责任,并且通过错误可以传递有用的信号,而无需将特定内部类型作为外部API的一部分。


 return errorx.IgnoreWithTrait(err, errorx.NotFound()) 

当需要某种错误来中断控制流但不应将其传递给调用函数时,此语法很有用。


尽管有很多处理工具,但此处未列出所有处理工具,但重要的是要记住,错误处理应保持尽可能简单。 我们尝试遵守的规则示例:


  • 收到错误的代码应始终完整记录下来; 如果部分信息是多余的,请让产生错误的代码来解决此问题
  • 您永远不要使用错误文本或Error()函数的结果在代码中对其进行处理; 仅类型/特征检查适用于此,或在非错误错误的情况下进行类型声明
  • 用户代码不应因未以某种特殊方式处理某种错误而中断,即使可以进行这种处理并为其提供了附加功能
  • 通过属性检查的错误比所谓的哨兵错误更好,因为 这样的支票不那么脆弱

外部错误x


在这里,我们开箱即用地描述了库用户可以使用的功能,但是在Joom中,与错误相关的代码的渗透率非常大。 日志记录模块显式接受其签名中的错误,并进行打印以消除格式错误的可能性,并从错误链中提取可选的可用上下文信息。 负责与goroutins进行恐慌安全工作的模块会在出现恐慌的情况下解压缩错误,并且知道如何使用错误语法来呈现恐慌而不会丢失原始堆栈跟踪。 其中一些,也许我们还将发布。


相容性问题


尽管我们对errorx如何允许我们处理错误感到非常满意,但是专门用于此主题的库代码的情况远非理想。 我们在Joom上使用errorx解决了相当具体的实际问题,但是从Go生态系统的角度来看,最好在标准库中拥有整套工具。 错误的来源实际上或潜在地属于另一个范式,必须将其视为外来的,即 可能不会以项目接受的形式携带信息。


但是,已做一些事情以免与其他现有解决方案冲突。


格式'%+v'用于打印错误以及堆栈跟踪(如果存在)。 这是Go生态系统中的事实上的标准,甚至包含在Go 2的设计草案中。


Cause() error errorx , , , Causer, errorx Wrap().



, Go 2, . .


, errorx Go 1. , Go 2, . , , errorx.


Check-handle , errorx , a Unwrap() error Wrap() errorx (.. , , Wrap ), . , , .


design draft Go 2, errorx.Is() errorx.As() , errors .


结论


, , , - , . , API : , , . 1.0 , Joom. , - .


: https://github.com/joomcode/errorx


, !


图片

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


All Articles