大家好,我是渔夫子。今天从应用场景的角度来聊聊我对 error 的理解。
01 什么是 Error
在 Go 中,error
是一种内建的数据类型。在 Go 中被定义为一个接口,定义如下:
// 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
}
复制代码
由此可知,该接口只有一个返回字符串的 Error 函数,所有的类型只要实现了该函数,就创建了一个错误类型。
02 创建 error 的方式
创建error
的方式包括 errors.New
、fmt.Errorf
、自定义实现了error接口
的类型等。
2.1 通过 errors.New 方法创建
通过该方法创建的错误一般是可预知的错误。简单来说就是调用者通过该错误信息就能明确的知道哪里出错了,而不需要再额外的添加其他上下文信息,我们在下面的示例中详细说明。
err := errors.New("this is error")
复制代码
我们看New
方法的实现可知,实际上是返回了一个errorString
结构体,该结构体包含了一个字符串属性,并实现了Error
方法。代码如下
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
}
复制代码
error.New 使用场景一
通过errors.New
函数创建局部变量或匿名变量,且不在调用函数中进行值或类型判断的处理,只打印或记录错误日志的场景。
使用示例 1
以下代码节选自源码/src/net/http/request.go
中解析 PostForm 的部分。当请求中的Body
为nil
时,返回的错误信息是"missing form body"。该信息已明确的说明错误是因为请求体为空造成的,所以不需要再额外的添加其他上下文信息。
func parsePostForm(r *Request) (vs url.Values, err error) {
if r.Body == nil {
err = errors.New("missing form body")
return
}
ct := r.Header.Get("Content-Type")
// 省略了后续的代码...
return
}
复制代码
使用示例 2
以下代码选择源码/src/net/http/transport.go
的部分,当请求体中的 url 地址为 nil 返回的错误:"http: nil Request.URL" ,说明是请求中的URL
字段为nil
。以及当 Header 为 nil 返回的错误:"http:nil Request.Header",说明请求体中的Header
字段为nil
。
func (t *Transport) roundTrip(req *Request) (*Response, error) {
t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)
ctx := req.Context()
trace := httptrace.ContextClientTrace(ctx)
if req.URL == nil {
req.closeBody()
return nil, errors.New("http: nil Request.URL")
}
if req.Header == nil {
req.closeBody()
return nil, errors.New("http: nil Request.Header")
}
//省略后面的代码...
}
复制代码
error.New 使用场景二
将errors.New
创建的错误赋值给一个全局的变量,我们称该变量为哨兵错误,该哨兵错误变量可以在被处理的时候使用 ==
或 errors.Is
来进行值的比较。
使用示例
在源码/src/io/io.go
中定义的代表文件末尾的哨兵错误变量EOF
。
var EOF = errors.New("EOF")
复制代码
在 beego 项目中,beego/core/utils/file.go
文件中有这样的应用,当读取文件时,遇到的错误不是文件末尾的错误则直接返回,如果遇到的是文件末尾的错误,则中断for
循环,说明文件已经读完文件中的所有内容了。如下:
func GrepFile(patten string, filename string) (lines []string, err error) {
//省略前面的代码...
fd, err := os.Open(filename)
if err != nil {
return
}
reader := bufio.NewReader(fd)
for {
byteLine, isPrefix, er := reader.ReadLine()
if er != nil && er != io.EOF {
return nil, er
}
if er == io.EOF {
break
}
//省略后面的代码...
}
复制代码
2.2 通过fmt.Errorf
方法创建
使用场景一:不带 %w 占位符
在创建错误的时候,不能通过errors.New
创建的字符串信息来描述错误,而需要通过占位符添加更多的上下文信息,即动态信息。
使用示例:不带%w
占位符:以下示例节选自gorm/schema/relationship.go
的部分代码,当外键不合法时,通过fmt.Errorf("invalid foreign key:%s", foreignKey)
返回带具体外键的错误。因为外键值是在运行时才能确定的。代码如下:
func (schema *Schema) buildMany2ManyRelation(relation *Relationship, field *Field, many2many string) {
//...
if len(relation.foreignKeys) > 0 {
ownForeignFields = []*Field{}
for _, foreignKey := range relation.foreignKeys {
if field := schema.LookUpField(foreignKey); field != nil {
ownForeignFields = append(ownForeignFields, field)
} else {
schema.err = fmt.Errorf("invalid foreign key: %s", foreignKey)
return
}
}
}
//...
}
复制代码
使用场景二:带%w
的占位符:
在有些场景下,调用者需要知道原始错误信息,一般会通过errors.Is
函数进行判断该错误链中是否包含某种特定类型的原始错误值。
使用 %w 占位符创建的错误信息,其实会形成一个错误链。其用法如下:
filename := "abc.webp"
fmt.Errorf("%w:%s", errors.New("unsupported extension"), filename)
复制代码
我们再来看下源代码:
func Errorf(format string, a ...interface{}) error {
p := newPrinter()
p.wrapErrs = true
p.doPrintf(format, a)
s := string(p.buf)
var err error
if p.wrappedErr == nil {
err = errors.New(s)
} else {
err = &wrapError{s, p.wrappedErr}
}
p.free()
return err
}
复制代码
通过源码可知,如果fmt.Errorf
中包含%w
占位符,创建的是一个wrapError
结构体类型的值。我们再来看下wrapError
结构体的定义:
type wrapError struct {
msg string
err error
}
复制代码
字段err
就是原始错误,msg
是经过格式化之后的错误信息。
使用示例:带 %w 的占位符
假设我们有一个从数据库查询合同的函数,当从数据库中查询到记录为空时,会返回一个sql.ErrNoRows
错误,我们用%w
占位符来wrap
该错误,并返回给调用者。
const query = "..."
func (s Store) GetContract(name string) (Contract, error) {
id := getID(name)
rows, err := s.db.Query(query, id)
if err != nil {
if err == sql.ErrNoRows {
return Contract{},
fmt.Errorf("no contract found for %s: %w", name, err)
}
// ...
}
// ...
}
复制代码
好了,现在GetContract
的调用者可以知道原始的错误信息了。在调用者逻辑中我们可以使用errors.Is
来判断err
中是否包含sql.ErrNoRows
值了。我们看下调用者的代码:
contract, err := store.GetContract("Raul Endymion")
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Do something specific
}
}
复制代码
2.3 自定义实现了error
接口的结构体
使用场景:这个是相对errors.New
来说的,errors.New
适用于对可预知的错误的定义。而当发生了不可预知的错误时,就需要自定义错误类型了。
使用示例
我们以 go 源码/src/io/fs/fs.go
文件中的源码为例,来看下自定义错误类型都需要包含哪些元素。
// PathError records an error and the operation and file path that caused it.
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
func (e *PathError) Unwrap() error { return e.Err }
复制代码
首先看结构体,有一个error
接口类型的Err
,这个代表的是错误源,因为根据上面讲解的,在错误层层传递返回给调用者时,我们需要追踪每一层的原始错误信息,所以需要该字段对error
进行wrap
,形成错误链。另外,有两个字段Op
和Path
,分别代表是产生该错误的操作和操作的路径。这两个字段就是所谓的未预料到的错误:不确定是针对哪个路径做了什么错误引发了该错误。
我们看下该错误类型在代码中的应用:
应用 1:在 go 的文件src/embed/embed.go
中的代码,当读取某目录时返回的一个PathError
类型的错误,代表读取该目录操作时,因为是一个目录,所以不能直接读取文件内容。
func (d *openDir) Read([]byte) (int, error) {
return 0, &fs.PathError{Op: "read", Path: d.f.name, Err: errors.New("is a directory")}
}
复制代码
应用 2:在 go 的文件src/embed/embed.go
中的代码中,有文件读取的函数,当offset
小于 0 时,返回了一个PathError
,代表是在读取该文件的时候,参数不正确。
func (f *openFile) Read(b []byte) (int, error) {
if f.offset >= int64(len(f.f.data)) {
return 0, io.EOF
}
if f.offset < 0 {
return 0, &fs.PathError{Op: "read", Path: f.f.name, Err: fs.ErrInvalid}
}
n := copy(b, f.f.data[f.offset:])
f.offset += int64(n)
return n, nil
}
复制代码
fs.ErrInvalid
的定义如下:
ErrInvalid = errors.New("invalid argument")
复制代码
由此可见,PathError
中的三个字段值都是不可预知的,都需要在程序运行时才能具体决定的,所以这种场景时,则需要自定义错误类型。
另外,我们还注意到该自定义的类型中有Unwrap
函数的实现,该函数主要是为了配合errors.Is
和errors.As
使用的,因为这两个函数在使用时是将错误链层层解包一一比对的。
03 errors.Is
和errors.As
根据上一节我们得到,通过 %w 占位符可以将错误组织成一个错误链。errors.Is
函数就是来判断错误链中有没有和指定的错误值相等的错误,相等于 ==
操作符。注意,这里是特定的错误值,就像 gorm 中定义的ErrRecordNotFound
这样:
var ErrRecordNotFound = errors.New("record not found")
复制代码
那么我们就可以这样使用errors.Is
:
errors.Is(err, ErrRecordNotFound)
复制代码
errors.As
函数,这个函数是用来检查错误链中的错误是否是特定的类型。如下代码示例是节选自 etcd 项目中etcd/server/embed/config_logging.go
中的部分代码,代表的是err
链中有没有能当做json.SyntaxError
类型的错误的,如果能,则将 err 中的错误值赋值到syntaxError
变量上,代码如下:
// setupLogRotation initializes log rotation for a single file path target.
func setupLogRotation(logOutputs []string, logRotateConfigJSON string) error {
//...
if err := json.Unmarshal([]byte(logRotateConfigJSON), &logRotationConfig); err != nil {
var unmarshalTypeError *json.UnmarshalTypeError
var syntaxError *json.SyntaxError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("improperly formatted log rotation config: %w", err)
case errors.As(err, &unmarshalTypeError):
return fmt.Errorf("invalid log rotation config: %w", err)
}
}
zap.RegisterSink("rotate", func(u *url.URL) (zap.Sink, error) {
logRotationConfig.Filename = u.Path[1:]
return &logRotationConfig, nil
})
return nil
}
复制代码
总结
本文从应用场景的角度讲解了各种创建错误方式的实际应用场景。示例中的代码尽量的选自 golang 源码或开源项目。 同时,每种的应用场景并非绝对的,需要灵活应用。希望本文对大家在实际使用中能够有所帮助。
---特别推荐---
特别推荐:一个专注 go 项目实战、项目中踩坑经验及避坑指南、各种好玩的 go 工具的公众号。「Go 学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100 个 go 常见的错误》pdf 文档。
评论