Go语言动手写Web框架 - Gee第七天 错误恢复(Panic Recovery)

8 阅读5分钟

写在前面:本项目参考 7天用Go从零实现Web框架Gee教程,下面的内容全部参考自该网站。主要是记录我在学习过程中的所思所想,如有存在问题还请多多指教。

今天我们实现 Gee 框架最后的一个部分:错误恢复。我们先来看一个例子:

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 框架编写了如下代码,那么就现有的 Gee 框架来说,如果直接访问 localhost:9999/panic,Web 服务会直接宕机。但是对于这类错误,我们希望有错误的处理,比如这个问题,是我们开发者出的问题,那么用户访问的时候我们希望用户知道错误的原因,我们希望能够向用户返回一个 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 挂载上错误恢复的函数,在这个函数中调用 recovery(),捕获 panic,并且将堆栈信息打印在日志中,向用户返回 Internal Server Error。但是你应该会注意到,这里面有一个 trace() 函数,这是用来干嘛的呢?

首先,Recovery() 函数已经可以做到捕获 panic 确保 Web 服务不会宕机,并向用户返回 Internal Server Error 了,那么为什么还需要 trace() 呢?

这是因为,如果没有 panic,终端会直接输出捕获到的原始的 panic 信息,即代码中的:

message := fmt.Sprintf("%s", err)
log.Printf("%s\n\n", message) // 假设这里没有 trace

这个打印出来的 message 实际上我们没法获取到什么信息,也无法根据其来进行追踪溯源找到问题所在(对于使用 Gee 的开发者来说),因此,我们希望在终端打印出来的错误日志是可以让开发者定位到问题所在的日志的。所以,trace 出现了,我们需要实现 trace 来做到获取 panic 的堆栈信息,进行处理之后返回打印出方便定位的错误日志的。

这里给出 trace 的实现代码,具体的实现细节在注释中解释清楚了:

// trace 用于把函数调用路径展示出来,如果没有 trace,Recover() 仅仅只会简单的输出 panic 的原始消息,而 trace 可以将 panic 的消息加工处理,使得我们可以追根溯源找到问题所在
func trace(message string) string {
        var pcs [32]uintptr             // 用于记录函数调用顺序
        n := runtime.Callers(3, pcs[:]) // 从 goroutine 的调用栈中抓取“正在回退的函数路径”,跳过了前三层:Callers自己、trace 和 defer

        var str strings.Builder // 这个用于拼接字符串,至于为什么使用 Builder,是因为它是一个高性能拼接器,避免 str += ... 之类的操作
        str.WriteString(message + "\nTraceback:")
        for _, pc := range pcs[:n] { // 遍历 pcs,其中 pc 是某个函数正在执行的时候的 CPU 地址
                fn := runtime.FuncForPC(pc)                           // 把 CPU 地址翻译为函数对象,例如从 0x1054f8c1 变为 main.main.func2
                file, line := fn.FileLine(pc)                         // 从函数对象中查出源码的坐标,例如 /tmp/main.go:47
                str.WriteString(fmt.Sprintf("\n\t%s:%d", file, line)) // 把结果写入 str,最后返回
        }
        return str.String()
        // 返回的结果类似于:
        //Traceback:
        //        /tmp/main.go:47
        //        /tmp/context.go:41
        //        /tmp/recovery.go:37
        //        ...
}

trace() 中,调用了 runtime.Callers(3, pcs[:]),Callers 用来返回调用栈的程序计数器, 第 0 个 Caller 是 Callers 本身,第 1 个是上一层 trace,第 2 个是再上一层的 defer func。因此,为了日志简洁一点,我们跳过了前 3 个 Caller。

接下来,通过 runtime.FuncForPC(pc) 获取对应的函数,在通过 fn.FileLine(pc) 获取到调用该函数的文件名和行号,打印在日志中。

至此,gee 框架的错误处理机制就完成了。

使用 demo 进行测试

最后,我们使用一个 demo 进行测试:

package main

import (
        "net/http"

        "gee"
)

func main() {
        r := gee.Default()
        r.GET("/", func(c *gee.Context) {
                c.String(http.StatusOK, "Hello Way2top\n")
        })
        // index out of range for testing Recovery()
        r.GET("/panic", func(c *gee.Context) {
                names := []string{"Way2top"}
                c.String(http.StatusOK, names[100])
        })

        r.Run(":9999")
}

这里的 gee.Default() 是默认使用了 Logger() 和 Recovery() 这两个中间件:

// gee.go
// Default 使用 Logger() & Recovery 中间件
func Default() *Engine {
        engine := New()
        engine.Use(Logger(), Recovery())
        return engine
}

启动服务后,在终端使用命令:

Gee on  main [!+] via  v1.24.3 
❯ curl http://localhost:9999/                                   
Hello Way2top

Gee on  main [!+] via  v1.24.3 
❯ curl http://localhost:9999/panic                              
{"message":"Internal Server Error"}

终端输出:

❯ go run main.go                                                
2026/01/19 15:36:11 Route  GET - /
2026/01/19 15:36:11 Route  GET - /panic
2026/01/19 15:36:17 [200] / in 6.292µs
2026/01/19 15:36:26 runtime error: index out of range [100] with length 1
Traceback:
        /opt/homebrew/opt/go/libexec/src/runtime/panic.go:788
        /opt/homebrew/opt/go/libexec/src/runtime/panic.go:115
        /Users/wry/Documents/coding/GoProject/Gee/main.go:17
        /Users/wry/Documents/coding/GoProject/Gee/gee/context.go:38
        /Users/wry/Documents/coding/GoProject/Gee/gee/recovery.go:21
        /Users/wry/Documents/coding/GoProject/Gee/gee/context.go:38
        /Users/wry/Documents/coding/GoProject/Gee/gee/logger.go:15
        /Users/wry/Documents/coding/GoProject/Gee/gee/context.go:38
        /Users/wry/Documents/coding/GoProject/Gee/gee/router.go:120
        /Users/wry/Documents/coding/GoProject/Gee/gee/gee.go:131
        /opt/homebrew/opt/go/libexec/src/net/http/server.go:3302
        /opt/homebrew/opt/go/libexec/src/net/http/server.go:2103
        /opt/homebrew/opt/go/libexec/src/runtime/asm_arm64.s:1224

2026/01/19 15:36:26 [500] /panic in 300.459µs