自定义异常
本节定位:掌握自定义异常的设计方法——选择合适的父类(Exception vs RuntimeException)、设计规范的构造方法、以及在企业级项目中构建分层异常体系。理解自定义异常在业务语义表达和异常分类中的核心价值。
场景引入
飞翔科技的小崔刚刚完成学生管理系统的开发。代码审查时,架构师白歌发现每个模块都在抛 RuntimeException("出错啦")。
"你这样写,调用方怎么区分是'学生不存在'还是'学分不足'还是'数据库挂了'?"白歌指着屏幕,"一个好的异常体系应该让异常自己会说话。"
李眉好奇地问:"Java 不是已经有很多异常了吗?为什么还要自己定义?"
大翔解释道:"JDK 的异常是通用的。IllegalArgumentException 可以表示一万种参数非法的情况,但业务上你需要区分'学号重复'和'年龄不合法'——这就是自定义异常的价值。"
为什么需要自定义异常
| 使用 JDK 通用异常 | 使用自定义异常 |
|---|---|
throw new RuntimeException("错了") | throw new StudentNotFoundException(studentId) |
| 调用方只能 catch RuntimeException | 调用方可以精确 catch StudentNotFoundException |
| 无法根据异常类型做差异化处理 | 可以根据不同业务异常走不同处理分支 |
| 日志排查困难,只能靠字符串匹配 | 通过类名即可快速定位问题类型 |
核心价值:自定义异常让你的异常具有业务语义,让代码自文档化。
设计自定义异常
第一步:选择父类
| 继承的父类 | 异常类型 | 适用场景 |
|---|---|---|
Exception | 受检异常 | 调用方必须处理的异常(如业务规则违反) |
RuntimeException | 非受检异常 | 调用方可选处理的异常(如参数校验失败) |
推荐:如果你使用 Spring 框架,继承
RuntimeException是一个好选择,因为 Spring 的事务管理默认只对非受检异常回滚。
第二步:设计构造方法
一个规范的自定义异常应该提供三个标准构造方法:
/**
* 自定义异常的标准构造方法模板
*/
public class StudentServiceException extends RuntimeException {
/**
* 构造方法一:仅异常消息
*/
public StudentServiceException(String message) {
super(message);
}
/**
* 构造方法二:异常消息 + 根因
* (最常用,支持异常链)
*/
public StudentServiceException(String message, Throwable cause) {
super(message, cause);
}
/**
* 构造方法三:仅根因
*/
public StudentServiceException(Throwable cause) {
super(cause);
}
// 可选:无参构造方法
public StudentServiceException() {
super();
}
}
| 构造方法 | 参数 | 使用场景 |
|---|---|---|
() | 无参 | 极少使用,不推荐(缺少错误信息) |
(String message) | 错误消息 | 业务逻辑异常,如 "学号已存在" |
(String message, Throwable cause) | 消息 + 根因 | 最常用,包装底层异常时 |
(Throwable cause) | 仅根因 | 纯包装,不需要额外消息 |
第三步:遵循命名规范
| 规范 | 说明 | 正例 | 反例 |
|---|---|---|---|
| 以 Exception 结尾 | 受检异常 | StudentNotFoundException | StudentNotFound |
| 以 Exception 结尾 | 非受检异常 | StudentImportException | ImportStudentError |
| 明确表达业务语义 | 一看就知道什么情况 | DuplicateStudentException | MyException1 |
| 分层命名 | 不同层用不同后缀 | DaoException, ServiceException | 全部叫 BizException |
飞翔科技实战:构建分层异常体系
业务场景
架构师白歌为飞翔科技学生管理系统设计了如下异常体系:
基类异常设计
/**
* 飞翔科技学生管理系统 - 业务异常基类
*
* 所有业务异常继承此类,统一携带错误码,便于:
* 1. 前端根据错误码展示不同的提示信息
* 2. 国际化(i18n)通过错误码查消息模板
* 3. 监控系统按错误码统计异常频率
*/
public class StudentSystemException extends RuntimeException {
/** 错误码 */
private final int errorCode;
/** 错误码常量 */
public static final int STUDENT_NOT_FOUND = 1001;
public static final int DUPLICATE_STUDENT = 1002;
public static final int INVALID_STUDENT = 1003;
public static final int INSUFFICIENT_CREDITS = 1004;
public static final int IMPORT_FAILED = 2001;
public static final int DB_ERROR = 9001;
public StudentSystemException(int errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public StudentSystemException(int errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public int getErrorCode() {
return errorCode;
}
/**
* 获取格式化的错误信息(用于日志和 API 响应)
*/
public String getFormattedMessage() {
return String.format("[错误码: %d] %s", errorCode, getMessage());
}
}
具体异常子类
/**
* 学生不存在异常
*/
public class StudentNotFoundException extends StudentSystemException {
public StudentNotFoundException(String studentId) {
super(STUDENT_NOT_FOUND,
String.format("学生不存在: %s", studentId));
}
public StudentNotFoundException(String studentId, Throwable cause) {
super(STUDENT_NOT_FOUND,
String.format("查询学生 %s 时发生错误", studentId), cause);
}
}
/**
* 学号重复异常
*/
public class DuplicateStudentException extends StudentSystemException {
private final String studentId;
private final String existingName;
public DuplicateStudentException(String studentId, String existingName) {
super(DUPLICATE_STUDENT,
String.format("学号 %s 已被学生 %s 占用", studentId, existingName));
this.studentId = studentId;
this.existingName = existingName;
}
public String getStudentId() { return studentId; }
public String getExistingName() { return existingName; }
}
/**
* 学分不足异常(携带业务数据,便于调用方精确了解情况)
*/
public class InsufficientCreditsException extends StudentSystemException {
private final String studentId;
private final int required;
private final int current;
public InsufficientCreditsException(String studentId, int required, int current) {
super(INSUFFICIENT_CREDITS,
String.format("学生 %s 学分不足:需要 %d,当前 %d(缺 %d)",
studentId, required, current, required - current));
this.studentId = studentId;
this.required = required;
this.current = current;
}
public String getStudentId() { return studentId; }
public int getRequired() { return required; }
public int getCurrent() { return current; }
public int getShortfall() { return required - current; }
}
业务代码中使用自定义异常
@Service
public class EnrollmentService {
private final StudentDao studentDao;
private final CourseDao courseDao;
public EnrollmentService(StudentDao studentDao, CourseDao courseDao) {
this.studentDao = studentDao;
this.courseDao = courseDao;
}
/**
* 学生选课
*/
public void enroll(String studentId, String courseId) {
// 1. 查找学生
Student student = studentDao.findById(studentId);
if (student == null) {
throw new StudentNotFoundException(studentId); // 自定义异常,语义明确
}
// 2. 查找课程
Course course = courseDao.findById(courseId);
if (course == null) {
throw new CourseNotFoundException(courseId); // 另一个自定义异常
}
// 3. 检查学分
if (student.getCredits() < course.getRequiredCredits()) {
throw new InsufficientCreditsException(
studentId, course.getRequiredCredits(), student.getCredits());
}
// 4. 检查是否已选过
if (courseDao.isEnrolled(studentId, courseId)) {
throw new DuplicateEnrollmentException(studentId, courseId);
}
// 5. 执行选课
try {
courseDao.enroll(studentId, courseId);
student.setCredits(student.getCredits() - course.getRequiredCredits());
studentDao.update(student);
logger.info("选课成功: {} -> {}", studentId, courseId);
} catch (DataAccessException e) {
throw new ServiceException(DB_ERROR, "选课数据操作失败", e);
}
}
}
/**
* 全局异常处理器(在 Controller 层统一处理)
*/
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(StudentNotFoundException.class)
public ResponseEntity<ErrorResponse> handleStudentNotFound(StudentNotFoundException e) {
return ResponseEntity.status(404)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(InsufficientCreditsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientCredits(
InsufficientCreditsException e) {
return ResponseEntity.status(400)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(StudentSystemException.class)
public ResponseEntity<ErrorResponse> handleSystemException(StudentSystemException e) {
return ResponseEntity.status(500)
.body(new ErrorResponse(e.getErrorCode(), "系统内部错误"));
}
}
运行输出(学生不存在):
HTTP 404 Not Found
{
"errorCode": 1001,
"message": "学生不存在: STU-2024-0001",
"timestamp": "2024-01-15T10:30:00"
}
运行输出(学分不足):
HTTP 400 Bad Request
{
"errorCode": 1004,
"message": "学生 STU-2024-0002 学分不足:需要 5,当前 2(缺 3)",
"timestamp": "2024-01-15T10:31:00"
}
核心原理:Throwable 的构造方法链
serialVersionUID 的作用
public class StudentSystemException extends RuntimeException {
// 为什么要显式定义 serialVersionUID?
private static final long serialVersionUID = 1L;
// ...
}
| 问题 | 说明 |
|---|---|
| 为什么要定义? | Throwable 实现了 Serializable 接口,所以异常对象可以被序列化(如在 RMI 或分布式系统中跨 JVM 传输) |
| 不定义会怎样? | 编译器自动生成一个,但不同编译器版本可能生成不同的值,导致反序列化失败 |
| 值写什么? | 通常是 1L,只要保证类结构不变时值不变即可 |
为什么构造方法要调用 super()
// Throwable 内部的构造方法链
public class Throwable {
private String detailMessage;
private Throwable cause;
public Throwable(String message) {
this.detailMessage = message; // 存储到私有字段
}
public Throwable(String message, Throwable cause) {
this.detailMessage = message;
this.cause = cause; // 建立异常链
}
}
// 你的自定义异常
public class MyException extends Exception {
public MyException(String message, Throwable cause) {
super(message, cause); // 必须调用!否则 message 和 cause 都不会被设置
// 不调用 super,getMessage() 返回 null,getCause() 返回 null
}
}
原理:
getMessage()和getCause()是Throwable的方法,数据存储在Throwable的私有字段中。如果不调用super(message, cause),这些方法将返回null。
易错场景
反例一:用一个异常类表示所有情况
// ❌ 极差的设计:所有错误都用同一个类,靠字符串区分
public class MyException extends RuntimeException {
public MyException(String message) {
super(message);
}
}
// 使用方
throw new MyException("学生不存在");
throw new MyException("学分不足");
throw new MyException("数据库错误");
// 调用方无法根据异常类型做差异化处理
catch (MyException e) {
if (e.getMessage().contains("学生不存在")) { // 丑陋的字符串判断!
// ...
} else if (e.getMessage().contains("学分不足")) {
// ...
}
}
// ✅ 正确:每种业务异常一个类
throw new StudentNotFoundException(studentId);
throw new InsufficientCreditsException(studentId, required, current);
throw new ServiceException(DB_ERROR, "数据库错误", e);
// 调用方可以精确捕获
catch (StudentNotFoundException e) { /* 404 */ }
catch (InsufficientCreditsException e) { /* 400 */ }
catch (ServiceException e) { /* 500 */ }
反例二:自定义异常没有提供 cause 构造方法
// ❌ 不完整的自定义异常
public class BadException extends RuntimeException {
public BadException(String message) {
super(message);
}
// 缺少 (String, Throwable) 和 (Throwable) 构造方法
}
// 使用时无法保留根因
try {
studentDao.save(student);
} catch (DataAccessException e) {
throw new BadException("保存失败"); // 根因 DataAccessException 丢失!
}
// ✅ 规范的自定义异常
public class GoodException extends RuntimeException {
public GoodException(String message) {
super(message);
}
public GoodException(String message, Throwable cause) { // 关键!
super(message, cause);
}
public GoodException(Throwable cause) {
super(cause);
}
}
反例三:过度使用受检异常
// ❌ 全部定义为受检异常,调用链每层都要处理或声明
public class StudentNotFoundException extends Exception { } // 受检
// 造成"异常污染"——每一层调用都必须声明或捕获
public void methodA() throws StudentNotFoundException {
methodB();
}
public void methodB() throws StudentNotFoundException {
methodC();
}
public void methodC() throws StudentNotFoundException {
throw new StudentNotFoundException("id");
}
// ✅ 对于 Spring 管理的业务系统,推荐使用非受检异常
// Spring 默认只对 RuntimeException 回滚事务
public class StudentNotFoundException extends RuntimeException { }
面试考点
Q1:什么时候应该自定义异常?JDK 的异常不够用吗?
JDK 的异常是通用的,无法表达具体业务语义。当需要以下情况时应该自定义异常:① 需要根据异常类型执行不同的处理逻辑(如区分"学生不存在"和"学分不足");② 需要携带额外的业务数据(如错误码、涉及的实体 ID);③ 需要建立分层的异常体系(如 DAO 层异常、Service 层异常、Controller 层异常)。核心原则是:让异常自己说话,而不是靠字符串匹配区分。
Q2:自定义异常应该继承 Exception 还是 RuntimeException?
两种选择都有合理场景:继承 Exception 表示受检异常,调用方必须显式处理,适合"调用方应该有恢复策略"的场景(如文件不存在)。继承 RuntimeException 表示非受检异常,调用方可选处理,适合"程序逻辑问题"的场景。在 Spring 生态中,推荐继承 RuntimeException,因为 Spring 事务默认只对非受检异常回滚。现代 Java 开发的趋势是优先使用非受检异常。
Q3:自定义异常的三个标准构造方法是什么?
①
(String message):最基础,仅传递错误消息。②(String message, Throwable cause):最常用,传递消息和根因,构建异常链。③(Throwable cause):仅传递根因,用于纯包装场景。此外有时还会提供无参构造方法,但不推荐。每个构造方法都应该调用super()对应版本,否则getMessage()/getCause()将返回 null。
Q4:自定义异常中应该包含哪些额外字段?
根据业务需要,常见的有:①
errorCode(错误码),便于 API 返回和国际化;② 业务实体 ID(如studentId),便于日志定位;③ 上下文数据(如required/current),便于调用方精确了解情况。但不要过度设计,加的字段应该确实有用。
Q5:异常类的 serialVersionUID 是必须的吗?
技术上不是必须的(编译器会自动生成),但强烈推荐显式定义。因为
Throwable实现了Serializable,异常对象可能在分布式系统(RMI、序列化缓存等)中传输。如果依赖编译器自动生成,不同编译器版本可能产生不同的值,导致反序列化时InvalidClassException。显式定义为private static final long serialVersionUID = 1L;可避免此问题。