什么是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 {
现在, IsDuplicateKeyError()
检查已销毁,尽管在将文本添加到错误时,我们无意更改其语义。 反过来,这将破坏依赖于此检查的代码:
func RegisterUser(u *User) error { err := InsertUser(u) if db.IsDuplicateKeyError(err) {
如果我们想做得更聪明,并添加自己的错误类型,该错误类型将存储原始错误并能够通过例如Cause() error
方法将其返回,那么我们也将仅部分解决问题。
- 现在,您需要知道错误的真正原因在于
Cause()
,而不是错误处理 - 无法向外部库教授此知识,并且编写在其中的帮助程序功能将无用
- 我们的实现可以期望
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 {
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) { }
此检查将在类型为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
, !
