自定义异常与异常转换
学习目标
- 根据调用者是否能恢复,选择 checked 或 unchecked 自定义异常。
- 为异常提供消息、cause 和必要的领域字段。
- 在基础设施层到业务层的边界进行异常翻译,不丢失根因。
核心知识点
专业术语
- 领域异常:用业务语言表达“订单不存在”“余额不足”等可理解失败。
- 异常链(cause chain):高层异常通过 cause 保留低层异常。
- 异常转换(translation):在层边界把底层技术异常包装为当前层能理解的异常。
- checked domain exception:继承 Exception,要求调用方捕获或声明。
- unchecked domain exception:继承 RuntimeException,适合调用方无法合理恢复或属于业务前置条件的错误。
白话解释与边界
自定义异常不是为了增加类数量,而是为了让调用者知道“该怎么处理”。文件读取失败通常在基础设施层保留 IOException,在服务层可以转换为 ConfigLoadException;用户输入不符合业务规则时,BalanceNotEnoughException 这类 RuntimeException 可以直接由统一异常处理器映射成响应。
异常消息供人阅读,字段供程序处理;不要让调用方通过解析 message 判断错误类型。翻译时必须传 cause,日志层可以沿着 getCause() 还原根因。
常用用法
定义 unchecked 领域异常
RuntimeException 适合表示当前调用无法继续、通常由统一边界处理的业务失败;异常类名要表达稳定的领域语义。
// 语义: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 只会增加签名噪声。
// 语义: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 保留底层类型和堆栈。
// 语义:带 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;不要在每层重复包装同一种异常。
// 语义:转换发生在层边界:高层获得稳定的领域类型,诊断系统仍能通过 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
}
}
}给异常携带结构化字段
字段让统一处理器可以直接输出错误码、资源标识和数量,不需要从异常文本中提取数据;字段应保持只读。
// 语义:字段让统一处理器可以直接输出错误码、资源标识和数量,不需要从异常文本中提取数据。
// 初始状态: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。
// 语义:高层消息增加文件、用户或请求上下文,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 构造器即可。
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 适合一个主操作已经失败、清理或审计又失败的场景;不要把不相关异常随意塞进同一个列表。
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。
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
}
}区分异常原因与错误响应
对外响应不应直接暴露数据库连接、文件路径和堆栈;内部日志保留完整异常链,对外只输出稳定错误码与安全消息。
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
}
}简单案例
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。
课后小问
为什么异常转换不能只写 new ConfigLoadException("failed")? 答案:只写新消息会丢失底层异常类型、堆栈和原始原因。 解析:通过带 cause 的构造器包装后,服务层可以使用稳定领域异常,日志仍可沿着 getCause() 定位文件或数据库故障。
checked 异常和 unchecked 异常如何选择? 答案:调用方有明确可恢复路径时可用 checked;无法恢复或属于调用前置条件时通常用 unchecked。 解析:checked 会把处理责任写进每层方法签名,只有真正需要调用方决策时才值得保留这份约束。
本节小结
- 自定义异常应表达领域语义,异常字段用于程序处理,message 用于人阅读。
- 异常翻译在层边界完成,并通过 cause 保留根因。
- addSuppressed 和 getSuppressed 用于保存主体故障之外的清理或审计故障。
- 对外错误响应应隐藏内部细节,内部日志保留完整异常链。
快速回顾
- 能写出 checked 和 unchecked 两类自定义异常。
- 能用带 cause 的构造器完成异常翻译。
- 能为领域异常增加稳定的结构化字段。
- 能解释 cause、suppressed 和对外错误响应的区别。
阿源的知识库