前面的普通方法都沿着正常路径返回结果,但方法也可能因为输入无效、文件不存在或数据库连接失败而无法完成任务。此时继续返回一个看似正常的对象,会把失败与成功混在一起。
Java 使用异常中断当前正常流程,并沿着调用栈把失败交给合适的边界处理。本章先讲清异常类型、抛出、声明、捕获和资源关闭,再把它放回心跳项目的 Controller -> Service 调用链。
1. 异常路径
方法正常完成时通过 return 把结果交给调用方;遇到无法履行的方法契约时,可以抛出异常:
1static String normalizeService(String service) {2if (service == null || service.isBlank()) {3throw new IllegalArgumentException("service must not be blank");4}5return service.strip().toLowerCase(Locale.ROOT);6}
执行 throw 后,当前方法不会继续运行,也不会返回 String。Java 会沿调用栈向上寻找能够处理该异常的 catch;一直没有匹配处理器时,当前线程的调用会失败。
1Controller 调用 HealthService2-> HealthService 调用 normalizeService3-> 输入为空,抛出异常4-> Service 后续语句不再执行5-> 异常返回 Controller 调用边界
异常对象同样由类创建。它保存类型、消息、原因和调用栈,调用栈能够指出异常经过哪些方法传播。错误消息应说明哪个条件不满足,但不要包含密码、令牌等敏感值。
不要用异常代替普通分支。查询结果允许不存在时,Optional.empty() 是契约内的正常结果;服务名称违反非空规则时,方法无法继续执行,才适合抛出异常。
2. 类型体系
Java 中可以抛出的对象都继承自 Throwable:
1Throwable2├── Error3└── Exception4├── RuntimeException5└── 其他受检异常
主要类型的含义是:
| 类型 | 示例 | 调用方要求 |
|---|---|---|
Error | OutOfMemoryError | 通常表示运行环境严重问题,不作为业务流程捕获 |
| 受检异常 | IOException、SQLException | 必须捕获,或者继续在方法签名声明 |
| 运行时异常 | IllegalArgumentException | 编译器不强制捕获,适合参数错误和业务失败 |
受检与非受检描述的是编译器规则,不等于严重程度。某个异常是否应该恢复、转换或继续传播,仍然取决于当前方法所在的边界。
Spring JDBC 会把不同驱动产生的 SQLException 转换为 DataAccessException 运行时异常。Repository 因而不必让每个方法都声明数据库驱动异常,上层也可以围绕稳定的 Spring 异常体系处理数据访问失败。
捕获 Throwable 或 Exception 会覆盖非常宽的范围,容易把编程错误误当成可恢复业务问题。处理器应尽量匹配自己真正理解并能正确处理的具体异常。
3. 自定义异常
IllegalArgumentException 能说明参数不合法,却不能稳定区分是哪一种业务规则失败。可以为服务名称创建具体异常:
1package cn.usehook.spring.health;23public class InvalidServiceNameException extends RuntimeException {45public InvalidServiceNameException(String service) {6super("Invalid service name: " + service);7}8}
类通过 extends RuntimeException 继承运行时异常行为,构造方法再把业务消息传给父类。HealthService 可以抛出这个具体类型:
1private String normalizeService(String service) {2if (service == null || service.isBlank()) {3throw new InvalidServiceNameException(service);4}5return service.strip().toLowerCase(Locale.ROOT);6}
异常类描述稳定的失败类别,而不是 HTTP 状态码。Service 不应该在这里返回 400 或依赖 ResponseEntity;以后由 Web 层把 InvalidServiceNameException 映射为 400 Bad Request。同一个 Service 若被定时任务调用,也仍然能理解该业务异常。
异常继承层级不宜只为了目录整齐而变得很深。当前具体类型已经足以让调用方精确处理;只有多个异常确实需要共享捕获策略时,才增加共同业务父类。
4. throws
调用可能抛出受检异常的方法时,当前方法必须处理它,或者使用 throws 继续声明:
1static String readStatusFile(Path path) throws IOException {2return 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. 捕获与资源关闭
调用方只有在能够补充上下文、转换边界结果或执行恢复动作时才应该捕获异常:
1try {2String content = readStatusFile(Path.of("status.txt"));3System.out.println(content);4} catch (IOException exception) {5System.err.println("Cannot read status configuration");6}
catch 参数保留了原异常。记录日志时应把异常对象一同交给日志框架,才能保留调用栈;只打印 exception.getMessage() 往往会丢失定位信息。
需要无论成功失败都执行的清理逻辑,可以放入 finally。对于文件流、数据库连接等实现 AutoCloseable 的资源,更推荐 try-with-resources:
1static String readStatus(Path path) throws IOException {2try (BufferedReader reader = Files.newBufferedReader(path)) {3return reader.readLine();4}5}
代码需要再引入 java.io.BufferedReader。无论 readLine() 正常返回还是抛出异常,Java 都会调用 reader.close()。后面直接使用 JDBC 连接验证时也会看到相同结构;连接池中的 close() 通常表示把连接归还池中。
不要捕获异常后什么也不做:
1try {2saveStatus();3} catch (RuntimeException exception) {4// 错误被吞掉,调用方会误以为保存成功5}
如果当前层无法恢复,应继续抛出原异常,或者在转换成更合适的异常时把原异常作为 cause 传入,保留失败链路。
6. 项目边界
在心跳项目中,不同层只处理自己理解的失败:
1Repository2-> 报告数据访问失败3Service4-> 抛出服务名称无效、状态不存在等业务异常5Controller 与全局处理器6-> 把已知异常转换成 HTTP 状态和问题详情7运行环境8-> 记录未预期异常并返回通用 500
测试可以断言方法抛出了正确类型:
1@Test2void rejectsBlankServiceName() {3InvalidServiceNameException exception = assertThrows(4InvalidServiceNameException.class,5() -> healthService.check(" ")6);78assertThat(exception.getMessage()).contains("Invalid service name");9}
这段测试需要静态引入 JUnit 的 assertThrows 和 AssertJ 的 assertThat。它验证 Service 的业务契约,不关心异常以后对应哪一个 HTTP 状态。
异常传播的关键不是在每一层都写 try/catch,而是让失败沿调用链到达真正理解它的边界。现在普通 Java 对象、接口、集合、泛型和异常已经能够支撑项目代码;下一篇补充注解与反射,理解 Spring 怎样发现这些类型上的元数据,再进入 IoC 容器。