Skip to content

自定义异常与异常转换 ​

学习目标 ​

  • 根据调用者是否能恢复,选择 checked 或 unchecked 自定义异常。
  • 为异常提供消息、cause 和必要的领域字段。
  • 在基础设施层到业务层的边界进行异常翻译,不丢失根因。

核心知识点 ​

专业术语 ​

  • 领域异常:用业务语言表达“订单不存在”“余额不足”等可理解失败。
  • 异常链(cause chain):高层异常通过 cause 保留低层异常。
  • 异常转换(translation):在层边界把底层技术异常包装为当前层能理解的异常。
  • checked domain exception:继承 Exception,要求调用方捕获或声明。
  • unchecked domain exception:继承 RuntimeException,适合调用方无法合理恢复或属于业务前置条件的错误。

白话解释与边界 ​

自定义异常不是为了增加类数量,而是为了让调用者知道“该怎么处理”。文件读取失败通常在基础设施层保留 IOException,在服务层可以转换为 ConfigLoadException;用户输入不符合业务规则时,BalanceNotEnoughException 这类 RuntimeException 可以直接由统一异常处理器映射成响应。

异常消息供人阅读,字段供程序处理;不要让调用方通过解析 message 判断错误类型。翻译时必须传 cause,日志层可以沿着 getCause() 还原根因。

常用用法 ​

定义 unchecked 领域异常 ​

RuntimeException 适合表示当前调用无法继续、通常由统一边界处理的业务失败;异常类名要表达稳定的领域语义。

java
// 语义:RuntimeException 适合表示当前调用无法继续、通常由统一边界处理的业务失败。
final class UserNotFoundException extends RuntimeException {
    UserNotFoundException(String userId) {
        super("user not found: " + userId);
    }
}

public class UncheckedDomainExceptionDemo {
    static String findUser(String id) {
        if (!"u-1".equals(id)) {
            throw new UserNotFoundException(id);
// 异常:throw new UserNotFoundException(id) 立即进入异常路径。
// 初始状态:表达式为 throw new UserNotFoundException(id)。
        }
        return "Alice";
    }

    public static void main(String[] args) {
        try {
            findUser("missing");
// 异常:findUser("missing") 抛出 UserNotFoundException,控制流转到 catch。
        } catch (UserNotFoundException error) {
            System.out.println(error.getMessage());
            // 输出:user not found: missing
        }
    }
}

定义 checked 领域异常 ​

checked 异常适合调用方确实有不同恢复路径的情况,例如重试、切换备用服务或提示用户;如果每层只能继续抛出,checked 只会增加签名噪声。

java
// 语义:checked 异常适合调用方确实有不同恢复路径的情况,例如重试、切换备用服务或提示用户。
final class CreditServiceException extends Exception {
    CreditServiceException(String message) {
        super(message);
    }
}

public class CheckedDomainExceptionDemo {
    static void freezeAccount() throws CreditServiceException {
        throw new CreditServiceException("credit service unavailable");
// 异常:throw new CreditServiceException("credit service unavailable") 立即进入异常路径。
// 初始状态:表达式为 throw new CreditServiceException("credit service unavailable")。
    }

    public static void main(String[] args) {
        try {
            freezeAccount();
        } catch (CreditServiceException error) {
            System.out.println(error.getMessage());
            // 输出:credit service unavailable
        }
    }
}

提供带 cause 的异常构造器 ​

带 cause 的构造器是异常转换的基础;消息可以补充当前层上下文,cause 保留底层类型和堆栈。

java
// 语义:带 cause 的构造器是异常转换的基础。
// 初始状态:root 初始为 new IllegalStateException("connection refused");error 初始为 new DataAccessException("load user failed", root)。
final class DataAccessException extends RuntimeException {
    DataAccessException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class ExceptionCauseConstructorDemo {
    public static void main(String[] args) {
        IllegalStateException root = new IllegalStateException("connection refused");
// 关键变化:root 接收表达式 new IllegalStateException("connection refused") 的计算结果。
// 初始状态:root 的初始值为 new IllegalStateException("connection refused")。
        DataAccessException error = new DataAccessException("load user failed", root);
        System.out.println(error.getMessage());
        // 输出:load user failed
        System.out.println(error.getCause().getMessage());
        // 输出:connection refused
    }
}

在基础设施边界翻译异常 ​

转换发生在层边界:高层获得稳定的领域类型,诊断系统仍能通过 cause 看到 IOException;不要在每层重复包装同一种异常。

java
// 语义:转换发生在层边界:高层获得稳定的领域类型,诊断系统仍能通过 cause 看到 IOException。
import java.io.IOException;

final class ConfigLoadException extends RuntimeException {
    ConfigLoadException(String message, Throwable cause) {
        super(message, cause);
    }
}

public class ExceptionTranslationDemo {
    static String load() {
        try {
            throw new IOException("config file missing");
// 异常:throw new IOException("config file missing") 立即进入异常路径。
// 初始状态:表达式为 throw new IOException("config file missing")。
        } catch (IOException error) {
            throw new ConfigLoadException("cannot load application config", error);
        }
    }

    public static void main(String[] args) {
        try {
            load();
        } catch (ConfigLoadException error) {
            System.out.println(error.getMessage());
            // 输出:cannot load application config
            System.out.println(error.getCause().getMessage());
            // 输出:config file missing
        }
    }
}

给异常携带结构化字段 ​

字段让统一处理器可以直接输出错误码、资源标识和数量,不需要从异常文本中提取数据;字段应保持只读。

java
// 语义:字段让统一处理器可以直接输出错误码、资源标识和数量,不需要从异常文本中提取数据。
// 初始状态:error 初始为 new StockNotEnoughException("book", 5, 2)。
final class StockNotEnoughException extends RuntimeException {
    private final String sku;
    private final int requested;
    private final int available;

    StockNotEnoughException(String sku, int requested, int available) {
        super("stock not enough: " + sku);
        this.sku = sku;
        this.requested = requested;
        this.available = available;
    }

    String sku() {
        return sku;
    }

    int requested() {
        return requested;
    }

    int available() {
        return available;
    }
}

public class StructuredExceptionDemo {
    public static void main(String[] args) {
        StockNotEnoughException error = new StockNotEnoughException("book", 5, 2);
// 关键变化:error 接收表达式 new StockNotEnoughException("book", 5, 2) 的计算结果。
// 初始状态:error 的初始值为 new StockNotEnoughException("book", 5, 2)。
        System.out.println(error.sku() + ": " + error.available());
        // 输出:book: 2
    }
}

用 cause 构造器重新抛出并补充上下文 ​

高层消息增加文件、用户或请求上下文,cause 仍保留原异常;不要只写新消息而丢掉 cause。

java
// 语义:高层消息增加文件、用户或请求上下文,cause 仍保留原异常。
public class RethrowWithContextDemo {
    static void importFile(String name) {
        try {
            Integer.parseInt("bad");
        } catch (NumberFormatException error) {
            throw new IllegalStateException("import failed: " + name, error);
// 异常:throw new IllegalStateException("import failed: " + name, error) 立即进入异常路径。
// 初始状态:表达式为 throw new IllegalStateException("import failed: " + name, error)。
        }
    }

    public static void main(String[] args) {
        try {
            importFile("users.csv");
        } catch (IllegalStateException error) {
            System.out.println(error.getMessage());
            // 输出:import failed: users.csv
            System.out.println(error.getCause().getClass().getSimpleName());
            // 输出:NumberFormatException
        }
    }
}

不常用但需要知道 ​

使用完整的四个异常构造器 ​

是否提供全部重载取决于异常是否作为公共 API;内部异常通常保留最需要的消息和 cause 构造器即可。

java
final class FlexibleException extends Exception {
    FlexibleException() {}

    FlexibleException(String message) {
        super(message);
    }

    FlexibleException(String message, Throwable cause) {
        super(message, cause);
    }

    FlexibleException(Throwable cause) {
        super(cause);
    }
}

public class ExceptionConstructorsDemo {
    public static void main(String[] args) {
        FlexibleException error = new FlexibleException("bad");
// 初始状态:error.message 为 "bad",由带 String 参数的构造器保存。
        System.out.println(error.getMessage());
// 输出:bad
    }
}

用 addSuppressed 保存辅助故障 ​

addSuppressed 适合一个主操作已经失败、清理或审计又失败的场景;不要把不相关异常随意塞进同一个列表。

java
public class AddSuppressedDemo {
    public static void main(String[] args) {
        IllegalStateException main = new IllegalStateException("body failed");
// 关键变化:main 接收表达式 new IllegalStateException("body failed") 的计算结果。
// 初始状态:main 的初始值为 new IllegalStateException("body failed")。
        main.addSuppressed(new IllegalStateException("audit failed"));
// 关键变化:main.addSuppressed(...) 把 message="audit failed" 的异常追加到 suppressed 列表。
        System.out.println(main.getSuppressed()[0].getMessage());
        // 输出:audit failed
    }
}

用 initCause 给没有 cause 的旧异常补根因 ​

initCause 主要用于适配不能修改构造器的旧异常;新异常优先直接调用带 cause 的构造器,避免重复设置 cause。

java
public class InitCauseDemo {
    public static void main(String[] args) {
        IllegalStateException error = new IllegalStateException("legacy wrapper");
// 关键变化:error 接收表达式 new IllegalStateException("legacy wrapper") 的计算结果。
// 初始状态:error 的初始值为 new IllegalStateException("legacy wrapper")。
        error.initCause(new IllegalArgumentException("bad argument"));
// 关键变化:error.initCause(...) 设置根因为 message="bad argument" 的 IllegalArgumentException。
        System.out.println(error.getCause().getMessage());
        // 输出:bad argument
    }
}

区分异常原因与错误响应 ​

对外响应不应直接暴露数据库连接、文件路径和堆栈;内部日志保留完整异常链,对外只输出稳定错误码与安全消息。

java
record ErrorResponse(String code, String message) {}

public class ExceptionBoundaryDemo {
    static ErrorResponse toResponse(Throwable error) {
        if (error instanceof UserNotFoundException) {
            return new ErrorResponse("USER_NOT_FOUND", error.getMessage());
// 返回:创建 code="USER_NOT_FOUND" 的错误响应,message 取传入 UserNotFoundException 的消息。
        }
        return new ErrorResponse("INTERNAL_ERROR", "please retry later");
    }

    public static void main(String[] args) {
        ErrorResponse response = toResponse(new UserNotFoundException("u-9"));
// 返回:toResponse(...) 创建 code="USER_NOT_FOUND"、message="user not found: u-9" 的响应。
        System.out.println(response.code() + ": " + response.message());
        // 输出:USER_NOT_FOUND: user not found: u-9
    }
}

简单案例 ​

java
final class OrderException extends RuntimeException {
    OrderException(String message) {
        super(message);
    }
}

public class CustomExceptionSummaryDemo {
    public static void main(String[] args) {
        try {
            throw new OrderException("order rejected");
        } catch (OrderException error) {
            System.out.println(error.getMessage());
            // 输出:order rejected
        }
    }
}

自定义异常让调用方按领域类型处理,而不是解析一段普通字符串。

易混点 ​

  • 自定义异常的父类决定调用契约:Exception 要求捕获或声明,RuntimeException 不要求但仍应有明确处理边界。
  • 异常消息适合展示和日志,程序分支应检查异常类型或结构化字段。
  • 异常转换必须传入 cause;否则高层消息虽然更好懂,根因却无法定位。
  • suppressed exception 用于辅助故障,不能替代主异常,也不能把所有异常都当作 suppressed。

课后小问 ​

  1. 为什么异常转换不能只写 new ConfigLoadException("failed")? 答案:只写新消息会丢失底层异常类型、堆栈和原始原因。 解析:通过带 cause 的构造器包装后,服务层可以使用稳定领域异常,日志仍可沿着 getCause() 定位文件或数据库故障。

  2. checked 异常和 unchecked 异常如何选择? 答案:调用方有明确可恢复路径时可用 checked;无法恢复或属于调用前置条件时通常用 unchecked。 解析:checked 会把处理责任写进每层方法签名,只有真正需要调用方决策时才值得保留这份约束。

本节小结 ​

  • 自定义异常应表达领域语义,异常字段用于程序处理,message 用于人阅读。
  • 异常翻译在层边界完成,并通过 cause 保留根因。
  • addSuppressed 和 getSuppressed 用于保存主体故障之外的清理或审计故障。
  • 对外错误响应应隐藏内部细节,内部日志保留完整异常链。

快速回顾 ​

  • 能写出 checked 和 unchecked 两类自定义异常。
  • 能用带 cause 的构造器完成异常翻译。
  • 能为领域异常增加稳定的结构化字段。
  • 能解释 cause、suppressed 和对外错误响应的区别。