Go Book / 3 Go Seniors / Go 并发编程:错误处理及错误传递

Go 并发编程:错误处理及错误传递

一、协程错误管理

我们在基础系列讲过Go程序开发中的错误处理规范,展示了几种函数执行中的错误返回问题,而在Go并发编程中,我们常常会忽略协程里面的错误处理问题,有时候,我们花了很多时间思考我们的各种流程将如何共享信息和协调,却忘记考虑如何优雅地处理错误。Go避开了流行的错误异常模型,Go认为错误处理非常重要,并且在开发程序时,我们应该像关注算法一样关注它,即错误处理也是业务流程的一部分~

思考错误处理时最根本的问题是,“应该由谁负责处理错误?” 在某些情况下,程序需要停止传递堆栈中的错误,并将它们处理掉,这样的操作应该何时执行呢? 在并发进程中,这样的问题变得愈发复杂。因为一个并发进程独立于其父进程或兄弟进程运行,所以可能很难推断出错误是如何产生的。

比如如下问题

checkStatus := func(done <-chan interface{}, urls ...string, ) <-chan *http.Response {
	responses := make(chan *http.Response)
	go func() {
		defer close(responses)
		for _, url := range urls {
			resp, err := http.Get(url)
			if err != nil {
				fmt.Println(err) //1
				continue
			}
			select {
			case <-done:
				return
			case responses <- resp:
			}
		}
	}()
	return responses
}

done := make(chan interface{})
defer close(done)

urls := []string{"https://www.baidu.com", "https://badhost"}
for response := range checkStatus(done, urls...) {
	fmt.Printf("Response: %v\n", response.Status)
}

上面的程序中,我们开一个协程从一个网络请求中返回响应,并通过一个通道把多次请求的响应信息发送给父协程,但里面忽略的网络请求有可能发生错误。如果其中某个请求失败,response信息为nil,然而父协程并不知道发生什么?

由此可见,即使开辟协程处理一段业务逻辑,我们也必须考虑子协程中发生的错误,并把错误传递给父协程。典型的做法是,我们需要为被调用的协程函数封装一个Result结构体作为返回数据。 例如:

type Result struct { //1
	Error    error
	Response *http.Response
}

把错误处理加入返回结果,改造后我们得以加强程序的健壮性,我们得以直到每个链接请求得到的结果以及错误信息。 具体改造如下:

checkStatus := func(done <-chan interface{}, urls ...string) <-chan Result { //2
	results := make(chan Result)
	go func() {
		defer close(results)
		for _, url := range urls {
			var result Result
			resp, err := http.Get(url)
			result = Result{Error: err, Response: resp} //3
			select {
			case <-done:
				return
			case results <- result: //4
			}
		}
	}()
	return results
}

done := make(chan interface{})
defer close(done)

// 尝试请求多个链接
errCount := 0
urls := []string{"a", "https://www.baidu.com", "b", "c", "d"}
for result := range checkStatus(done, urls...) {
	if result.Error != nil {
		fmt.Printf("error: %v\n", result.Error)
		errCount++
		if errCount >= 3 {
			fmt.Println("Too many errors, breaking!")
			break
		}
		continue
	}
	fmt.Printf("Response: %v\n", result.Response.Status)
}

总之,在并发协程里,不要忽略协程内部的错误处理,把结果和错误信息都返回给调用者。

二、并发系统中的错误传递

在上面的错误处理中,我们讨论了如何从Go协程处理错误,但我们没有提到这些错误应该是什么样子,或者错误应该如何流经一个庞大而复杂的系统。

许多开发人员认为错误传递是不值得关注的,或者,至少不是首先需要关注的。 Go试图通过强制开发者在调用堆栈中的每一帧处理错误来纠正这种不良做法。首先让我们看看错误的定义。错误何时发生,以及错误会提供什么。错误表明您的系统已进入无法完成用户明确或隐含请求的操作的状态。 因此,它需要传递一些关键信息:

  • 发生了什么? 这是错误的一部分,其中包含有关所发生事件的信息,例如“磁盘已满”,“套接字已关闭”或“凭证过期”。尽管生成错误的内容可能会隐式生成此信息,你可以用一些能够帮助用户的上下文来完善它。

  • 何时何处发生? 错误应始终包含一个完整的堆栈跟踪,从调用的启动方式开始,直到实例化错误。 此外,错误应该包含有关它正在运行的上下文的信息。 例如,在分布式系统中,它应该有一些方法来识别发生错误的机器。当试图了解系统中发生的情况时,这些信息将具有无法估量的价值。 另外,错误应该包含错误实例化的机器上的时间,以UTC表示。

  • 有效的信息说明? 显示给用户的消息应该进行自定义以适合你的系统及其用户。它只应包含前两点的简短和相关信息。 一个友好的信息是以人为中心的,给出一些关于这个问题的指示,并且应该是关于一行文本。

  • 如何获取更详细的错误信息? 在某个时刻,有人可能想详细了解发生错误时的系统状态。提供给用户的错误信息应该包含一个ID,该ID可以与相应的日志交叉引用,该日志显示错误的完整信息:发生错误的时间(不是错误记录的时间),堆栈跟踪——包括你在代码中自定义的信息。包含堆栈跟踪的哈希也是有帮助的,以帮助在bug跟踪器中汇总类似的问题。

默认情况下,没有人工干预,错误所能提供的信息少得可怜。 因此,我们可以认为:在没有详细信息的情况下传播给用户任何错误的行为都是错误的。因为我们可以使用搭建框架的思路来对待错误处理。 可以将所有错误归纳为两个类别:

  • 程序Bug:Bug是你没有为系统定制的错误,或者是“原始”错误。
  • 已知业务及系统意外:例如,网络连接断开,磁盘写入失败等。

我们知道一个健壮的系统总会分层构建,如在一个典型的web请求中,用户发出请求,系统的web API接受用户请求(高层),分析用户请求的业务模块,调用业务逻辑层处理用户逻辑(中间层),最后调用数据访问层操作用户持久数据(底层)。我们看到在系统的高中低分层中分别承当各自的业务计算功能,其中每一层都有可能出现错误。如果最底层出现错误,我们需要把错误向上传递,但最终呈现给用户的是什么呢?要知道,呈现给用户和开发人员的信息是很大区别的,对用户呈现的信息要尽量友好,而对开发人员呈现的信息要尽量完整详细,以便开发人员排查bug。所以你应该很容易就能想到方案:即对每一层每一种错误类别尽可能地自定义,并在错误由低到高传递时做适当的包装和日志记录

好了,讨论完错误信息在系统中传递应该具备什么要素后,我们来封装一个简单的错误实例:

// 自定义一个简单的错误信息结构体,其实现了go基本错误接口
type MyError struct {
	Inner      error
	Message    string
	StackTrace string
	Misc       map[string]interface{}
}
func (err MyError) Error() string {
	return err.Message
}

// 工具函数:错误信息在系统各模块传递时的“错误包装器”
func wrapError(err error, messagef string, msgArgs ...interface{}) MyError {
	return MyError{
		Inner:      err, // 存储我们正在包装的错误。 如果需要调查发生的事情,我们总是希望能够查看到最低级别的错误。
		Message:    fmt.Sprintf(messagef, msgArgs...),
		StackTrace: string(debug.Stack()),        // 记录了创建错误时的堆栈跟踪。
		Misc:       make(map[string]interface{}), // 创建一个杂项信息存储字段。可以存储并发ID,堆栈跟踪的hash或可能有助于诊断错误的其他上下文信息。
	}
}

我们先从低层级开始,定义一个底层级的错误信息

// "lowlevel" module
type LowLevelErr struct {
	error
}

func LowLevelModule(path string) (bool, error) {
	info, err := os.Stat(path)
    // 发生错误时,使用错误包装器返回一个自定义的底层级错误类型,我们想隐藏工作未运行原因的底层细节,因为这对于用户并不重要。
	if err != nil {
		return false, LowLevelErr{wrapError(err, err.Error())} 
	}
	return info.Mode().Perm()&0100 == 0100, nil
}

接下来看看中间层,定义一个中间层级的错误信息

// "intermediate" module

type IntermediateErr struct {
	error
}

func IntermediateLevelModule(id string) error {
	const jobBinPath = "/bad/job/binary"
    // 调用底层级的函数 ,接收其中的错误信息
	isExecutable, err := LowLevelModule(jobBinPath)
	if err != nil {
       // 发现错误,使用错误包装器封装来自上一层的错误消息,并添加当前层级的错误信息
		return IntermediateErr{wrapError(err,
			"cannot run job %q: requisite binaries not available", id)} //1
	} else if isExecutable == false {
        // 由于没有底层级的错误,包装器第一参数只需传入nil
		return wrapError(
			nil,
			"cannot run job %q: requisite binaries are not executable", id,
		)
	}

	return exec.Command(jobBinPath, "--id="+id).Run()
}

ok,接下来看看高层级的调用,我们定义了一个直接对用户呈现的错误函数,对开发人员记录错误日志,对用户呈现直观的错误信息。

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 := IntermediateLevelModule("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)
	}
}

这种实现方法与标准库的错误包兼容,此外你可以用你喜欢的任何方式来进行包装,并且自由度非常大。