Go中这么多创建error的方式,你真的了解它们各自的应用场景吗

大家好,我是渔夫子。今天从应用场景的角度来聊聊我对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使用场景1

通过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使用场景2

将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方法创建

使用场景1:不带%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
            }
        }
    }
    //...
}   

使用场景2:带%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源码或开源项目。 同时,每种的应用场景并非绝对的,需要灵活应用。希望本文对大家在实际使用中能够有所帮助。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容