Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

关于Golang错误处理的一些建议 #66

Open
kevinyan815 opened this issue Sep 22, 2021 · 0 comments
Open

关于Golang错误处理的一些建议 #66

kevinyan815 opened this issue Sep 22, 2021 · 0 comments

Comments

@kevinyan815
Copy link
Owner

kevinyan815 commented Sep 22, 2021

错误处理这块是Go日常被大家吐槽较多的地方,我在工作中也观察到一些现象,比较严重的是在各层级的逻辑代码中对错误的处理有些重复。

比如,有人写代码就会在每一层都判断错误并记录日志,从代码层面看,貌似很严谨,但是如果看日志会发现一堆重复的信息,等到排查问题时反而会造成干扰。

这里总结三点Go代码的错误处理相关的最佳实践。

这些最佳实践也是网上一些前辈分享的,我自己实践后在这里用自己的语言描述出来,希望能对大家有所帮助

认识error

Go程序通过error类型的值表示错误

error类型是一个内建接口类型,该接口只规定了一个返回字符串值的Error方法

type error interface {
    Error() string
}

Go语言的函数经常会返回一个error值,调用者通过测试error值是否是nil来进行错误处理。

i, err := strconv.Atoi("42")
if err != nil {
    fmt.Printf("couldn't convert number: %v\n", err)
    return
}
fmt.Println("Converted integer:", i)

error 为 nil 时表示成功;非 nil 的 error 表示失败。

自定义错误记得要实现error接口

我们经常会自己定义符合自己需要的错误类型,但是记住要让这些类型实现error接口,这样就不用向调用方暴露额外的类型。

比如下面我们自己定义了myError这个类型,如果不实现error接口的话,调用者的代码中就会被myError这个类型侵入。即,下面的run函数,在定义返回值类型时,直接定义成error即可。

如果myError不实现error接口的话,这里的返回值类型就要定义成myError类型。可想而知,紧接着调用者的程序里就要通过myError.Code == xxx 来判断到底是那种具体的错误(当然想要这么干得先把myError改成导出的MyError)。

package myerror

import (
	"fmt"
	"time"
)

type myError struct {
        Code int
	When time.Time
	What string
}

func (e *myError) Error() string {
	return fmt.Sprintf("at %v, %s",
		e.When, e.What)
}

func run() error {
	return &MyError{
    1002,
		  time.Now(),
		  "it didn't work",
	}
}

func TryIt() {
	if err := run(); err != nil {
		fmt.Println(err)
	}
}

那调用者判断自定义error是具体哪种错误的时候应该怎么办呢,myError并未向包外暴露,答案是通过向包外暴露检查错误行为的方法来实现。

myerror.IsXXXError(err) 
...

抑或是通过比较error本身与包向外暴露的常量错误是否相等来判断,比如操作文件时常用来判断文件是否结束的io.EOF

if err != io.EOF {
    return err
}

错误处理常犯的错误和解决方案

先看一段简单的程序,看大家能不能发现一些细微的问题

func WriteAll(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        log.Println("unable to write:", err) // annotated error goes to log file
        return err                           // unannotated error returned to caller
    }
    return nil
}

func WriteConfig(w io.Writer, conf *Config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        log.Printf("could not marshal config: %v", err)
        return err
    }
    if err := WriteAll(w, buf); err != nil {
        log.Println("could not write config: %v", err)
        return err
    }
    return nil
}

func main() {
    err := WriteConfig(f, &conf)
    fmt.Println(err) // io.EOF
}

错误处理常犯的两个问题

上面程序的错误处理暴露了两个问题:

  1. 底层函数WriteAll在发生错误后,除了向上层返回错误外还向日志里记录了错误,上层调用者做了同样的事情,记录日志然后把错误再返回给程序顶层。

    因此在日志文件中得到一堆重复的内容,

unable to write: io.EOF
could not write config: io.EOF
...
  1. 在程序的顶部,虽然得到了原始错误,但没有相关内容,换句话说没有把WriteAllWriteConfig记录到log里的那些信息包装到错误里,返回给上层。

针对这两个问题的解决方案可以是,在底层函数WriteAllWriteConfig中为发生的错误添加上下文信息,然后将错误返回上层,由上层程序最后处理这些错误。

一种简单的保证错误的方法是使用fmt.Errorf函数,给错误添加信息

func WriteConfig(w io.Writer, conf *Config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        return fmt.Errorf("could not marshal config: %v", err)
    }
    if err := WriteAll(w, buf); err != nil {
        return fmt.Errorf("could not write config: %v", err)
    }
    return nil
}
func WriteAll(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        return fmt.Errorf("write failed: %v", err)
    }
    return nil
}

给错误附加上下文信息

fmt.Errorf只是给错误添加了简单的注解信息,如果你想在添加信息的同时还加上错误的调用栈,可以借助github.com/pkg/errors这个包,提供的包装错误的能力

//只附加新的信息
func WithMessage(err error, message string) error

//只附加调用堆栈信息
func WithStack(err error) error

//同时附加堆栈和信息
func Wrap(err error, message string) error

有包装方法,就有对应的解包方法,Cause方法会返回包装错误对应的最原始错误--即会递归地进行解包

func Cause(err error) error

下面是使用github.com/pkg/errors改写后的错误处理程序

func ReadFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, errors.Wrap(err, "open failed")
    }
    defer f.Close()
    buf, err := ioutil.ReadAll(f)
    if err != nil {
        return nil, errors.Wrap(err, "read failed")
    }
    return buf, nil
}
func ReadConfig() ([]byte, error) {
    home := os.Getenv("HOME")
    config, err := ReadFile(filepath.Join(home, ".settings.xml"))
    return config, errors.WithMessage(err, "could not read config")
}


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

上面格式化字符串时用的 %+v 是在 % v 基础上,对值进行展开,即展开复合类型值,比如结构体的字段值等明细。

这样既能给错误添加调用栈信息,又能保留对原始错误的引用,通过Cause可以还原到最初始引发错误的原因。

总结

总结一下,错误处理的原则就是:

  1. 错误只在逻辑的最外层处理一次,底层只返回错误。
  2. 底层除了返回错误外,要对原始错误进行包装,增加错误信息、调用栈等这些利于排查的上下文信息。

参考链接

x

@kevinyan815 kevinyan815 changed the title 关于错误处理的一些建议 关于Golang错误处理的一些建议 Sep 22, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant