Go 语言的异常处理

ERROR 是什么

对于一个 Go 语言程序员,你一定写过这样的代码。

if err != nil {
    doSomething()
}
复制代码

这里的 err 便是我们今天要讨论的主角,为了解释它是什么这个问题,我们不妨先从设计者的角度出发,看一看标准库里的 errors 包是如何设计的。

// because the former will succeed if err wraps an *fs.PathError.
package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
	return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}
复制代码

上图为 golang sdk 1.16.6 版本的 errors.go

让人意外的是,竟然如此的简洁,除去注释一共就 10 行代码,就完成了这个每个 Go 程序员都用过的标准库。仔细看的朋友应该发现了,New 函数的返回值类型 error 并没有在这个文件中定义,是的,实际上 error 类型定义在builtin 这个标准库中,它的全貌是这个样子的。

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}
复制代码

上图为 golang sdk 1.16.6 版本的 builtin.go

依然是如此的简洁~~~ 这个时候我觉得我们可以回答这个问题了,error 就是一个接口,只有一个 error 方法,返回一个字符串。这个回答是没有问题的,但是却让人觉得没有什么意义。我们不妨先放下这个问题,去研究一下 error 的作用。现在让我们回到 errors 这个标准库上。

为什么返回的是指针

我们可以看到 errors 包中只有一个 errorString 的结构体,内嵌了一个字符串。该结构体实现了 error 接口,最后通过一个 New() 函数,提供给使用者,这里可以看到 New 函数返回的是 errorString 的指针类型。我写一个小的Demo 来解释这样做的意义。

package main

import (
	"log"
)

func main() {
	errA:=New("some error")
	errB:=New("some error")
	log.Println(errB==errA)
}

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

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e errorString) Error() string {
	return e.s
}
复制代码

这段代码中我完全复制的标准库 errors 的实现方式,只是把 New() 函数的返回值变成了非指针类型,这个时候我的errA 和 errB 竟然相等了。这往往不是我们希望看到的,这应该是两个错误,只是错误的描述相同而已!当我们返回结构体做比较时,golang 会一次对比结构体中的每一个值是否相同,全部相等则为真,对应到这里,只有一个字符串的对比~~ 所以在你设计你自己的错误包时,注意返回指针而不是结构体,一定会帮你避免很多莫名其妙的 bug。

错误处理进化历史

为了理解 error 的使用,了解一些其他语言的错误处理也是很有必要的。 ####C 首先是c语言,c语言是单返回值的,入参一般都是指针,然后返回一个int作为成功还是失败。由于返回值被error占用了,只能把要影响的对象通过指针透传,无论如何在今天看来都是不够流畅的。 ####C++ 然后是 c++,它引入了 excepiton,但是一个明显的缺陷是, 调用者并不知道对方会抛出什么异常。 ####JAVA 最后是 java,它引入了 checked excepiton 所有方法必须声明,所有使用者必须处理。然而..... 在实际开发任务中,会有相当多的程序员,并不去区分错误类型,不管是一般性的错误,还是灾难性的错误,全部都是抛异常。

Go 语言的错误处理

现在让我们回到 golang 的错误处理。 Go 语言采用多返回值,而不再使用 exception,error 往往是最后一个返回值。当你调用一个函数后,你必须先判断 error,如果 error 不为nil,那么你一定不应该再使用你的 value。如果即使检测到了 error 还要继续使用 value,这种代码设计应视为一种缺陷或者说妥协。

panic

golang 的 panic 和其他语言的 exception 完全不同,正常情况下你不应该去假设调用者会去处理你的 panic,发生了 panic 就意味着代码不应该继续运行。

然而总有例外

总有一些场景,无论如何你都不希望你的程序运行会终止,即使是发生了 panic,那么你可以去捕捉这个 panic,虽然这有可能到导致一些灾难性的错误没有被及时处理,但是我们往往需要在完美与实用之间找到平衡。 通过 error+panic 这一套组合,很容易的让 Go 程序员知道了什么时候有异常 (error),什么时候有真正的异常(panic)。而不是像其他语言那样,不管什么异常,统统 try catch。这套体系下让 error 变得更像一个普通的 value。所以对于 error 是什么这个问题,我们现在也可以增加一个答案: errors are values。

当心野生 goroutine

什么是野生 goroutine,下面的代码就是野生 goroutine。

go func(){
    doSomething()
}
复制代码

这么写的代码,你是无法捕捉到内部的 panic 的,如果你以为这里做了兜底处理就是万事大吉了,那么迟早会有重大的事故等待着你。不管是从异常处理,还是 goroutine 泄露的角度出发,我们都不应该使用野生的goroutine。比较好的做法是构建一个 goroutine 池,从任务队列里捞取任务去处理,想要开启异步任务的地方把任务丢到队列里就好了。这样可以在 goroutine 池里统一的做异常处理,从安全的角度说也锁死了 goroutine 的总数。

隐藏的控制流

在使用exception处理异常的语言中,比如 Java,Python,Javascript。当你写下这样的代码

try:
    func_one()
    func_two()
except Exception as e:
    do_something()
复制代码

你无法确定在 try 的作用域内,执行到哪里,代码会突然进入 except,这种情况下我们称之为隐藏的控制流,这其实算不上什么大的问题,但是还是有一些场景会增加程序员的心智负担,golang 显示的抛出 error 的方式则避免了这种情况。

预定义 error

英文名字叫做 sentinel error,这个名字来源于计算机编程中使用一个特定值来表示不能进一步处理的错误。比如我们可以在标准库io包中看到这样的代码。

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package io provides basic interfaces to I/O primitives.
// Its primary job is to wrap existing implementations of such primitives,
// such as those in package os, into shared public interfaces that
// abstract the functionality, plus some other related primitives.
//
// Because these interfaces and primitives wrap lower-level operations with
// various implementations, unless otherwise informed clients should not
// assume they are safe for parallel execution.
package io

import (
	"errors"
	"sync"
)

// Seek whence values.
const (
	SeekStart   = 0 // seek relative to the origin of the file
	SeekCurrent = 1 // seek relative to the current offset
	SeekEnd     = 2 // seek relative to the end
)

// ErrShortWrite means that a write accepted fewer bytes than requested
// but failed to return an explicit error.
var ErrShortWrite = errors.New("short write")

// errInvalidWrite means that a write returned an impossible count.
var errInvalidWrite = errors.New("invalid write result")

// ErrShortBuffer means that a read required a longer buffer than was provided.
var ErrShortBuffer = errors.New("short buffer")
复制代码

上图为golang sdk 1.16.6版本的io.go

这里面的 ErrShortWrite 就是一个预定义 error,首字母大写,意味着他也作为 API 的一部分保留给了用户,这样做看起来没什么问题,用户似乎通过相等判断就能知道我的程序是否还能继续向下执行,但是这样做两个弊端。 第一,这个 error 成了你包的 API 的一部分,你的 API 变大了。 第二,这个 error 成了两个包之间的依赖,使用者调用你一个方法之后,还要再去引入你包里的一个变量,增加了代码的耦合性。所以,我们还是尽量不要使用预处理错误。

自定义 error

在 Go 语言中,我们可以很轻松的定义自己的 error 类型,并且如果你在一家公司中,你还可以为你们公司提供一套统一的错误码,这将提高你们错误的处理效率。并且你还能通过断言或者switch这样的语法来轻松判断你的错误类型。

package main

import "fmt"

func main() {
	err:=New("some error")
	switch err.(type) {
	case *errorString:
		fmt.Println("errorString")
	default:
		fmt.Println("not errorString")
	}
	err,ok := err.(*errorString)
	fmt.Println(ok)
}

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

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}
复制代码

上面的代码将会输出errorString和true

错误处理

讲了这么多,实际上错误处理才是理解 error 的灵魂所在,然而错误处理的核心就是包装错误。 不过在这之前,我们先讲一个更容易理解,同时也十分重要的错误处理原则。

错误只处理一次

在大型的项目当中,通常都是分成很多层的,比如数据操作层,业务处理层。比如当我们在数据操作层发生了一个异常后,把异常记录到日志里,并抛给了业务处理层,业务处理层捕捉到这个错误,也记录到了日志里,直到最后你会发现,同一个错误日志打的导出都是~~~

func insert() error{
    value,err := doSomething()
    if err!= nil {
        log.print(err)
        return err
    }
}
func doSomething() error{
    value,err := revice()
    if err!= nil {
        log.print(err)
    return err
    }
}
复制代码

这是一个常见的违法了错误只处理一次原则的案例。日志记录也是处理错误,你既然记录了下来,就不应该再抛给调用者。

消除错误

当我们开发一个自己的包时,可以把error包装到结构体内部,然后在相关方法内部去判断是否有错误,而不是交给调用者去判断,这样可以让调用的人写出更加简洁的代码。

func doSomething() error {
    var o operator
    err := o.f1()
    if err!= nil{
        return err
    }
    err = o.f2()
    if err!= nil{
        return err
    }
    return o.f3()
}
type operator struct {
    name string
    profession string
    interest string
}

func (o *operator)f1() error{
    if o.name=="火轮"{
        return nil
    }
    return errors.New("name must be 火轮")
}
func (o *operator)f2() error{
    if o.profession=="码农"{
        return nil
    }
    return errors.New("profession must be 码农")
}

func (o *operator)f3() error{
    if o.interest=="传火"{
        return nil
    }
    return errors.New("interest must be 传火")
}
复制代码

一个多次判断 err 的例子,代码较为啰嗦。

func doSomething() error {
    var o operator
    o.f1()
    o.f2()
    o.f3()
    return o.err
}
type operator struct {
    name string
    profession string
    interest string
    err error
}

func (o *operator)f1(){
    if o.err!= nil{
        return
    }
    if o.name!="火轮"{
        o.err = errors.New("name must be 火轮")
    }
    return
}
func (o *operator)f2(){
    if o.err!= nil{
        return
    }
    if o.profession!="码农"{
        o.err = errors.New("profession must be 码农")
    }
    return
}

func (o *operator)f3(){
    if o.err!= nil{
        return
    }
    if o.interest=="传火"{
        o.err = errors.New("interest must be 传火")
    }
    return
}
复制代码

把错误判断的逻辑封装到内部,给使用者简洁的体验。

包装错误

在 Golang 中处理错误,更优雅的做法是包装错误,添加一些上下文进去,而不是直接把错误干巴巴的抛出去。(github.com/pkg/errors) 这个包就是包装错误的一个事实标准了,很多错误包也是基于这个包进行二次开发的,我附上这个包的一段代码,相信你一眼就能看明白。

_, err := ioutil.ReadAll(r)
if err != nil {
    return errors.Wrap(err, "read failed")
}
type causer interface {
    Cause() error
}

switch err := errors.Cause(err).(type) {
case *MyError:
// handle specifically
default:
// unknown error
}
复制代码

通常,我们只需要在顶层记录一次包装后的错误,便可看到完整的错误链。

##参考资料

  1. blog.golang.org/errors-are-…
  2. coolshell.cn/articles/21…

猜你喜欢

转载自juejin.im/post/7036895061010972686