错误处理(上)
错误处理到现在为止应该已经接触过几次了。比如,声明error类型的变量err,或是调用errors包中的New函数。
error类型
error类型是一个接口类型,是一个Go语言的内建类型。在这个接口类型的声明中只包含了一个方法Error。这个方法不接受任何参数,但是会返回一个string类型的结果。它的作用是返回错误信息的字符串表示形式。使用error类型的方式通常是,在函数声明的结果列表的最后,声明一个该类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为nil”。如果值“不为nil”,就需要进入错误处理。否则就是继续正常的流程。示例如下:
package main
import "fmt"
func echo(request string) (response string, err error) {
if request == "" {
err = fmt.Errorf("空字符串") // 这里底层也是调用下面的New,但是支持字符串格式化
// 如果是纯字符串,可以直接调用errors包里的New函数
// err = errors.New("empty request")
return
}
response = fmt.Sprintf("echo:%s", request)
return
}
func main() {
for _, req := range []string{"", "Hello"} {
fmt.Printf("request: %s\n", req)
resp, err := echo(req)
if err != nil {
fmt.Printf("error: %s\n", err)
continue
}
fmt.Printf("response: %s\n", resp)
}
}
在echo函数和main函数中,我都使用到了卫述语句。卫述语句,就是被用来检查后续操作的前置条件并进行相应处理的语句。在进行错误处理的时候经常会用到卫述语句,以至于“我的程序满屏都是卫述语句,简直是太难看了!”(这里我有同感)。
错误判断
由于error是一个接口类型,所以即使同为error类型的错误值,它们的实际类型也可能不同。错误判断的做法一般是如下的3种:
- 对于类型在已知范围内的一系列错误值,一般使用类型断言表达式或类型switch语句来判断
- 对于已有相应变量且类型相同的一系列错误值,一般直接使用判等操作来判断
- 对于没有相应变量且类型未知的一系列错误值,只能使用其错误信息的字符串表示形式来做判断
对于上面的3种情况,接下来分别展开。
第一种情况
类型在已知范围内的错误值是最容易分辨的。拿os包中的几个代表错误的类型os.PathError、os.LinkError、os.SyscallError和os/exec.Error举例,它们的指针类型都是error接口的实现类型,同时它们也都包含了一个名叫Err,类型为error接口类型的代表潜在错误的字段。
如果得到一个error类型值,并且知道该值的实际类型肯定是它们中的某一个,那就可以用类型switch语句去做判断。示例如下:
package main
import (
"fmt"
"os"
"os/exec"
)
// underlyingError 会返回已知的操作系统相关错误的潜在错误值。
func underlyingError(err error) error {
switch err := err.(type) {
case *os.PathError:
return err.Err
case *os.LinkError:
return err.Err
case *os.SyscallError:
return err.Err
case *exec.Error:
return err.Err
}
return err
}
func main() {
r, w, err := os.Pipe()
if err != nil {
fmt.Fprintf(os.Stderr, "unexpected error: %s\n", err)
return
}
// 人为制造 *os.PathError 类型的错误。
r.Close()
_, err = w.Write([]byte("hi"))
if err != nil {
uError := underlyingError(err)
fmt.Fprintf(os.Stderr, "underlying error: %s (type: %T)\n", uError, uError)
}
}
函数underlyingError的作用是,获取和返回已知的操作系统相关错误的潜在错误值。里面用switch做类型判断,如果是已知的那些类型,这些类型都会有Err字段,直接返回Err字段的值。如果case子句都没有被选中,那么就是一个其他的类型,直接返回传入的参数err,即放弃获取潜在错误值。
第二种情况
在Go语言的标准库中也有不少以相同方式创建的同类型的错误值。还拿os包来说,其中不少的错误值都是通过调用errors.New函数来初始化的,比如:os.ErrClosed、os.ErrInvalid以及os.ErrPermission。与之前的那些错误类型不同,这几个都是已经定义好的、确切的错误值。os包中的代码有时候会把它们当做潜在错误值,封装进前面那些错误类型的值中。
如果我们在操作文件系统的时候得到了一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那么就可以用普通的switch语句去做判断。这里比较难理解,示例如下:
package main
import (
"fmt"
"os"
"os/exec"
)
// underlyingError 会返回已知的操作系统相关错误的潜在错误值。
func underlyingError(err error) error {
switch err := err.(type) {
case *os.PathError:
return err.Err
case *os.LinkError:
return err.Err
case *os.SyscallError:
return err.Err
case *exec.Error:
return err.Err
}
return err
}
func main() {
paths := []string{
os.Args[0], // 当前的源码文件或可执行文件。
"/it/must/not/exist", // 肯定不存在的目录。
os.DevNull, // 肯定存在的目录。
}
printError := func(i int, err error) {
if err == nil {
fmt.Println("nil error")
return
}
err = underlyingError(err) // 先去获取潜在错误值
// 然后对错误值进行判等来分辨
switch err {
case os.ErrClosed:
fmt.Printf("case: %s\n", os.ErrClosed)
fmt.Printf("error(closed)[%d]: %s\n", i, err)
case os.ErrInvalid:
fmt.Printf("case: %s\n", os.ErrInvalid)
fmt.Printf("error(invalid)[%d]: %s\n", i, err)
case os.ErrPermission:
fmt.Printf("case: %s\n", os.ErrPermission)
fmt.Printf("error(permission)[%d]: %s\n", i, err)
default:
fmt.Println("case not fount")
fmt.Printf("error(unknow)[%d]: %s\n", i, err)
}
}
var f *os.File
var index int
var err error
{
index = 0
f, err = os.Open(paths[index])
if err != nil {
fmt.Printf("unexpected error: %s\n", err)
return
}
// 人为制造潜在错误为 os.ErrClosed 的错误。
f.Close()
_, err = f.Read([]byte{})
printError(index, err)
}
{
index = 1
// 人为制造 os.ErrInvalid 错误。
f, _ = os.Open(paths[index])
_, err = f.Stat()
printError(index, err)
}
{
index = 2
// 人为制造潜在错误为 os.ErrPermission 的错误。
_, err = exec.LookPath(paths[index])
printError(index, err)
}
if f != nil {
f.Close()
}
}
这里会用到上一个例子里的underlyingError函数。printError变量代表的函数会接受一个error类型的参数值,该值代表某个文件操作的相关错误。先用underlyingError函数得到它的潜在错误值(也可能类型都不符合得到的是原来的错误值),然后用switch语句对错误值进行判等操作。如此来分辨出具体的错误。
第三种情况
对于上面的两种情况,都有明确的方式来解决。但是,如果对一个错误的函数并不清楚,那只能通过它拥有的错误信息去判断了。总是能够通过错误值的Error方法拿到它的错误信息,就是错误信息的字符串表示形式。还是os包,里面就有做这种判断的函数,比如:os.IsExist、os.IsNotExist和os.IsPermission。
这里的例子和上面那个差不多,这次用了if来做判断(case和if都可以用),示例如下:
package main
import (
"fmt"
"os"
"os/exec"
"runtime"
)
func main() {
paths := []string{
runtime.GOROOT(), // 当前环境下的Go语言根目录。
"/it/must/not/exist", // 肯定不存在的目录。
os.DevNull, // 肯定存在的目录。
}
printError2 := func(i int, err error) {
if err == nil {
fmt.Println("nil error")
return
}
if os.IsExist(err) {
fmt.Printf("error(exist)[%d]: %s\n", i, err)
} else if os.IsNotExist(err) {
fmt.Printf("error(not exist)[%d]: %s\n", i, err)
} else if os.IsPermission(err) {
fmt.Printf("error(permission)[%d]: %s\n", i, err)
} else {
fmt.Printf("error(other)[%d]: %s\n", i, err)
}
}
var f *os.File
var index int
var err error
{
index = 0
err = os.Mkdir(paths[index], 0700)
printError2(index, err)
}
{
index = 1
f, err = os.Open(paths[index])
printError2(index, err)
}
{
index = 2
_, err = exec.LookPath(paths[index])
printError2(index, err)
}
if f != nil {
f.Close()
}
}
这里的代码里看不出什么,这种情况是获取错误的字符串表示形式然后做判断。这里做判断的就是os.IsExist、os.IsNotExist和os.IsPermission这3个函数。具体看os.IsNotExist做了什么,这个去源码里看一下:
// 转去调用一个内部的方法
func IsNotExist(err error) bool {
return isNotExist(err)
}
// 再转去调用字符串分析的方法
func isNotExist(err error) bool {
return checkErrMessageContent(err, "does not exist", "not found",
"has been removed", "no parent")
}
// 这个函数就是看看错误信息里是否有特定的字符串
func checkErrMessageContent(err error, msgs ...string) bool {
if err == nil {
return false
}
// 第一个例子就开始用的这个函数,就是从源码里超的
err = underlyingError(err)
for _, msg := range msgs {
if contains(err.Error(), msg) {
return true
}
}
return false
}
这里看到了,我们的代码里用用做判断的函数,在源码里具体做的事情就是获取错误信息的字符串表示信息,然后去判断是否包含了特定的字符串。
总结
这篇主要就是讲错误类型的判断,并且用os包举例了3种判断错误类型的方法。
第一种类型断言,就是直接用类型断言判断错误的类型。error类型是一个接口类型,这里要用类型断言判断出该类型的动态类型,通过这个动态类型来分辨。
第二种错误值判等,通过错误值来判断,这里的错误值是已知的,所以使用判等来进行判断。
第三种分析错误值,其实还是通过错误值来判断,但是这里的错误值不确定。例子里用了os包中提供的方法来进行判断,其底层就是检查字符串是否包含特定的字符。
另外,用于判断的语句,类型断言应该还是用case比较合适。其他情况case和if都可以用来做判断。
错误处理(下)
在上篇中,主要是从使用者的角度看“怎样处理错误值”。这篇,要从建造者的角度关心“怎么才能给予使用者恰当的错误值”。
构建错误值体系的基本方式有两种:
- 创建立体的错误类型体系
- 创建扁平的错误值列表
错误类型体系
由于在Go语言中实现接口是非侵入式的,所以可以做的很灵活。比如,在标准库的net代码包中,有一个名为Error的接口类型。它算是内建接口类型error的一个扩展接口,因为error是net.Error的嵌入接口。net.Error接口除了拥有error接口的Error方法外,还有两者自己什么的方法:Timeout和Temporary。net包中有很多错误类型都实现了net.Error接口,比如下面这些:
- *net.OpError
- *net.AddrError
- net.UnknownNetworkError
这些错误类型就是一个树形结构,内建接口error就是根节点,而net.Error接口就是就是第一级子节点。
当我们细看net包中的这些具体错误类型的实现时,还会发现,与os包中的一些错误类型类似,它们也都有一个名为Err、类型为error接口类型的字段,代表的也是当前错误的潜在错误。
所以,这些错误类型的值缠绵还有另外一种关系,即:链式关系。比如,使用者调用net.DialTCP之类的函数是,net包的代码可能会返回给他一个 *net.OpError 类型的错误值,这个表示用于操作不当造成了一个错误。同时,这些代码还会把一个 *net.AddrError 或 net.UnknownNetworkError 类型的值赋值该错误值的Err字段,以表示导致这个错误的潜在原因。所以,如果此处的潜在错误值的Err字段也有非nil值,那么就指明了更深层次的错误原因。如此一级有一级就像链条指向了问题的根源。
以上这些内容总结成一句话就是,用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。这是Go语言标准库给予我们的优秀范本,非常有借鉴意义。
不过要注意,如果不想让包外代码改动你返回的错误值的话,字段名称一定要小写。可以通过暴露某些方法让包外代码可以进一步获取错误信息,比如写一个Ere方法返回私有的err字段的值。下面的扁平化方式就不得不暴露字段给包外代码,这会带来一些问题。
小结
错误类型体系是立体的,从整体上看它往往呈现出树形的结构。通过接口间的嵌套以及接口的实现,就可以构建出一棵错误类型树。通过这棵树,使用者就可以一步步地确定错误值的种类。
另外,为了追根溯源,还可以在错误类型中,统一安放一个可以代表潜在错误的字段。这叫做链式的错误关联,可以帮助使用者找到错误的根源。
扁平的错误值列表
这个就简单得多了。当我们只是想预先创建一些代表已知错误的错误值的时候,用扁平化的方法就是可以了。
由于error是接口类型,所以通过error.New函数生成的错误值只能被赋值给变量,不能给常量。又由于这些变量需要给包外的代码使用,所以访问权限只能公开(首字母大写)。
这就带来了一个问题,如果有恶意代码改变了这些公开变量的值,那么程序的功能就会受到影响。因为在这种情况下,我们一般就是通过判等操作来判断拿到的凑之具体是哪一个错误,如果值被改变了,就会影响到判等操作的结果。这里光看文字没啥感觉,下面有两个示例。
示例1:
package main
import (
"fmt"
"os"
"os/exec"
)
func main() {
_, err := exec.LookPath(os.DevNull)
fmt.Printf("error: %s\n", err)
if execErr, ok := err.(*exec.Error); ok {
// 这里修改了err里的值,因为字段名Name和Err是大写的
execErr.Name = os.TempDir()
execErr.Err = os.ErrNotExist
}
fmt.Printf("error: %s\n", err) // err还是开头的err,但是值被修改了
}
示例2:
package main
import (
"fmt"
"os"
"errors"
)
func main() {
err := os.ErrPermission
// 现在的判断是正确的
if os.IsPermission(err) {
fmt.Printf("error(permission): %s\n", err)
} else {
fmt.Printf("error(other): %s\n", err)
}
// 由于字段名是大写的,就可以修改了。
// os.ErrPermission = os.ErrExist // 这句怕看不懂,其实就是改掉原本的值
os.ErrPermission = errors.New("可以是任意内容啊") // 把原值改掉,改成什么不重要
// 这次再判断err类型就不一样了。err还是开头的err,但是判断结果不一样了
if os.IsPermission(err) {
fmt.Printf("error(permission): %s\n", err)
} else {
fmt.Printf("error(other): %s\n", err)
}
}
这两个示例其实就是一个情况,字段名大写了,于是就暴露出来,可以修改了。示例1中if语句内是这里所说的恶意代码,示例2中 os.ErrPermission = os.ErrExist
是这里所说的恶意代码。原本以为不改不就OK了?但是在这里的问题是err的值被改了,但是没有看到显示的修改err的代码。这个问题就很严重了,问题难以被发现。
解决方案有两个:
方案一,先私有化变量,然后编写公开的用于获取错误值以及用于判等的错误值的函数。就是像上节错误类型体系的最后说的那么做。
方案二,此方案存在于syscall包中。该包中有一个类型叫Errno,该类型代表了系统调用是可能发生的底层错误。这个错误类型是error接口的实现类型,同时也是对内建类型uintptr的再定义类型。由于uintptr可以常量的类型,所以syscall.Error就可以是常量。syscall包中声明有大量的Errno类型的常量,包外的代码可以获取到这些大写的常量的值,但是无法改标这些常量。
下面是方案二所说的,定义了int类型Errno,并且实现了error接口。自定义这类错误的示例:
package main
import (
"fmt"
"strconv"
)
// Errno 代表某种错误的类型。
type Errno int
// error接口类型,需要实现一个Error方法,这个方法不接受任何参数,但是会返回一个string类型的结果
func (e Errno) Error() string {
return "errno " + strconv.Itoa(int(e))
}
func main() {
const (
ERR0 = Errno(0)
ERR1 = Errno(1)
ERR2 = Errno(2)
)
var myErr error = Errno(0)
switch myErr {
case ERR0:
fmt.Println("ERR0")
case ERR1:
fmt.Println("ERR1")
case ERR2:
fmt.Println("ERR2")
}
}
小结
方案一:使用私有变量,使错误值不可见也不可改,然后编写公开的函数返回私有变量的值。
方案二:使用常量,这样可见但是不可改,需要像syscall那样声明新的类型来实现error接口。
总之,扁平的错误值列表虽然相对简单,但是你需要知道其中的隐患以及解决方案。
有疑问加站长微信联系(非本文作者)