本文代码地址:https://gitee.com/lymgoforIT/gee-web/tree/master/day7-panic-recover
本文是7天用Go从零实现Web框架Gee教程系列
的第七篇。
- 实现错误处理机制。
panic
Go
语言中,比较常见的错误处理方法是返回 error
,由调用者决定后续如何处理。但是如果是无法恢复的错误,可以手动触发 panic
,当然如果在程序运行过程中出现了类似于数组越界的错误,panic
也会被触发。panic
会中止当前执行的程序,退出。
下面是主动触发的例子:
// hello.go
func main() {
fmt.Println("before panic")
panic("crash")
fmt.Println("after panic")
}
$ go run hello.go
before panic
panic: crash
goroutine 1 [running]:
main.main()
~/go_demo/hello/hello.go:7 +0x95
exit status 2
下面是数组越界触发的 panic
// hello.go
func main() {
arr := []int{1, 2, 3}
fmt.Println(arr[4])
}
$ go run hello.go
panic: runtime error: index out of range [4] with length 3
defer
panic
会导致程序被中止,但是在退出前,会先处理完当前协程上已经defer
的任务,执行完成后再退出。效果类似于 java
语言的 try...catch
。
// hello.go
func main() {
defer func() {
fmt.Println("defer func")
}()
arr := []int{1, 2, 3}
fmt.Println(arr[4])
}
$ go run hello.go
defer func
panic: runtime error: index out of range [4] with length 3
可以 defer
多个任务,在同一个函数中 defer
多个任务,会逆序执行。即先执行最后 defer
的任务。
在这里,defer
的任务执行完成之后,panic
还会继续被抛出,导致程序非正常结束。注意这里的继续抛出是往上一层 函数调用栈抛出,如果当前是子协程抛的panic,子协程自身没有捕获,就不会继续抛给父协程了,而是直接panic导致程序退出。所以一般在开启子协程时,建议在协程开始的地方就用上defer 与recover避免子协程导致程序崩溃。注意这里说的的函数和子协程的区别哦
recover
Go
语言还提供了 recover
函数,可以避免因为 panic
发生而导致整个程序终止,recover
函数只在 defer
中生效。
// hello.go
func test_recover() {
defer func() {
fmt.Println("defer func")
if err := recover(); err != nil {
fmt.Println("recover success")
}
}()
arr := []int{1, 2, 3}
fmt.Println(arr[4])
fmt.Println("after panic")
}
func main() {
test_recover()
fmt.Println("after recover")
}
$ go run hello.go
defer func
recover success
after recover
我们可以看到,recover
捕获了 panic
,程序正常结束。test_recover()
中的 after panic
没有打印,这是正确的,当 panic
被触发时,控制权就被交给了 defer
。就像在 java
中,try
代码块中发生了异常,控制权交给了 catch
,接下来执行 catch
代码块中的代码。而在 main()
中打印了 after recover
,说明程序已经恢复正常,继续往下执行直到结束。
Gee 的错误处理机制
对一个 Web
框架而言,错误处理机制是非常必要的。可能是框架本身没有完备的测试,导致在某些情况下出现空指针异常等情况。也有可能用户不正确的参数,触发了某些异常,例如数组越界,空指针等。如果因为这些原因导致系统宕机,必然是不可接受的。
我们在第五天实现的框架并没有加入异常处理机制,如果代码中存在会触发 panic
的 BUG
,很容易宕掉。
例如下面的代码:
func main() {
r := gee.New()
r.GET("/panic", func(c *gee.Context) {
names := []string{"geektutu"}
c.String(http.StatusOK, names[100])
})
r.Run(":9999")
}
在上面的代码中,我们为 gee
注册了路由 /panic
,而这个路由的处理函数内部存在数组越界 names[100]
,如果访问 localhost:9999/panic
,Web
服务就会宕掉。
今天,我们将在 gee
中添加一个非常简单的错误处理机制,即在此类错误发生时,向用户返回 Internal Server Error
,并且在日志中打印必要的错误信息,方便进行错误定位。
我们之前实现了中间件机制,错误处理也可以作为一个中间件,增强 gee
框架的能力。
新增文件 gee/recovery.go
,在这个文件中实现中间件 Recovery
。
func Recovery() HandlerFunc {
return func(c *Context) {
defer func() {
if err := recover(); err != nil {
message := fmt.Sprintf("%s", err)
log.Printf("%s\n\n", trace(message))
c.Fail(http.StatusInternalServerError, "Internal Server Error")
}
}()
c.Next()
}
}
Recovery
的实现非常简单,使用 defer
挂载上错误恢复的函数,在这个函数中调用 recover()
,捕获 panic
,并且将堆栈信息打印在日志中,向用户返回 Internal Server Error
。但是要注意,Recovery只能捕获到c.Next()调用链上没有开启子协程情况下的panic,如果用户在某个handler中开启了子协程,则Gee是无能为力的,即子协程如果panic并且子协程自身没有捕获panic的话,Recovery中间件是捕获不到的,最终会导致程序宕掉。
你可能注意到,这里有一个 trace()
函数,这个函数是用来获取触发 panic
的堆栈信息,完整代码如下:
day7-panic-recover/gee/recovery.go
package gee
import (
"fmt"
"log"
"net/http"
"runtime"
"strings"
)
// print stack trace for debug
func trace(message string) string {
var pcs [32]uintptr
n := runtime.Callers(3, pcs[:]) // skip first 3 caller
var str strings.Builder
str.WriteString(message + "\nTraceback:")
// 遍历跳过三级后,之前的所有函数栈信息
for _, pc := range pcs[:n] {
fn := runtime.FuncForPC(pc)
file, line := fn.FileLine(pc)
str.WriteString(fmt.Sprintf("\n\t%s:%d", file, line))
}
return str.String()
}
func Recovery() HandlerFunc {
return func(c *Context) {
defer func() {
if err := recover(); err != nil {
message := fmt.Sprintf("%s", err)
log.Printf("%s\n\n", trace(message))
c.Fail(http.StatusInternalServerError, "Internal Server Error")
}
}()
c.Next()
}
}
在 trace()
中,调用了 runtime.Callers(3, pcs[:])
,Callers
用来返回调用栈的程序计数器, 第 0
个 Caller
是 Callers函数
本身,第 1
个是上一层 trace函数
,第 2
个是再上一层的 defer func函数
。因此,为了日志简洁一点,我们跳过了前 3
个 Caller
,只打印在调用defer func函数
之前的所有函数栈信息。
接下来,通过 runtime.FuncForPC(pc)
获取对应的函数,再通过 fn.FileLine(pc)
获取到调用该函数的文件名和行号,打印在日志中。
由于日志和异常捕获两个中间件非常重要,所以我们可以在引擎初始化时,提供默认的方法,让其直接使用这两个中间件。
day7-panic-recover/gee/gee.go
// Default use Logger() & Recovery middlewares
func Default() *Engine {
engine := New()
engine.Use(Logger(), Recovery())
return engine
}
至此,gee
框架的错误处理机制就完成了。
使用 Demo
day7-panic-recover/main.go
package main
import (
"net/http"
"gee"
)
func main() {
r := gee.Default()
r.GET("/", func(c *gee.Context) {
c.String(http.StatusOK, "Hello Geektutu\n")
})
// index out of range for testing Recovery()
r.GET("/panic", func(c *gee.Context) {
names := []string{"geektutu"}
c.String(http.StatusOK, names[100])
})
r.Run(":9999")
}
接下来进行测试,先访问主页,访问一个有BUG
的 /panic
,服务正常返回。接下来我们再一次成功访问了主页,说明服务完全运转正常。
$ curl "http://localhost:9999"
Hello Geektutu
$ curl "http://localhost:9999/panic"
{"message":"Internal Server Error"}
$ curl "http://localhost:9999"
Hello Geektutu
我们可以在后台日志中看到如下内容,引发错误的原因和堆栈信息都被打印了出来,通过日志,我们可以很容易地知道,在day7-panic-recover/main.go:47
的地方出现了 index out of range
错误。
2020/01/09 01:00:10 Route GET - /
2020/01/09 01:00:10 Route GET - /panic
2020/01/09 01:00:22 [200] / in 25.364µs
2020/01/09 01:00:32 runtime error: index out of range
Traceback:
/usr/local/Cellar/go/1.12.5/libexec/src/runtime/panic.go:523
/usr/local/Cellar/go/1.12.5/libexec/src/runtime/panic.go:44
/tmp/7days-golang/day7-panic-recover/main.go:47
/tmp/7days-golang/day7-panic-recover/gee/context.go:41
/tmp/7days-golang/day7-panic-recover/gee/recovery.go:37
/tmp/7days-golang/day7-panic-recover/gee/context.go:41
/tmp/7days-golang/day7-panic-recover/gee/logger.go:15
/tmp/7days-golang/day7-panic-recover/gee/context.go:41
/tmp/7days-golang/day7-panic-recover/gee/router.go:99
/tmp/7days-golang/day7-panic-recover/gee/gee.go:130
/usr/local/Cellar/go/1.12.5/libexec/src/net/http/server.go:2775
/usr/local/Cellar/go/1.12.5/libexec/src/net/http/server.go:1879
/usr/local/Cellar/go/1.12.5/libexec/src/runtime/asm_amd64.s:1338
2020/01/09 01:00:32 [500] /panic in 395.846µs
2020/01/09 01:00:38 [200] / in 6.985µs