跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

前面的普通方法都沿着正常路径返回结果,但方法也可能因为输入无效、文件不存在或数据库连接失败而无法完成任务。此时继续返回一个看似正常的对象,会把失败与成功混在一起。

Java 使用异常中断当前正常流程,并沿着调用栈把失败交给合适的边界处理。本章先讲清异常类型、抛出、声明、捕获和资源关闭,再把它放回心跳项目的 Controller -> Service 调用链。

1. 异常路径

方法正常完成时通过 return 把结果交给调用方;遇到无法履行的方法契约时,可以抛出异常:

service-name.java
1
static String normalizeService(String service) {
2
if (service == null || service.isBlank()) {
3
throw new IllegalArgumentException("service must not be blank");
4
}
5
return service.strip().toLowerCase(Locale.ROOT);
6
}

执行 throw 后,当前方法不会继续运行,也不会返回 String。Java 会沿调用栈向上寻找能够处理该异常的 catch;一直没有匹配处理器时,当前线程的调用会失败。

exception-flow.txt
1
Controller 调用 HealthService
2
-> HealthService 调用 normalizeService
3
-> 输入为空,抛出异常
4
-> Service 后续语句不再执行
5
-> 异常返回 Controller 调用边界

异常对象同样由类创建。它保存类型、消息、原因和调用栈,调用栈能够指出异常经过哪些方法传播。错误消息应说明哪个条件不满足,但不要包含密码、令牌等敏感值。

不要用异常代替普通分支。查询结果允许不存在时,Optional.empty() 是契约内的正常结果;服务名称违反非空规则时,方法无法继续执行,才适合抛出异常。

2. 类型体系

Java 中可以抛出的对象都继承自 Throwable:

throwable-hierarchy.txt
1
Throwable
2
├── Error
3
└── Exception
4
├── RuntimeException
5
└── 其他受检异常

主要类型的含义是:

类型示例调用方要求
ErrorOutOfMemoryError通常表示运行环境严重问题,不作为业务流程捕获
受检异常IOException、SQLException必须捕获,或者继续在方法签名声明
运行时异常IllegalArgumentException编译器不强制捕获,适合参数错误和业务失败

受检与非受检描述的是编译器规则,不等于严重程度。某个异常是否应该恢复、转换或继续传播,仍然取决于当前方法所在的边界。

Spring JDBC 会把不同驱动产生的 SQLException 转换为 DataAccessException 运行时异常。Repository 因而不必让每个方法都声明数据库驱动异常,上层也可以围绕稳定的 Spring 异常体系处理数据访问失败。

捕获 Throwable 或 Exception 会覆盖非常宽的范围,容易把编程错误误当成可恢复业务问题。处理器应尽量匹配自己真正理解并能正确处理的具体异常。

3. 自定义异常

IllegalArgumentException 能说明参数不合法,却不能稳定区分是哪一种业务规则失败。可以为服务名称创建具体异常:

src/main/java/cn/usehook/spring/health/InvalidServiceNameException.java
1
package cn.usehook.spring.health;
2
3
public class InvalidServiceNameException extends RuntimeException {
4
5
public InvalidServiceNameException(String service) {
6
super("Invalid service name: " + service);
7
}
8
}

类通过 extends RuntimeException 继承运行时异常行为,构造方法再把业务消息传给父类。HealthService 可以抛出这个具体类型:

src/main/java/cn/usehook/spring/health/HealthService.java
1
private String normalizeService(String service) {
2
if (service == null || service.isBlank()) {
3
throw new InvalidServiceNameException(service);
4
}
5
return service.strip().toLowerCase(Locale.ROOT);
6
}

异常类描述稳定的失败类别,而不是 HTTP 状态码。Service 不应该在这里返回 400 或依赖 ResponseEntity;以后由 Web 层把 InvalidServiceNameException 映射为 400 Bad Request。同一个 Service 若被定时任务调用,也仍然能理解该业务异常。

异常继承层级不宜只为了目录整齐而变得很深。当前具体类型已经足以让调用方精确处理;只有多个异常确实需要共享捕获策略时,才增加共同业务父类。

4. throws

调用可能抛出受检异常的方法时,当前方法必须处理它,或者使用 throws 继续声明:

config-reader.java
1
static String readStatusFile(Path path) throws IOException {
2
return Files.readString(path);
3
}

这段代码需要引入 java.io.IOException、java.nio.file.Files 和 java.nio.file.Path。throws IOException 是方法契约的一部分,表示调用方还要决定怎样处理读取失败。

throw 和 throws 作用不同:

语法位置作用
throw exception方法体内实际抛出一个异常对象
throws IOException方法签名声明方法可能把某类受检异常继续交给调用方

运行时异常也可以写在 throws 后面,但编译器不要求这样做。一般只有当声明能显著帮助理解公开契约时才写,不必给每个业务方法机械列出全部运行时异常。

覆盖接口或父类方法时,实现方法不能随意增加范围更宽的受检异常,否则通过接口调用的代码无法提前知道这一新要求。运行时异常不受这条编译限制。

5. 捕获与资源关闭

调用方只有在能够补充上下文、转换边界结果或执行恢复动作时才应该捕获异常:

read-config.java
1
try {
2
String content = readStatusFile(Path.of("status.txt"));
3
System.out.println(content);
4
} catch (IOException exception) {
5
System.err.println("Cannot read status configuration");
6
}

catch 参数保留了原异常。记录日志时应把异常对象一同交给日志框架,才能保留调用栈;只打印 exception.getMessage() 往往会丢失定位信息。

需要无论成功失败都执行的清理逻辑,可以放入 finally。对于文件流、数据库连接等实现 AutoCloseable 的资源,更推荐 try-with-resources:

resource-reader.java
1
static String readStatus(Path path) throws IOException {
2
try (BufferedReader reader = Files.newBufferedReader(path)) {
3
return reader.readLine();
4
}
5
}

代码需要再引入 java.io.BufferedReader。无论 readLine() 正常返回还是抛出异常,Java 都会调用 reader.close()。后面直接使用 JDBC 连接验证时也会看到相同结构;连接池中的 close() 通常表示把连接归还池中。

不要捕获异常后什么也不做:

swallowed-exception.java
1
try {
2
saveStatus();
3
} catch (RuntimeException exception) {
4
// 错误被吞掉,调用方会误以为保存成功
5
}

如果当前层无法恢复,应继续抛出原异常,或者在转换成更合适的异常时把原异常作为 cause 传入,保留失败链路。

6. 项目边界

在心跳项目中,不同层只处理自己理解的失败:

project-exception-boundary.txt
1
Repository
2
-> 报告数据访问失败
3
Service
4
-> 抛出服务名称无效、状态不存在等业务异常
5
Controller 与全局处理器
6
-> 把已知异常转换成 HTTP 状态和问题详情
7
运行环境
8
-> 记录未预期异常并返回通用 500

测试可以断言方法抛出了正确类型:

src/test/java/cn/usehook/spring/health/HealthServiceTests.java
1
@Test
2
void rejectsBlankServiceName() {
3
InvalidServiceNameException exception = assertThrows(
4
InvalidServiceNameException.class,
5
() -> healthService.check(" ")
6
);
7
8
assertThat(exception.getMessage()).contains("Invalid service name");
9
}

这段测试需要静态引入 JUnit 的 assertThrows 和 AssertJ 的 assertThat。它验证 Service 的业务契约,不关心异常以后对应哪一个 HTTP 状态。

异常传播的关键不是在每一层都写 try/catch,而是让失败沿调用链到达真正理解它的边界。现在普通 Java 对象、接口、集合、泛型和异常已经能够支撑项目代码;下一篇补充注解与反射,理解 Spring 怎样发现这些类型上的元数据,再进入 IoC 容器。