深入理解与使用go之错误处理–实现
引子
错误管理是构建健壮和可观察的应用程序的一个基本方面,它应该与代码库的任何其他部分一样重要。在Go中,错误管理不像大多数编程语言那样依赖于传统的try/catch
机制。相反,错误作为正常返回值返回。那么问题来了:
- 程序什么时候发生崩溃(panic)
- 通常处理错误的方式是怎样的
- 我们应该忽略错误么
- 在defer里出错应该怎么处理呢
- 所有的panic错误都是可以捕获的么
我记得刚写程序的时候,感觉对go的错误很茫然,一股脑的忽略错误,像下面这样
// json 解码
_ = json.Unmarshal(data, &User)
// 打开文件
f, _ := os.Open("hello")
或者,我们避无可避,选择这样判断错误
if strings.Contains(err.Error(), "op error") {
return ""
}
return data
- 如果json解码的是配置文件,忽略错误会直接导致整个程序启动崩溃
- 打开文件如果没有权限,下面的所有操作直接panic
- 如果A开发错误内容是小写 “op error” 而B开发是大写 “OP ERROR”, 能判断么
带着上面的这些问题,我们来讨论讨论今天要说的错误处理
错误处理
错误的分类
我觉得开始处理之前,我们很有必要对错误进行一定的分类
- 崩溃型错误
- 系统调用出现的崩溃 如堆栈溢出、数组越界、空指针引用等等
- 程序限制型崩溃,如初始化过程中的配置读取、日志路径权限等等,出现错误整个程序就不该继续往下走
- 普通型错误
- 错误我们需要处理,比如数据库连接异常 我们进行重试
- 正常型错误
- 数据库因为查询为空返回的错误
- 文件读取到末尾的EOF错误
- 我们不感兴趣且对程序执行不会产生重大影响的错误
有了这三个分类,我们来一个一个看
崩溃型错误
-
系统调用崩溃性错误
这种错误一般是程序为了避免进一步的不确定行为和数据损坏,而提前退出
如 数组越界
a := []int{1, 2, 3} fmt.Println(a[3])
运行会直接panic
panic: runtime error: index out of range [3] with length 3 goroutine 1 [running]: main.main() /data/www/hello/main.go:12 +0x1b exit status 2
-
程序限制型
net/http
包的server.go
有段代码func checkWriteHeaderCode(code int) { if code < 100 || code > 999 { panic(fmt.Sprintf("invalid WriteHeader code %v", code)) } }
原话是:
// We used to send "HTTP/1.1 000 0" on the wire in responses but there's // no equivalent bogus thing we can realistically send in HTTP/2, // so we'll consistently panic instead and help people find their bugs // early. (We can't return an error from WriteHeader even if we wanted to.) 我们过去常常在网络上发送“HTTP/1.1 000 0”作为响应,但现在。 我们不能在HTTP/2中实际发送等同的虚假信息, 因此,我们将持续恐慌,帮助人们及早发现他们的缺陷。(即使我们想从WriteHeader返回错误,也不能这样做。)
database/sql
包sql.go
func Register(name string, driver driver.Driver) { driversMu.Lock() defer driversMu.Unlock() if driver == nil { panic("sql: Register driver is nil") } if _, dup := drivers[name]; dup { panic("sql: Register called twice for driver " + name) } drivers[name] = driver }
解释是:
// Register makes a database driver available by the provided name. // If Register is called twice with the same name or if driver is nil, // it panics. 注册使数据库驱动程序按提供的名称可用。 如果使用相同的名称调用了Register两次,或者驱动程序为空, 它就会出现恐慌。
总结来说,我们的程序需要依赖项,如果程序员给了错误的依赖项,程序无法继续正常进行,就需要提前panic
这几种情况都被视为程序员限制型错误,我们再看一个例子
go 的 regexp 包提供了两个函数
func Compile(expr string) (*Regexp, error) func MustCompile(str string) *Regexp
第一个会多返回一个错误,而第二个则在出错的时候直接panic, 而我们更倾向于使用第二个函数
程序员在编写代码的时候,这种可预知的正则pattern模式错误是可以尽早的提前规避的
-
处理
如果我们在开发测试环境都未触发这些崩溃型错误,那到了生产环境部分case出现了,直接退出程序不再提供服务么
当然不行,我们应当在适当的地方增加捕获机制,并记录日志告警来提醒程序员及时修复这些错误
难道直接在入口函数里加 recovery
func main() { defer func() { if err := recover(); err != nil { log.Println(err) } }() a := []int{1, 2, 3} fmt.Println(a[3]) }
正常被捕获了
2024/03/20 09:29:35 runtime error: index out of range [3] with length 3
但如果这样呢
func printElm(index int) { a := []int{1, 2, 3} fmt.Println(a[index]) } func main() { defer func() { if err := recover(); err != nil { log.Println(err) } }() go printElm(3) time.Sleep(time.Second) }
直接panic了
panic: runtime error: index out of range [3] with length 3 goroutine 6 [running]: main.printElm(0x0?) /data/www/hello/main.go:11 +0x9a created by main.main /data/www/hello/main.go:20 +0x45 exit status 2
这里就要提到一个重要的概念:
recover
只能在相同的 Go 协程中捕获panic
所以在子协程中,我们也应该做好错误捕获
func printElm(index int) { defer func() { if err := recover(); err != nil { log.Println(err) } }() a := []int{1, 2, 3} fmt.Println(a[index]) }
普通型错误
-
调用三方包一般性错误 例如
num, err := strconv.Atoi("123") if err != nil { log.Println(err) num = 0 } fmt.Println(num)
这种我们要做的就是接收错误
- 可以兼容错误,兼容后继续处理
- 不可兼容错误,向上返回错误,或打日志后直接返回
-
自定义错误
正常情况下,如果我们代码包可能出现多种错误的情况下,我们需要根据不同的错误进行不同的处理,我们就需要定义错误类型
我们以
strconv
为例截取其中部分, 看看别人的包是怎么使用的错误-
定义错误类型
var ErrSyntax = errors.New("invalid syntax")
-
实现错误接口
type NumError struct { Func string // the failing function (ParseBool, ParseInt, ParseUint, ParseFloat, ParseComplex) Num string // the input Err error // the reason the conversion failed (e.g. ErrRange, ErrSyntax, etc.) } func (e *NumError) Error() string { return "strconv." + e.Func + ": " + "parsing " + Quote(e.Num) + ": " + e.Err.Error() }
-
包装错误方法
func syntaxError(fn, str string) *NumError { return &NumError{fn, cloneString(str), ErrSyntax} }
-
抛错
func ParseInt(s string, base int, bitSize int) (i int64, err error) { const fnParseInt = "ParseInt" if s == "" { return 0, syntaxError(fnParseInt, s) } // code here }
-
使用判断
假设我们要根据语法错误做特殊处理
num, err := strconv.ParseInt("", 10, 64) if errors.Is(err, strconv.ErrSyntax) { fmt.Println(err) // 特殊处理 } fmt.Println(num)
从上面的例子不难看出,完整的错误处理分了大概5步
我们日常使用中,如果对出错的参数和上下文不关心,错误处理可能就三步(第1,4,5)第4 步我们可以继续简化成
if s == "" { return 0, ErrSyntax }
-
-
包装错误
假设我们想使用别人的错误,但是又希望添加上我们的上下文
-
在go1.13之后,可以使用
%w
参数进行包装 -
然后使用
errors.As
进行包装错误的判断这里有个问题需要考虑下,
As
方法的第二个参数在实现Error
接口时,如果传递的是*
, 我们这里就要使用**
否则,使用
*
即可,参考 问题
我们包装了一个 panic 错误
type PanicError struct { Err error } func (p PanicError) Error() string { return fmt.Sprintf("panic error: %v", p.Err) }
错误使用处,我们添加指明了函数上下文
func printElm(index int) (err error) { defer func() { if e := recover(); e != nil { err = fmt.Errorf("printElm index err %w ", customerror.PanicError{ Err: fmt.Errorf("org error is %v", e), }) } }() a := []int{1, 2, 3} fmt.Println(a[index]) return }
然后使用时
err := printElm(3) if errors.As(err, new(customerror.PanicError)) { fmt.Println("panic error") }
这里需要注意的是,如果上面实现 我们使用了
func (p *PanicError) Error() string
这里我们需要替换成
panicError := new(customerror.PanicError) if errors.As(err, &panicError)
-
-
defer 错误
有一种错误产生可能是在
defer
语句里的,假设这样一个场景, 我们常常从数据库读取信息最后需要关闭连接
func getUserInfo(db *sql.DB, userId int) ( Info.User, error) { rows, err := db.Query(query, userId) if err != nil { return nil, err } defer rows.Close() // other code here }
-
如果
rows.Close()
成功了,好了没问题 -
如果
rows.Close()
失败了呢,有人说把错误处理 改成这样
defer func() { err = rows.Close() }()
-
那么问题来了,当
db.Query
出现错误后, 这里有没有错误是不是都会覆盖怎么办呢
- 如果程序本身 错误不为空
- 关闭错误有值 记录日志,返回程序本身错误
- 关闭错误无值 ,返回程序本身错误
- 如果程序本身无错误,直接将关闭错误赋值给返回值
defer func() { closeError := rows.Close() if err != nil { if closeError != nil { log.Printf("failed to close db rows: %v", err) } return } err = closeError }()
- 如果程序本身 错误不为空
-
正常型错误
正常型错误有两种
-
正常的程序逻辑状态,甚至都不应该称之为错误
比如下面的数据库返回条数为空
if err != nil { if err == sql.ErrNoRows { // 正常的数据为空 } else { // ... } }
-
错误可以出现,但我们不感兴趣
假设我们有个通知处理,是额外的一部分,我们对于是否出现错误,不关心, 可以如下处理
_ = notify()
如果后面其他开发看到,这个就会很困惑,最好的做法是, 我们加个注释,记录个日志
// notify the other people, don't care about online, we also notify offline err := notify() log.Println(err)
-
错误处理就是这些,如果有更好的做法,欢迎评论区讨论