使用并发代码,特别是分布式系统,在系统中很容易出现问题,而且很难确认发生这种问题的原因。仔细考虑问题是如何通过系统传播的,以及如何最终呈现给用户,你会为自己,团队和用户减少很多痛苦。 在“错误处理”一节中,我们讨论了如何从goroutine处理错误,但我们没有花时间讨论这些错误应该是什么样子,或者错误应该如何流经一个庞大而复杂的系统。让我们花点时间来讨论错误传递的哲学。
许多开发人员认为错误传递是不值得关注的,或者,至少不是首先需要关注的。 Go试图通过强制开发者在调用堆栈中的每一帧处理错误来纠正这种不良做法。
首先让我们看看错误的定义。错误何时发生,以及错误会提供什么。
错误表明您的系统已进入无法完成用户明确或隐含请求的操作的状态。 因此,它需要传递一些关键信息:
发生了什么
这是错误的一部分,其中包含有关所发生事件的信息,例如“磁盘已满”,“套接字已关闭”或“凭证过期”。尽管生成错误的内容可能会隐式生成此信息,你可以用一些能够帮助用户的上下文来完善它。
何时何处发生
错误应始终包含一个完整的堆栈跟踪,从调用的启动方式开始,直到实例化错误。
此外,错误应该包含有关它正在运行的上下文的信息。 例如,在分布式系统中,它应该有一些方法来识别发生错误的机器。当试图了解系统中发生的情况时,这些信息将具有无法估量的价值。
另外,错误应该包含错误实例化的机器上的时间,以UTC表示。
有效的信息说明
显示给用户的消息应该进行自定义以适合你的系统及其用户。它只应包含前两点的简短和相关信息。 一个友好的信息是以人为中心的,给出一些关于这个问题的指示,并且应该是关于一行文本。
如何获取更详细的错误信息
在某个时刻,有人可能想详细了解发生错误时的系统状态。提供给用户的错误信息应该包含一个ID,该ID可以与相应的日志交叉引用,该日志显示错误的完整信息:发生错误的时间(不是错误记录的时间),堆栈跟踪——包括你在代码中自定义的信息。包含堆栈跟踪的哈希也是有帮助的,以帮助在bug跟踪器中汇总类似的问题。
默认情况下,没有人工干预,错误所能提供的信息少得可怜。 因此,我们可以认为:在没有详细信息的情况下传播给用户任何错误的行为都是错误的。因为我们可以使用搭建框架的思路来对待错误处理。可以将所有错误归纳为两个类别:
- Bug。
- 已知业务及系统意外(例如,网络连接断开,磁盘写入失败等)。
Bug是你没有为系统定制的错误,或者是“原始”错误。有时这是故意的,如果在系统多次迭代时出现的错误,尽快不可避免的传递给了用户,但接受到用户反馈后对提高系统健壮性并不是坏处。有时这是偶然的。在确定如何传播错误,系统随着时间的推移如何增长以及最终向用户展示什么时,这种区别将证明是有用的。
想象下一个巨大的系统,包含了很多模块:
假设在“Low Level Component”中发生错误,并且我们已经制作了一个格式良好的错误,并传递给堆栈。 在“Low Level Component”的背景下,这个错误可能被认为是合理的,但在我们的系统中,它可能不是。 让我们看看在每个组件的边界处,所有传入的错误都必须包含在我们代码所在组件的格式错误中。 例如,如果我们处于“Intermediary Component”,并且我们从“Low Level Component”调用代码,这可能会出错,我们可以使用:
func PostReport(id string) error {
result, err := lowlevel.DoWork()
if err != nil {
if _, ok := err.(lowlevel.Error); ok { //1
err = WrapErr(err, "cannot post report with id %q", id) //2
}
// ...
}
}
- 我们在这里断言以确定是我们自定义的错误。如果不是,我们会简单的把err传递给堆栈表明这里发生的错误是个bug。
- 在这里,我们使用函数将传入的错误与我们模块的相关信息进行封装,并给它一个新类型。请注意,包装错误可能隐藏一些底层细节。
在错误最初被实例化时,错误发生时的底层细节是存在于错误信息中的。在我们的示例中,模块的边界处我们将错误包装起来,不属于我们定义的错误类型的错误都被视为格式错误。请注意,实际工作中建议只以你自己的模块边界(公共函数/方法)或代码添加有价值的上下文时以这种方式包装错误。
采取这种立场可以让我们的系统有机地发展。 我们可以确定传入的错误是正确的,反过来可以确保考虑错误如何离开模块。错误正确性成为我们系统的一个新特性。通过这样做,我们给出了一个思想上的框架,通过呈现给用户的内容明确划分了错误的类型。
所有的错误都应该记录下尽可能多的信息。 但是,当向用户显示错误时,就需要尽可能的清晰明了。
当我们的代码发现到一个格式良好的错误时,我们可以确信,在代码中的所有级别上,都意识到了该错误的存在,而且已经将其记录下来并打印出来供用户查看。
当错误传播给用户时,我们记录错误,同时向用户显示一条友好的消息,指出发生了意外事件。如果我们的系统中支持自动错误报告,那是最好不过的事情。如果不支持,应当建议用户提交一个错误报告。请注意,任何微小的错误都会包含有用的信息,即使我们无法保证面面俱到。
请记住,在任何一种情况下,如果出现错误或格式错误,我们将在邮件中包含一个日志ID,以便在需要更多信息时可以参考。
我们来看一个完整的例子。 这个例子不会非常健壮(例如,错误类型可能是简单化的),并且调用堆栈是线性的,但不妨碍大家来理清思路:
type MyError struct {
Inner error
Message string
StackTrace string
Misc map[string]interface{}
}
func wrapError(err error, messagef string, msgArgs ...interface{}) MyError {
return MyError{
Inner: err, //1
Message: fmt.Sprintf(messagef, msgArgs...),
StackTrace: string(debug.Stack()), //2
Misc: make(map[string]interface{}), //3
}
}
func (err MyError) Error() string {
return err.Message
}
- 在这里存储我们正在包装的错误。 如果需要调查发生的事情,我们总是希望能够查看到最低级别的错误。
- 这行代码记录了创建错误时的堆栈跟踪。
- 这里我们创建一个杂项信息存储字段。可以存储并发ID,堆栈跟踪的hash或可能有助于诊断错误的其他上下文信息。
接下来,我们建立一个名为 lowlevel 的模块:
// "lowlevel" module
type LowLevelErr struct {
error
}
func isGloballyExec(path string) (bool, error) {
info, err := os.Stat(path)
if err != nil {
return false, LowLevelErr{wrapError(err, err.Error())} // 1
}
return info.Mode().Perm()&0100 == 0100, nil
}
- 在这里,我们用自定义错误来封装os.Stat中的原始错误。在这种情况下,我们不会掩盖这个错误产生的信息。
然后我们建立另一个名为 intermediate 的模块,它会调用 lowlevel 所在的包:
// "intermediate" module
type IntermediateErr struct {
error
}
func runJob(id string) error {
const jobBinPath = "/bad/job/binary"
isExecutable, err := isGloballyExec(jobBinPath)
if err != nil {
return err //1
} else if isExecutable == false {
return wrapError(nil, "job binary is not executable")
}
return exec.Command(jobBinPath, "--id="+id).Run() //1
}
- 我们传递来自 lowlevel 模块的错误,由于我们接收从其他模块传递的错误而没有将它们包装在我们自己的错误类型中,这将会产生问题。
最后,让我们创建一个调用intermediate包函数的顶级main函数:
func handleError(key int, err error, message string) {
log.SetPrefix(fmt.Sprintf("[logID: %v]: ", key))
log.Printf("%#v", err) //3
fmt.Printf("[%v] %v", key, message)
}
func main() {
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)
err := runJob("1")
if err != nil {
msg := "There was an unexpected issue; please report this as a bug."
if _, ok := err.(IntermediateErr); ok { //1
msg = err.Error()
}
handleError(1, err, msg) //2
}
}
- 在这里我们检查是否错误是预期的类型。 如果是,可以简单地将其消息传递给用户。
- 在这一行中,将日志和错误消息与ID绑定在一起。我们可以很容易增加这个增量,或者使用一个GUID来确保一个唯一的ID。
- 在这里我们记录完整的错误,以备需要深入了解发生了什么。
我们在运行后会在日志中发现:
[logID: 1]: 21:46:07 main.LowLevelErr{error:main.MyError{Inner: (*os.PathError)(0xc4200123f0),
Message:"stat /bad/job/binary: no such file or directory", StackTrace:"goroutine 1 [running]: runtime/debug.Stack(0xc420012420, 0x2f, 0xc420045d80)
/home/kate/.guix-profile/src/runtime/debug/stack.go:24 +0x79 main.wrapError(0x530200, 0xc4200123f0, 0xc420012420, 0x2f, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, ...)
/tmp/babel-79540aE/go-src-7954NTK.go:22 +0x62 main.isGloballyExec(0x4d1313, 0xf, 0xc420045eb8, 0x487649, 0xc420056050)
/tmp/babel-79540aE/go-src-7954NTK.go:37 +0xaa main.runJob(0x4cfada, 0x1, 0x4d4c35, 0x22)
/tmp/babel-79540aE/go-src-7954NTK.go:47 +0x48 main.main()
/tmp/babel-79540aE/go-src-7954NTK.go:67 +0x63 ", Misc:map[string]interface {}{}}}
并且标准输出会打印:
[1]There was an unexpected issue; please report this as a bug.
我们可以看到,在这个错误路径的某处,它没有正确处理,并且因为我们无法确定错误信息是否适合用户自行处理,所以我们输出一个简单的错误信息,指出意外事件发生了。如果回顾 lowlevel 模块,我们会发现错误发生的原因:我们没有包装来自 lowlevel 模块的错误。让我们纠正它:
// "intermediate" module
type IntermediateErr struct {
error
}
func runJob(id string) error {
const jobBinPath = "/bad/job/binary"
isExecutable, err := isGloballyExec(jobBinPath)
if err != nil {
return IntermediateErr{wrapError(err,
"cannot run job %q: requisite binaries not available", id)} //1
} else if isExecutable == false {
return wrapError(
nil,
"cannot run job %q: requisite binaries are not executable", id,
)
}
return exec.Command(jobBinPath, "--id="+id).Run()
}
- 在这里,我们现在使用自定义错误。我们想隐藏工作未运行原因的底层细节,因为这对于用户并不重要。
func handleError(key int, err error, message string) {
log.SetPrefix(fmt.Sprintf("[logID: %v]: ", key))
log.Printf("%#v", err)
fmt.Printf("[%v] %v", key, message)
}
func main() {
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)
err := runJob("1")
if err != nil {
msg := "There was an unexpected issue; please report this as a bug."
if _, ok := err.(IntermediateErr); ok {
msg = err.Error()
}
handleError(1, err, msg)
}
}
现在,当我们运行更新后的代码,会得到类似的日志:
[logID: 1]: 22:11:04 main.IntermediateErr{error:main.MyError
{Inner:main.LowLevelErr{error:main.MyError{Inner:(*os.PathError) (0xc4200123f0), Message:"stat /bad/job/binary: no such file or directory", StackTrace:"goroutine 1 [running]:
runtime/debug.Stack(0xc420012420, 0x2f, 0x0)
/home/kate/.guix-profile/src/runtime/debug/stack.go:24 +0x79 main.wrapError(0x530200, 0xc4200123f0, 0xc420012420, 0x2f, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, ...)
/tmp/babel-79540aE/go -src-7954DTN.go:22 +0xbb main.isGloballyExec(0x4d1313, 0xf, 0x4daecc, 0x30, 0x4c5800)
/tmp/babel-79540aE/go -src-7954DTN.go:39 +0xc5 main.runJob(0x4cfada, 0x1, 0x4d4c19, 0x22)
/tmp/babel-79540aE/go-src-7954DTN.go:51 +0x4b
main.main()
/tmp/babel-79540aE/go -src-7954DTN.go:71 +0x63
", Misc:map[string]interface {}{}}}, Message:"cannot run job \"1\": requisite binaries not available", StackTrace:"goroutine 1 [running]: runtime/debug.Stack(0x4d63f0, 0x33, 0xc420045e40)
/home/kate/.guix-profile/src/runtime/debug/stack.go:24 +0x79 main.wrapError(0x530380, 0xc42000a370, 0x4d63f0, 0x33, 0xc420045e40, 0x1, 0x1, 0x0, 0x0, 0x0, ...)
/tmp/babel-79540aE/go -src-7954DTN.go:22 +0xbb main.runJob(0x4cfada, 0x1, 0x4d4c19, 0x22)
/tmp/babel-79540aE/go -src-7954DTN.go:53 +0x356 main.main()
/tmp/babel-79540aE/go -src-7954DTN.go:71 +0x63 ", Misc:map[string]interface {}{}}}
错误信息变得十分明白:
[1]cannot run job "1": requisite binaries not available
这种实现方法与标准库的错误包兼容,此外你可以用你喜欢的任何方式来进行包装,并且自由度非常大。