重构与错误处理:用异常取代错误码,用卫语句简化错误处理

错误处理这件事,说实话,很多团队都做得不够好。

我见过太多代码里充斥着这样的场景:一个函数返回 -1 表示失败,返回 0 表示成功,返回 1 表示某种特殊错误。然后调用方要小心翼翼地检查每个返回值,稍不留神就漏掉一个判断,线上就出了事故。

今天我们就来聊聊,怎么用异常取代错误码,用卫语句简化错误处理。这两个手法,是我在项目中反复验证过的。

错误码的痛点

先看一段典型的错误码风格代码。嗯,你肯定见过:

public int saveUser(User user) {
    if (user == null) {
        return -1; // 参数为空
    }
    if (user.getName() == null || user.getName().isEmpty()) {
        return -2; // 用户名不能为空
    }
    if (user.getAge() < 0 || user.getAge() > 150) {
        return -3; // 年龄不合法
    }
    // 保存逻辑...
    return 0; // 成功
}

调用方呢?得这样写:

int result = saveUser(user);
if (result == -1) {
    // 处理参数为空
} else if (result == -2) {
    // 处理用户名为空
} else if (result == -3) {
    // 处理年龄不合法
} else if (result == 0) {
    // 成功,继续后续逻辑
}

你想想看,这有什么问题?

  • 调用方容易遗漏检查。我见过有人直接写 saveUser(user); 然后继续往下走,完全不看返回值。结果数据没保存成功,用户还以为操作完成了。
  • 错误码没有语义。-1、-2、-3 这些数字,鬼知道什么意思。你还要去翻文档或者看函数注释。
  • 错误处理逻辑和业务逻辑混在一起。每个调用点都要写一堆 if-else,代码可读性极差。
我曾经在一个遗留系统里看到,一个函数返回了 20 多种错误码。调用方为了处理这些错误码,写了将近 300 行的 switch-case。后来加了一个新错误码,有三个调用点忘记更新,直接导致数据不一致。那次事故让我下定决心,必须用异常取代错误码。

用异常取代错误码

异常的好处是什么?说白了,它把「错误处理」和「正常逻辑」分开了。你不需要在每个调用点都检查返回值,只需要在合适的地方捕获异常即可。

重构后的代码:

public void saveUser(User user) {
    if (user == null) {
        throw new IllegalArgumentException("用户对象不能为空");
    }
    if (user.getName() == null || user.getName().isEmpty()) {
        throw new IllegalArgumentException("用户名不能为空");
    }
    if (user.getAge() < 0 || user.getAge() > 150) {
        throw new IllegalArgumentException("年龄不合法,范围应在0-150之间");
    }
    // 保存逻辑...
}

调用方:

try {
    saveUser(user);
    // 成功,继续后续逻辑
} catch (IllegalArgumentException e) {
    // 统一处理参数校验错误
    log.error("保存用户失败:{}", e.getMessage());
    // 返回友好的错误信息给前端
}

你看,调用方只需要一个 try-catch 就能处理所有参数校验错误。而且异常本身携带了错误信息,不需要再去查错误码表。

我个人习惯:自定义业务异常类,比如 BusinessException,然后在这个异常里带上错误码和错误信息。这样既保留了错误码的结构化信息,又享受了异常带来的便利。

卫语句:提前返回,减少嵌套

刚才的代码里,我用了多个 if 判断来校验参数。这其实已经是一种「卫语句」的写法了。但很多人写代码时,喜欢这样:

public void processOrder(Order order) {
    if (order != null) {
        if (order.isValid()) {
            if (order.getItems() != null && !order.getItems().isEmpty()) {
                // 处理订单逻辑...
            } else {
                log.error("订单项为空");
            }
        } else {
            log.error("订单无效");
        }
    } else {
        log.error("订单对象为空");
    }
}

这种嵌套结构,我称之为「箭头形代码」。读起来非常累,你要一层一层地往里看,才能找到真正的业务逻辑。

用卫语句重构:

public void processOrder(Order order) {
    if (order == null) {
        log.error("订单对象为空");
        return;
    }
    if (!order.isValid()) {
        log.error("订单无效");
        return;
    }
    if (order.getItems() == null || order.getItems().isEmpty()) {
        log.error("订单项为空");
        return;
    }
    // 处理订单逻辑...
}

为什么会这样?因为卫语句的核心思想是:先处理异常情况,提前返回,把正常逻辑留在最后。这样代码的阅读路径是线性的,而不是嵌套的。

核心原则:每个函数只做一件事,并且把「边界情况」和「异常情况」提前处理掉。剩下的就是主路径,清晰明了。

异常 vs 卫语句:什么时候用哪个?

这个问题我经常被问到。我的建议是:

场景 推荐方式 原因
参数校验、前置条件检查 卫语句 + 异常 卫语句提前返回,异常传递错误信息
业务逻辑中的错误 异常 业务错误需要被上层统一处理,不适合提前返回
外部资源调用失败 异常 网络、数据库等错误是不可预期的,必须用异常传播
简单的 null 检查 卫语句 直接 return 或抛异常,不需要 try-catch

说白了,卫语句适合「检查到问题就立即停止」的场景,异常适合「需要把错误信息传递到上层统一处理」的场景。两者不冲突,可以结合使用。

实战案例:用户注册功能

我们来看一个完整的例子。假设有一个用户注册功能,需要校验参数、检查用户名是否重复、发送验证码。用错误码的方式写出来是这样的:

public int register(String username, String password, String email) {
    if (username == null || username.length() < 3) {
        return 1001; // 用户名不合法
    }
    if (password == null || password.length() < 6) {
        return 1002; // 密码不合法
    }
    if (email == null || !email.contains("@")) {
        return 1003; // 邮箱不合法
    }
    if (userRepository.existsByUsername(username)) {
        return 2001; // 用户名已存在
    }
    // 发送验证码...
    if (!smsService.sendCode(email)) {
        return 3001; // 验证码发送失败
    }
    // 保存用户...
    return 0; // 成功
}

调用方:

int result = register(username, password, email);
switch (result) {
    case 1001: // 提示用户名不合法
    case 1002: // 提示密码不合法
    case 1003: // 提示邮箱不合法
    case 2001: // 提示用户名已存在
    case 3001: // 提示验证码发送失败
    case 0:    // 注册成功,跳转页面
}

重构后,用异常 + 卫语句:

public void register(String username, String password, String email) {
    // 卫语句:参数校验
    if (username == null || username.length() < 3) {
        throw new BusinessException(1001, "用户名长度至少3位");
    }
    if (password == null || password.length() < 6) {
        throw new BusinessException(1002, "密码长度至少6位");
    }
    if (email == null || !email.contains("@")) {
        throw new BusinessException(1003, "邮箱格式不正确");
    }
    // 卫语句:业务校验
    if (userRepository.existsByUsername(username)) {
        throw new BusinessException(2001, "用户名已被注册");
    }
    // 发送验证码
    boolean sent = smsService.sendCode(email);
    if (!sent) {
        throw new BusinessException(3001, "验证码发送失败,请稍后重试");
    }
    // 保存用户
    userRepository.save(new User(username, password, email));
}

调用方:

try {
    register(username, password, email);
    // 注册成功,跳转页面
} catch (BusinessException e) {
    // 统一处理业务错误,e.getCode() 和 e.getMessage() 返回给前端
    log.warn("注册失败:{}", e.getMessage());
    // 显示错误信息给用户
}

你看,调用方只需要一个 try-catch,所有业务错误都被统一处理了。而且每个错误都带有明确的错误码和错误信息,前端可以根据错误码做不同的展示。

注意:不要滥用异常。如果某个错误是「预期内」的,比如用户输入了重复的用户名,用 BusinessException 是合理的。但如果某个错误是「编程错误」,比如空指针,那就应该用 RuntimeException,让它在开发阶段就暴露出来。

SVG:错误处理重构的核心逻辑

下面这张图展示了从错误码到异常 + 卫语句的转变过程:

错误处理重构:从错误码到异常 + 卫语句 重构前:错误码 函数返回 int 表示结果 调用方需要检查每个返回值 错误码没有语义,容易遗漏 业务逻辑和错误处理混在一起 典型代码 if (result == -1) { ... } else if (result == -2) { ... } 重构 重构后:异常 + 卫语句 异常携带错误码和信息 卫语句提前返回,减少嵌套 调用方统一 try-catch 正常逻辑和错误处理分离 典型代码 if (user == null) { throw ... } try { register(...); } catch (e) { ... } 核心目标:让错误处理变得可预期、可维护、可理解

避坑指南

最后,分享几个我踩过的坑:

  • 不要用异常控制流程。比如用 try-catch 来跳出循环,或者用异常来传递正常业务数据。异常的开销比普通返回大得多,而且会让代码逻辑变得混乱。
  • 不要吞掉异常。我见过有人写 catch (Exception e) {},什么都不做。这等于把错误藏起来了,线上出了问题根本查不到原因。至少打个日志。
  • 卫语句不要过度。如果一个函数有七八个卫语句,说明这个函数做的事情太多了。考虑拆分成多个小函数。
  • 异常粒度要适中。不要一个函数只抛一种异常,也不要抛几十种。我个人习惯是:每个模块定义 3-5 种业务异常,配合错误码使用。
我曾经在一个项目中,把所有错误都抛同一个 Exception,然后调用方用 e.getMessage() 来判断错误类型。结果后来需求变了,错误信息改了,前端解析就全乱了。从那以后,我坚持用自定义异常 + 错误码,前端只认错误码,不认错误信息。

好了,关于用异常取代错误码、用卫语句简化错误处理,就聊到这里。这两个手法结合起来,能让你的错误处理代码变得干净、可维护。下次写代码时,不妨试试看。


公众号:蓝海资料掘金营,微信deep3321