让我们从底层了解Go语言的Error接口和错误处理,进阶Go编程模式:错误处理
引言
:Go 语言中的错误处理与其他语言不太一样,它把错误当成一种值来处理,更强调判断错误、处理错误,而不是一股脑的 catch 捕获异常。
一.认识Error
Go 语言中把错误当成一种特殊的值来处理,不支持其他语言中使用try/catch
捕获异常的方式。
1.1Error接口
Go 语言中使用一个名为 error
接口来表示错误类型。
type error interface {
Error() string
}
error
接口只包含一个方法——Error
,这个函数需要返回一个描述错误信息的字符串。当一个函数或方法需要返回错误时,我们通常是把错误作为最后一个返回值。例如下面标准库 os 中打开文件的函数。
func Open(name string) (*File, error) {
return OpenFile(name, O_RDONLY, 0)
}
由于 error 是一个接口类型,默认零值为nil
。所以我们通常将调用函数返回的错误与nil
进行比较,以此来判断函数是否返回错误。例如你会经常看到类似下面的错误判断代码。
file, err := os.Open("./xx.go")
if err != nil {
fmt.Println("打开文件失败,err:", err)
return
}
注意⚠️
当我们使用fmt
包打印错误时会自动调用 error 类型的 Error 方法,也就是会打印出错误的描述信息。
1.2创建错误
我们可以根据需求自定义 error,最简单的方式是使用errors
包提供的New
函数创建一个错误。
函数签名如下,
func New(text string) error
它接收一个字符串参数返回包含该字符串的错误。我们可以在函数返回时快速创建一个错误。
func queryById(id int64) (*Info, error) {
if id <= 0 {
return nil, errors.New("无效的id")
}
// ...
}
或者用来定义一个错误变量,例如标准库io.EOF
错误定义如下。
var EOF = errors.New("EOF")//使用了pkg/errors三方库
1.3错误的简单处理
1.3.1Go 语言的错误处理
Go 语言的函数支持多返回值,所以,可以在返回接口把业务语义(业务返回值)和控制语义(出错返回值)区分开。Go 语言的很多函数都会返回 result、err 两个值,于是就有这样几点:
- 参数上基本上就是入参,而返回接口把结果和错误分离,这样使得函数的接口语义清晰;
- Go 语言中的错误参数如果要忽略,需要显式地忽略,用 _ 这样的变量来忽略;
- 因为返回的 error 是个接口(其中只有一个方法 Error(),返回一个 string ),所以你可以扩展自定义的错误处理。
- 如果一个函数返回了多个不同类型的 error,你也可以使用下面这样的方式:
if err != nil {
switch err.(type) {
case *json.SyntaxError:
...
case *ZeroDivisionError:
...
case *NullPointerError:
...
default:
...
}
}
我们可以看到,Go 语言的错误处理的方式,本质上是返回值检查,但是它也兼顾了异常的一些好处——对错误的扩展。
1.3.2.资源清理
出错后是需要做资源清理的,不同的编程语言有不同的资源清理的编程模式。
- C 语言:使用的是 goto fail; 的方式到一个集中的地方进行清理。
- C++ 语言:一般来说使用 RAII 模式,通过面向对象的代理模式,把需要清理的资源交给一个代理类,然后再析构函数来解决。
- Java 语言:可以在 finally 语句块里进行清理。
- Go 语言:使用 defer 关键词进行清理。
下面是一个 Go 语言的资源清理的示例:
func Close(c io.Closer) {
err := c.Close()
if err != nil {
log.Fatal(err)
}
}
func main() {
r, err := Open("a")
if err != nil {
log.Fatalf("error opening 'a'\n")
}
defer Close(r) // 使用defer关键字在函数退出时关闭文件。
r, err = Open("b")
if err != nil {
log.Fatalf("error opening 'b'\n")
}
defer Close(r) // 使用defer关键字在函数退出时关闭文件。
}
1.3.3.fmt.Errorf的使用
当我们需要传入格式化的错误描述信息时,使用fmt.Errorf
是个更好的选择。
fmt.Errorf("查询数据库失败,err:%v", err)
但是上面的方式会丢失原有的错误类型,只拿到错误描述的文本信息。
为了不丢失函数调用的错误链,使用fmt.Errorf
时搭配使用特殊的格式化动词%w
,可以实现基于已有的错误再包装得到一个新的错误。
fmt.Errorf("查询数据库失败,err:%w", err)
对于这种二次包装的错误,errors
包中提供了以下三个方法。
func Unwrap(err error) error // 获得err包含下一层错误
func Is(err, target error) bool // 判断err是否包含target
func As(err error, target interface{}) bool // 判断err是否为target类型
二.错误处理进阶
错误处理一直是编程必须要面对的问题。错误处理如果做得好的话,代码的稳定性会很好。不同的语言有不同的错误处理的方式。Go 语言也一样,这节课,我们来讨论一下 Go 语言的错误出处,尤其是那令人抓狂的 if err != nil 。
说到 Go 语言的 if err !=nil
的代码了,这样的代码的确是能让人写到吐。下面我们就讲讲如何将这些代码进行优化。我们先看一个令人崩溃的代码。
func parse(r io.Reader) (*Point, error) {
var p Point
if err := binary.Read(r, binary.BigEndian, &p.Longitude); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.Latitude); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.Distance); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.ElevationGain); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.ElevationLoss); err != nil {
return nil, err
}
}
- 看到上面的代码大家是不是感觉一阵头大?大家写一遍机会发现每一个操作除了传入的参数不同之外都是一样的,所以我们可以用函数式编程的方式,如下代码示例:
func parse(r io.Reader) (*Point, error) {
var p Point
var err error
//通过借口接收不同类型的参数
read := func(data interface{}) {
if err != nil {
return
}
err = binary.Read(r, binary.BigEndian, data)
}
read(&p.Longitude)
read(&p.Latitude)
read(&p.Distance)
read(&p.ElevationGain)
read(&p.ElevationLoss)
if err != nil {
return nil, err
}
return &p, nil
}
从这段代码中,我们可以看到,我们通过使用 Closure(闭包) 的方式把相同的代码给抽出来重新定义一个函数,这样大量的 if err!=nil 处理得很干净了,但是会带来一个问题,那就是有一个 err 变量和一个内部的函数,感觉不是很干净。
那么,我们还能不能搞得更干净一点呢?我们从 Go 语言的 bufio.Scanner()中似乎可以学习到一些东西:
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}
可以看到,scanner在操作底层的 I/O 的时候,那个 for-loop 中没有任何的 if err !=nil 的情况,退出循环后有一个 scanner.Err() 的检查,看来使用了结构体的方式。模仿它,就可以对我们的代码进行重构了。
首先,定义一个结构体和一个成员函数:
type Reader struct {
r io.Reader
err error
}
func (r *Reader) read(data interface{}) {
if r.err == nil {
r.err = binary.Read(r.r, binary.BigEndian, data)
}
}
然后,我们的代码就可以变成下面这样:
func parse(input io.Reader) (*Point, error) {
var p Point
r := Reader{r: input}
r.read(&p.Longitude)
r.read(&p.Latitude)
r.read(&p.Distance)
r.read(&p.ElevationGain)
r.read(&p.ElevationLoss)
if r.err != nil {
return nil, r.err
}
return &p, nil
}
有了刚刚的这个技术,我们的“流式接口 Fluent Interface”也就很容易处理了。如下所示:
package main
import (
"bytes"
"encoding/binary"
"fmt"
)
// 长度不够,少一个Weight
var b = []byte {0x48, 0x61, 0x6f, 0x20, 0x43, 0x68, 0x65, 0x6e, 0x00, 0x00, 0x2c}
var r = bytes.NewReader(b)
type Person struct {
Name [10]byte
Age uint8
Weight uint8
err error
}
func (p *Person) read(data interface{}) {
if p.err == nil {
p.err = binary.Read(r, binary.BigEndian, data)
}
}
func (p *Person) ReadName() *Person {
p.read(&p.Name)
return p
}
func (p *Person) ReadAge() *Person {
p.read(&p.Age)
return p
}
func (p *Person) ReadWeight() *Person {
p.read(&p.Weight)
return p
}
func (p *Person) Print() *Person {
if p.err == nil {
fmt.Printf("Name=%s, Age=%d, Weight=%d\n",p.Name, p.Age, p.Weight)
}
return p
}
func main() {
p := Person{}
p.ReadName().ReadAge().ReadWeight().Print()
fmt.Println(p.err) // EOF 错误
}
相信你应该看懂这个技巧了,不过,需要注意的是,它的使用场景是有局限的,也就只能在对于同一个业务对象的不断操作下可以简化错误处理,如果是多个业务对象,还是得需要各种 if err != nil的方式。
本文参考:
- 李文周老师博客:Error接口和错误处理
- 左耳朵耗子:错误处理
- 武沛齐相关视频
- 第三方库:pkg/errors
如有不理解的地方可以一起讨论,或则查阅以上文章视频学习,感谢大家观看。