从零开始写一个web服务到底有多难?(三)——异常处理

异常处理

Go error

Go error是一个普通的接口,通过该接口得到一个普通的值。(当然也不太普通一点是error的首字母是小写的,但是我们仍然可以在外部使用它。)

go 复制代码
type error interface {
	Error() string
}

我们经常会使用errors.New()来返回一个error对象。

go 复制代码
err := errors.New("errorString")

他的内部实现也非常简单,实现了Error()接口。

go 复制代码
type errorString struct {
	s string
}

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

func (e *errorString) Error() string {
	return e.s
}

包里还给了一个错误码的样例,那么我们直接用来试试。

这样写会返回true吗?

go 复制代码
func getError(ctx *server.Context) {
	err := errors.New("unsupported operation")
	ctx.WriteJson(http.StatusOK, errors.Is(err, errors.ErrUnsupported))
}

显然不会,我们可以注意到前面New函数返回的是errorString的指针,我们自己New出的error对象和包内的对象指针显然不一样。

当然这样设计是合理的。我们在实际合作开发时,有可能会出现两个人设置的errorString恰好一样,如果以值相等做判断,那么这两个不同的错误会因为有相同的errorString而被判定相等。我们显然不希望出现这种情况。

Exception vs Error

Exception

java引入了checked exception。引用一下csdn中别人的简介。

可以看出有两个特点,第一是支持静态代码检查,若方法声明抛出异常,调用者必须处理异常。第二是会有隐藏的控制流,当异常发生时,会在方法内throw error,并直接执行调用者的catch代码。

异常的严重程度由函数的调用者来区分。

但是我们在实践的过程中往往会出现两种情况,第一种是直接catch一个Exception对象,并且在代码中忽略掉,不做处理。(当然很多时候也没法做处理,因为抛出异常时,内部代码的执行情况调用者并不清楚,自然只能做一些释放资源,重试等笼统的处理)

Error

Go的处理异常逻辑是不引入Exception,支持多参数返回,所以我们很容易在函数返回值中带上实现了error interface的对象,交由调用者来判定。实际上需要调用者出现error后立即处理。我们可以看到之前的文章里有一个非常经典的写法。

go 复制代码
func (c *Context) WriteJson(code int, resp interface{}) error {
	c.W.WriteHeader(code)
	respJson, err := json.Marshal(resp)
	if err != nil {
		return err
	}
	_, err = c.W.Write(respJson)
	return err
}

如果一个函数返回了 (value,error) ,你不能对这个value做任何假设,必须先判定error。唯一可以忽略error的情况是,你连value也不关心。所以说我们在go中使用error时,会非常多的用到这样的写法。

go 复制代码
respJson, err := json.Marshal(resp)
if err != nil {
	return err
}

而不是try-catch一个大的代码块,然后在catch里面处理。

因为一旦有一个函数产生error,我们期望的一种处理模式是立即去处理它,然后降级还是容错。

VS

GO 中有 panic的机制,GO panic意味着 fatal error,就是不可恢复的错误。我们不能假设调用者来解决panic。

即对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界,不可恢复的环境问题,栈溢出,我们 才使用panic。对于其他的错误情况,我们应该是期望使用error来进行判定。

使用多个返回值和一个简单的约定----如果返回err,需要立即处理。GO可以让调用者知道什么时候出现了error,并且立刻处理。并为真正的异常情况保留了panic。

Java则将良性的错误和致命的错误都通过throw的方式往外抛。对调用者而言区分起来就比较困难了。

Exception在假设一个代码块中任何一行代码都有可能出现异常。我们在使用的过程中也应该尽可能的保持语义的原子性。尽量最小范围的使用try-catch。

Error的好处

简单

考虑失败,而不是成功

没有隐藏的控制流

完全交由调用函数方处理error

Errors are values

Sentinel Error

预定义的特定错误,我们叫sentinel error。看一下Go的官方文档,在Go 1.13之前我们经常会将实际发生的错误和sentinel error比较。判定是否发生了某个预定义的错误。

使用sentinel error判定是最不灵活的错误处理策略,因为调用方必须使用==将结果与预定义的sentinel error进行比较。当想要更多上下文信息时,就会遇到一个问题,返回一个不同的错误将会和预定义的错误指针不一致,即相等检查不通过。

调用者只能通过使用error.Error()方法获取errorString,然后比较errorString与预定义的sentinel error的errorString是否值相等。当然这样又回到了我们之前提到的设计问题,即两个不同包内声明相同errorString的错误,会被误判为相等。

如果使用 Sentinel errors。。。

Sentinel errors将成为API的公共部分。

如果你的公共函数,或者公共方法返回了一个Sentinel errors,那么该值必须是公共的,必须有文档记录。

如果API定义了一个返回特定错误的interface,则该接口的所有实现都将被限制为仅返回该错误,即使他们可以提供更多信息。

Sentinel errors在两个包之间创建了依赖。

Sentinel errors最糟糕的问题是它们在两个包之间创建了源代码之间的依赖关系。

例如检查错误是否等于io.EOF,你的代码必须导入io包。这个例子听起来好像没什么问题,因为这种情况非常常见。但是想象一下,如果我们整个项目有很多的包,每个包中导出各自的错误值时,那么我们整个项目就被迫导入这些错误值才能检查特定的错误。

建议

不依赖检查error.Error()的输出。Error方法存在于error接口主要用于方便程序员使用,而非程序使用(编写测试用例可能会依赖这个返回)。输出的字符串可用于记录日志。但不应该在程序内部使用。

所以我们在编写代码的时候应该尽量避免使用Sentinel errors。在标准库中有一些使用它们的情况,但不是我们应该模仿的方式。

Error Types

Error Types是实现了error接口的自定义类型。例如MyError类型记录了文件和行号以记录错误发生位置和发生了什么。

go 复制代码
type MyError struct {
	Msg  string
	File string
	Line int
}

func (e *MyError) Error() string {
	return fmt.Sprintf("%s:%d:%s", e.File, e.Line, e.Msg)
}

func GetError() error {
	return &MyError{
		"Error Happened",
		"MyErrpr.go",
		16,
	}
}

因为MyError是一个type,调用者可以使用断言转换成这个类型,来获取更多的上下文信息。

go 复制代码
func getError(ctx *server.Context) {
	err := server.GetError()
	switch err := err.(type) {
	case nil:
		// call succeeded
	case *server.MyError:
		fmt.Println("error occurred on line:", err.Line)
	default:
		// unknown error
	}
}

与使用Sentinel errors相比,Error Types的一个改进是它们能够包装底层错误以提供更多上下文。

调用者通过使用类型断言和类型switch,让自定义的error变为public。这会导致函数和调用者产生强耦合。

建议

避免使用Error Types,虽然比Sentinel errors好了一些,因为它们可以携带更多的信息(即官方文档Adding infomation章节),但是耦合的问题仍然没有被解决。

所以我们在编写代码的时候应该尽量避免使用Error Types,至少避免将它们作为公共API的一部分。

Opaque errors

这种处理方式是调用者和代码耦合最小的处理方式。

我们将这种风格称为不透明的错误处理,仅判断调用是否成功,如果失败了直接返回错误,而不假设,处理其内容。

go 复制代码
func getError(ctx *server.Context) error {
	err := server.GetError()
	if err != nil {
		return err
	}
	// do something
}

在一些情况下,这样简单的处理并不满足我们的业务需求。例如网络活动,需要调用方判断错误的性质,以确定是否需要重试请求。在这种情况下,我们可以断言错误实现了特定的行为,而不是断言错误是特定的类型或值。

在包内新加一个接口,实现判断错误是否是临时性的。当错误类型实现了这个接口,并在实现中返回true时,外部调用IsTemporary才会返回true。

在MyError中实现了这个接口。

go 复制代码
type temporary interface {
	Temporary() bool
}

func (e *MyError) Temporary() bool {
	return true
}

func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}

修改调用者代码,断言类型后可以进一步断言错误实现了特定的行为。在这种实现中并没有向外部暴露类型。

而是通过暴露一个IsTemporary的方法。调用者可以不导入定义错误类型的包的情况下,也不必了解error的底层类型,就可实现对错误的判断------我们只对它的行为感兴趣。在此例中,只要判断IsTemporary返回值即可判断是否需要进行重试的行为。

go 复制代码
func getError(ctx *server.Context) {
	err := server.GetError()
	if server.IsTemporary(err) {
		fmt.Println("retry")
	} else {
		fmt.Println("panic")
	}
}

当然这样的实现虽然比之前的2种好了很多,但是我们在实际使用过程种仍然会觉得非常麻烦。看看有没有办法再改进一下。

Handling Error

正常流程

我们一般的业务代码大多是这样的形式。

go 复制代码
func handleError(path string) {
	f, err := os.Open(path)
	if err != nil {
		//handle error
	}
	// do something
}

还记得我们之前Opaque errors的处理方式吧,如果我们一路将错误直接返回给调用者,调用者可能也会这样做。那么错误会被一直返回到程序的顶部,程序的主体将把错误打印到日志文件中。但是打印出来的只是我们预定义的一个Sentinel errors。因为直接向上抛出之后,我们往往会假定上层可能用 == 去判定error,因此我们不敢对error进行处理。但是这样真的出现异常时,没有生成错误的file:line信息,没有产生错误代码的stack信息,就会导致排查问题非常困难。

go 复制代码
func getError(ctx *server.Context) error {
	err := server.GetError()
	if err != nil {
		return err
	}
	// do something
}

Handle errors once

我们经常会发现类似的代码,在错误处理中,先记录日志,再返回错误。

go 复制代码
func handleError(path string) {
	f, err := os.Open(path)
	if err != nil {
		//handle error
		log.PrintLn("error: %v", err)
		return err
	}
	// do something
}

这样做的确有了file:line信息和stack信息,但是同样非常不好,在我们的例子中,在Open处发生了一个异常,记录错误发生的文件和行,并且错误也会返回给调用者。但是调用者可能也会记录并返回它,记录调用点的文件和行,一直返回到程序的顶部。

如果我们打印日志后不再返回err呢?

同样存在问题!

Go中的错误处理契约规定,在出现错误的情况下,不能对其他返回值的内容做出任何假设。

比如我们对JSON进行序列化失败,buf的内容是未知的,可能它不包含任何内容,但更糟糕的是,它可能包含一个写了一半的JSON片段。

如果程序员在检查并记录错误日志后不再返回err,那么调用者的上层可能会认为函数调用成功了。

Wrap errors

日志记录与错误无关且对调试没有任何帮助的信息应被视为噪音,记录是因为某些东西失败了,而日志包含了答案。

错误要被日志记录。

应用程序处理错误,保证处理的完整性。

之后不再报告当前错误。

使用pkg/errors包实现了一个简单的Wrap errors的demo。

我们在Open产生error时,将原始根因包装起来,并加入我们想要添加的关键信息。

在调用处接收到错误时,通过errors.Cause拿到根因,完整处理error后不再上报异常。

注意到我们这边是可以拿到根因的,所以前面说到的Error Types还是断言行为都能适应。这样就非常的方便。

同样堆栈信息也可以拿到。

go 复制代码
func ReadFile(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.Wrap(err, "open failed")
	}
	defer f.Close()
}

func handleError(path string) {
	_, err := ReadFile(path)
	if err != nil {
		fmt.Println("original error: %T %v \n", errors.Cause(err), errors.Cause(err))
		fmt.Printf("stack trace: \n%+v\n", err)
		os.Exit(1)
	}
}

总结

1.在应用代码中,使用errors.New或者erros.Errorf返回错误。

2.调用其他包内的函数,通常直接将错误返回。

3.和其他库进行协作,考虑使用erros.Wrap或者erros.Wrapf保存堆栈信息。同样适用于和标准库协作的时候。

4.不处理错误时,不需要打日志。

5.在程序的顶部或者是工作的goroutine顶部(请求入口),使用%+v记录堆栈详情。

6.使用errors.Cause获取root error,再和sentinel error判定。

7.一旦确定在此处处理错误时,错误就不再是错误。如果函数/方法扔需要返回,则此处的返回值应该是成功。(比如在一些降级处理中,返回了降级处理的结果,那么返回的err应该是nil。因为错误已经被妥善的处理了。

Errors in Go 1.13

Go 1.13为errors和fmt标准库包引入了新特性,以简化处理包含其他错误的错误。

1.包含另一个错误的error可以实现返回root error的UnWrap方法。如果e1.Unwrap()返回e2,那么我们说e1包装e2,我们可以展开e1获得e2。

2.包含两个用于检查错误的新函数:Is和As。Is是比较两个error的值,与我们之前用的==类似,但他会先尝试Unwrap获取根因,尝试对比根因是否相等。As是判断一个error是否是我们期望的类型。

3.fmt.Errorf支持新的%w谓词。相当于内部将错误包装起来。当我们使用%w包装错误时,产生的错误可用errors.Is以及errors.As判定。

具体用法可参考官方文档。

相关推荐
蒙娜丽宁2 天前
Go语言错误处理详解
ios·golang·go·xcode·go1.19
qq_172805593 天前
GO Govaluate
开发语言·后端·golang·go
littleschemer4 天前
Go缓存系统
缓存·go·cache·bigcache
程序者王大川5 天前
【GO开发】MacOS上搭建GO的基础环境-Hello World
开发语言·后端·macos·golang·go
Grassto5 天前
Gitlab 中几种不同的认证机制(Access Tokens,SSH Keys,Deploy Tokens,Deploy Keys)
go·ssh·gitlab·ci
高兴的才哥5 天前
kubevpn 教程
kubernetes·go·开发工具·telepresence·bridge to k8s
少林码僧6 天前
sqlx1.3.4版本的问题
go
蒙娜丽宁6 天前
Go语言结构体和元组全面解析
开发语言·后端·golang·go
蒙娜丽宁6 天前
深入解析Go语言的类型方法、接口与反射
java·开发语言·golang·go
三里清风_7 天前
Docker概述
运维·docker·容器·go