4. 错误处理模式:Go与Java的异常哲学

错误处理这件事,说白了就是代码里的「安全带」。我见过太多系统崩溃,根源不是业务逻辑多复杂,而是错误没处理好。今天咱们聊聊Go和Java这两套截然不同的思路——一个极简,一个厚重。

4.1 Go语言的error处理:显式地拥抱错误

Go的设计哲学很直接:错误就是值。它不像Java那样抛出一个异常对象,而是把错误当作普通返回值。你想想看,这其实挺符合直觉的——函数执行完,要么给我结果,要么告诉我哪里出错了。

核心原则:Go鼓励你每次调用都检查error,别偷懒。

4.1.1 error接口与自定义错误

Go的error是一个内置接口,就一个方法:

type error interface {
    Error() string
}

我在项目中遇到过一种情况:光返回一个字符串错误信息,根本不够用。比如数据库操作失败,你不仅要知道「失败了」,还得知道是连接超时还是SQL语法错。这时候就需要自定义错误类型:

type DBError struct {
    Code    int
    Message string
    Err     error  // 原始错误,方便链式追踪
}

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

这样调用方就能通过类型断言拿到更多上下文:

if err != nil {
    var dbErr *DBError
    if errors.As(err, &dbErr) {
        // 根据 dbErr.Code 做不同处理
    }
}

4.1.2 错误包装与链式追踪

Go 1.13之后引入了%w格式化动词和errors.Is/errors.As,这玩意儿太好用了。以前我们只能自己拼字符串,现在可以优雅地包装错误:

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
    }
    // 解析 data...
}

调用方用errors.Is就能判断根因:

if errors.Is(err, os.ErrNotExist) {
    // 文件不存在,走默认配置
}

我的习惯:每个可能出错的函数,返回值第一个位置放结果,第二个放error。别为了省事把error塞到结果结构体里——那会让调用方更容易忽略错误。

4.2 Go的panic/recover:真的别乱用

Go里有个panic,类似Java的运行时异常。但Go社区的态度很明确:panic是用来处理「不可能发生」的情况的,比如数组越界、空指针解引用。业务逻辑的错误,请用error。

我曾经接手过一个项目,前任工程师把参数校验失败也panic了……结果每次请求进来,recover一把捞,性能惨不忍睹。嗯,这就是典型的用错场景。

4.2.1 什么时候该用panic?

  • 初始化失败:比如配置文件解析不出来,程序启动就该挂掉
  • 不可恢复的状态:比如内存分配失败、关键goroutine挂掉
  • 程序员错误:比如接口实现不符合契约

4.2.2 recover的正确姿势

recover只在defer函数里有效。我建议你在每个goroutine的入口处都放一个recover,防止一个协程崩溃拖垮整个进程:

func SafeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("goroutine panic recovered: %v", r)
                // 这里可以上报监控、打印堆栈
            }
        }()
        fn()
    }()
}

注意:recover之后,goroutine就结束了。如果你需要继续处理,得在recover外面重新调度。别指望recover能让panic的代码继续往下跑——它只是让你优雅地死掉。

4.3 Java的异常层次结构:分门别类地管理故障

Java的异常体系,说白了就是一张分类表。它把问题分成两大类:检查型异常(Checked Exception)和运行时异常(RuntimeException)。这个设计初衷是好的——编译器逼你处理那些可预见的错误。但我在实际项目里发现,很多人被这个机制搞得很痛苦。

4.3.1 异常层次结构速览

异常类型 父类 是否强制处理 典型场景
Checked Exception Exception(不含RuntimeException) 是,必须try-catch或throws 文件不存在、网络超时、SQL异常
RuntimeException RuntimeException 否,可处理可不处理 空指针、数组越界、参数非法
Error Error 不建议处理 OutOfMemoryError、StackOverflowError

你想想看,FileNotFoundException这种异常,编译器逼你处理,其实挺合理的——文件可能确实不存在。但SQLException就有点烦了,每次数据库操作都要try-catch,代码里全是模板代码。

4.3.2 try-catch-finally的执行顺序

这个面试常考,但实际写代码时很多人搞混。我直接给你画个流程图:

Java try-catch-finally 执行流程 try 块开始 执行 try 块中的代码 是否抛出异常? 匹配 catch 块(按顺序) 执行匹配的 catch 块 finally 块(始终执行) 继续执行后续代码 关键点 1. 无异常 → 跳过catch 2. 有异常 → 匹配catch 3. finally 始终执行

这里有个坑:finally块里如果return了,会覆盖try或catch里的return。我曾经排查过一个诡异的bug——方法明明返回了true,调用方却拿到false。查了半天,finally里有个清理代码偷偷return了false。嗯,从那以后我定了个规矩:finally里只做资源释放,绝不写return。

4.3.3 try-with-resources:Java 7的救星

Java 7之前,关闭资源得在finally里写一堆null判断。Java 7之后,try-with-resources自动帮你关:

// 旧写法
BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("test.txt"));
    // 读文件
} catch (IOException e) {
    log.error("读取失败", e);
} finally {
    if (br != null) {
        try { br.close(); } catch (IOException e) { /* 忽略 */ }
    }
}

// 新写法
try (BufferedReader br = new BufferedReader(new FileReader("test.txt"))) {
    // 读文件
} catch (IOException e) {
    log.error("读取失败", e);
}

我的建议:能用try-with-resources就别手写finally。少写代码,少出bug。

4.4 Go vs Java:两种哲学的对决

这两种语言的设计思路,其实反映了对「错误」的不同理解。我做个对比表,你一看就明白:

维度 Go Java
错误本质 错误是值(error) 错误是对象(Exception)
处理方式 显式检查返回值 隐式传播异常栈
强制程度 靠开发者自觉 编译器强制(Checked)
性能开销 几乎为零 异常创建有栈填充开销
适用场景 系统编程、微服务 企业应用、复杂业务

我个人更倾向Go的哲学——错误就是值,你没法假装它不存在。Java的检查型异常初衷是好的,但实践中容易导致两种极端:要么在每一层都try-catch然后吞掉异常,要么在方法签名上挂一长串throws。这两种都不健康。

避坑指南:我曾经见过一个Java项目,所有方法签名都是throws Exception。这等于告诉调用方:「我也不知道会出什么错,你自己看着办吧。」——那检查型异常的意义就完全丧失了。

4.5 总结:选对模式,别选信仰

Go和Java的错误处理没有绝对的好坏。关键看你的团队和场景:

  • 团队习惯Go:老老实实每个error都检查,别嫌啰嗦。用errors.Is和errors.As做链式追踪。
  • 团队习惯Java:检查型异常用于「调用方能合理恢复」的场景;运行时异常用于「程序员犯错」的场景。别滥用throws。
  • 无论哪种语言:别吞错误,别用异常做流程控制,别在finally里写return。

错误处理这件事,说白了就是代码的「安全气囊」。平时觉得多余,真出事的时候,它能救命。