一、panic不是世界末日

刚接触Golang时,看到panic这个词总觉得特别吓人——就像程序突然心脏病发作一样。但事实上,它只是Go语言处理不可恢复错误的机制。举个例子:

func main() {
    // 模拟一个致命错误
    connectDB("invalid_connection_string") 
}

func connectDB(connStr string) {
    if connStr == "" {
        panic("数据库连接字符串不能为空") // 触发panic
    }
    // 假装连接数据库...
}

当这段代码运行时,控制台会打印堆栈信息然后退出。虽然看起来粗暴,但这正是panic的设计初衷——处理那些"程序已经无法正常继续"的情况。

不过在实际项目中,我们更希望程序能优雅地处理错误而不是直接崩溃。这时候就需要recover来拯救世界了。

二、recover的正确打开方式

recover就像是程序的急救包,必须配合defer使用才有效。看这个升级版的数据库连接示例:

func main() {
    safeConnect := func() {
        if err := recover(); err != nil {
            fmt.Printf("捕获到panic: %v\n", err)
            // 这里可以添加告警通知等逻辑
        }
    }
    
    defer safeConnect()
    
    connectDB("") // 传递空字符串触发panic
    fmt.Println("这行不会被执行")
}

func connectDB(connStr string) {
    if connStr == "" {
        panic("数据库连接字符串不能为空")
    }
    fmt.Println("数据库连接成功")
}

现在程序不会直接崩溃,而是会打印错误信息后继续运行。注意几个关键点:

  1. recover必须放在defer函数中
  2. recover只在直接发生panic的函数中无效
  3. 恢复后程序会从panic点之后继续执行

三、错误处理的层次艺术

在实际项目中,我们通常采用分层错误处理策略。来看一个Web服务的典型例子:

// 数据访问层
func GetUser(id int) (User, error) {
    if id <= 0 {
        return User{}, errors.New("无效的用户ID")
    }
    // 模拟数据库查询
    return User{ID: id, Name: "张三"}, nil
}

// 业务逻辑层
func ProcessUserOrder(userID int) (Order, error) {
    user, err := GetUser(userID)
    if err != nil {
        return Order{}, fmt.Errorf("查询用户失败: %w", err) // 错误包装
    }
    
    if user.Balance <= 0 {
        return Order{}, errors.New("用户余额不足")
    }
    // 处理订单逻辑...
    return Order{ID: "123", Amount: 100}, nil
}

// HTTP接口层
func handleOrderRequest(w http.ResponseWriter, r *http.Request) {
    userID, _ := strconv.Atoi(r.URL.Query().Get("user_id"))
    order, err := ProcessUserOrder(userID)
    
    if err != nil {
        // 根据错误类型返回不同HTTP状态码
        if errors.Is(err, ErrUserNotFound) {
            w.WriteHeader(http.StatusNotFound)
        } else {
            w.WriteHeader(http.StatusBadRequest)
        }
        fmt.Fprintf(w, `{"error": "%s"}`, err.Error())
        return
    }
    
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(order)
}

这种分层处理的好处是:

  1. 每层只处理自己关心的错误
  2. 通过错误包装保留完整调用链
  3. 顶层可以统一格式化错误输出

四、实战中的进阶技巧

4.1 自定义错误类型

当需要携带更多错误信息时,可以定义自己的错误类型:

type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Details string `json:"details,omitempty"`
}

func (e APIError) Error() string {
    return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

func NewAPIError(code int, msg string) *APIError {
    return &APIError{
        Code:    code,
        Message: msg,
    }
}

// 使用示例
func GetProduct(id int) (*Product, error) {
    if id == 0 {
        return nil, NewAPIError(400, "产品ID不能为空")
    }
    // ...
}

4.2 错误包装与解包

Go 1.13引入了错误包装机制,可以保留错误链:

var ErrDBConnection = errors.New("数据库连接失败")

func ConnectDB() error {
    // 模拟连接失败
    return fmt.Errorf("%w: 连接超时", ErrDBConnection)
}

func main() {
    err := ConnectDB()
    if errors.Is(err, ErrDBConnection) { // 错误匹配
        fmt.Println("捕获到数据库连接错误:", err)
    }
}

4.3 panic与recover的边界

虽然recover很强大,但也要注意使用边界:

  1. 不要用recover处理业务逻辑错误
  2. 协程中的panic需要单独recover
  3. 某些致命错误(如内存不足)无法被recover

看个协程安全的例子:

func safeGo(fn func()) {
    defer func() {
        if err := recover(); err != nil {
            log.Printf("协程panic: %v", err)
        }
    }()
    fn()
}

func main() {
    safeGo(func() {
        panic("测试协程panic")
    })
    time.Sleep(time.Second)
    fmt.Println("主协程继续执行")
}

五、错误处理哲学

经过这些实践,我总结出几个Go错误处理的黄金法则:

  1. 能用error解决的问题绝不用panic
  2. 错误信息要包含足够上下文
  3. 在程序边界处处理或转换错误
  4. 文档要明确说明函数可能返回的错误
  5. 保持错误处理代码的简洁性

记住,好的错误处理不是让程序永远不报错,而是让错误发生时能提供清晰的问题诊断路径。就像老司机开车,不是永远不出事故,而是知道怎么安全处理各种突发状况。