1. 现实问题:失败不是一个“返回 null”就结束的分支
解析订单号、读取库存和改变订单状态都会失败。用 null 表示所有失败时,调用方既不知道原因,也容易在更晚的位置触发空指针。把错误当作正常数据返回,又会让每层重复 if。异常、枚举和泛型分别解决三个问题:异常沿调用链传递失败,枚举限制状态集合,泛型让容器的元素类型在编译期明确。
目标不是“到处 try-catch”,而是确定谁能处理失败。底层解析器可以抛出带上下文的异常,Service 把它翻译为领域结果,Controller 再把结果翻译为 HTTP 状态。
2. 最小可运行示例:类型安全的解析结果
import java.util.Optional;
public class ParseDemo {
enum OrderStatus { CREATED, PAID, CANCELLED }
record Order(String id, OrderStatus status) {}
static Optional<Order> parse(String text) {
if (text == null || text.isBlank()) return Optional.empty();
String[] parts = text.split(":");
if (parts.length != 2 || parts[0].isBlank()) return Optional.empty();
try {
return Optional.of(new Order(parts[0], OrderStatus.valueOf(parts[1])));
} catch (IllegalArgumentException ex) {
return Optional.empty();
}
}
public static void main(String[] args) {
parse("A-100:PAID").ifPresent(order -> System.out.println(order.id()));
parse("A-101:UNKNOWN").ifPresentOrElse(
order -> System.out.println(order.status()),
() -> System.out.println("订单文本无效"));
}
}
Optional<Order> 只适合表达“可能没有值”的返回边界,不要把它当作实体字段或方法参数的万能包装。若失败原因需要展示给用户,应该定义 ParseResult<T>,包含成功值或错误码,而不是把所有原因压成 empty。
3. 调用链与对象变化
parse 收到文本引用,split 创建字符串数组,OrderStatus.valueOf 根据枚举常量名查找状态。成功时 Order 记录对象被放进 Optional,调用 ifPresent 时 lambda 收到该引用;失败时 catch 捕获异常,方法返回共享的 empty 语义对象。异常对象本身沿栈帧向上寻找匹配的 catch,未处理就终止当前线程。
枚举值不是普通字符串:PAID 是 OrderStatus 的固定实例,比较可以用 ==;数据库保存时应明确存名称、代码还是序号,不能把 ordinal() 当永久协议。泛型 Optional<Order> 在编译期限制 get 的结果类型,避免调用方把一个订单当成别的对象。
4. 为什么这样设计
异常适合表示当前层无法继续完成且需要上层决定的失败;输入校验失败通常应成为明确的领域结果,编程错误则应快速暴露。捕获异常后不要只打印 ex.getMessage(),要保留 cause 和关键上下文,也不要 catch Exception 后把所有错误当作“用户输入错误”。
枚举让状态迁移有名字、有边界;泛型让集合、结果和仓储接口表达元素类型。二者组合能减少魔法字符串和强制类型转换。类型系统不能替你完成业务验证,但它能在错误还没有进入运行时前阻止一部分错误。
5. 项目落点:异常翻译要有层次
数据库层可抛 DataAccessException,Service 根据唯一键冲突或资源不存在转成 DuplicateArticleException、ArticleNotFoundException,Web 层统一异常处理再输出稳定 JSON。不要让 Controller 逐个 catch 数据库细节。订单状态变更也应使用 OrderStatus 和显式迁移方法,拒绝 CANCELLED -> PAID 这种非法跳转。
练习:实现 Result<T>,提供 success(value)、failure(code, message) 和 map;再为文章发布写一个只允许 DRAFT -> PUBLISHED 的状态方法。测试成功、未知状态、重复发布和 null 输入,观察每种错误如何停在最接近它的边界。
6. 易错排查
- 空 catch:异常被吞掉后系统“看起来没报错”,实际数据可能没有保存;至少记录上下文或返回失败。
throw new RuntimeException(ex.getMessage()):丢失 cause,日志无法看到原始堆栈。- 枚举用
ordinal()持久化:新增常量会改变序号,使用稳定 code 或名称。 - 泛型 raw type:
List list放弃编译器检查,改成List<Order>或通配符表达真实边界。
7. 一页复习
先确定失败语义,再选异常、Optional 或结果对象;异常由能处理它的层捕获,不能处理的层继续抛出并保留 cause。枚举表达有限状态,泛型表达容器里装什么。任何 null、魔法字符串和 raw type 都应成为代码审查时的追问点。
把失败分成三类会更容易落点:输入文本格式错误属于解析边界,返回带字段原因的结果;数据库连接断开属于基础设施异常,由 Repository 保留 cause 并让上层决定重试;状态迁移不允许属于领域异常,Service 翻译成冲突响应。若三类都 catch 成 RuntimeException("失败"),调用链会丢失处理者和恢复方式。
枚举状态的输入输出也要固定:数据库的 DRAFT 读成 OrderStatus.CREATED 之前需要一个映射表,未知 code 应失败而不是默认为 CREATED;状态迁移接收当前状态和动作,返回新状态或抛出原因。泛型 Result<Order> 的成功分支只能携带 Order,失败分支携带 code/message,调用方不会把一个错误字符串误当订单字段。
项目文件可以把领域异常放在 domain/error,Web 错误响应放在 web/error,数据库异常适配放在 persistence. 统一异常处理器只做翻译,不在其中重复查询数据库。排查时保留原始 cause、业务 id 和状态,检查是否在代理边界前被吞掉,以及是否把 Optional.empty() 当成“数据库故障”。
练习:为文章发布实现 Result<Article>,测试草稿成功、重复发布、文章不存在和数据库异常四条路径;再让未知枚举字符串进入解析器,验证错误中包含原始字段但不泄露 SQL。最后比较 Optional、异常和 Result 在 Controller 返回 400/404/500 时的调用链差异。
错误对象本身也有状态:解析失败应带字段名、原始值的安全摘要和错误码;领域异常应带订单/文章 id 和当前状态;基础设施异常应保留 cause、操作名和重试建议。上层翻译时只改变面向客户端的表示,不要丢掉底层堆栈。这样从 HTTP 500 反查到 SQL 或连接错误才有路径。
泛型结果的类型参数还会影响 API 可读性:Result<Order> 表示成功值是订单,Result<List<Order>> 表示批量结果,不能用一个 Result<Object> 让每个调用方强转。枚举映射要单独测试数据库 code、JSON name 和 Java constant 的差异,新增状态时先更新迁移、映射、状态图和前端显示。
项目文件可在 domain/result 放 Result,在 domain/status 放枚举,在 web/error 放响应 DTO,在 persistence/error 放数据访问异常。排错先找异常第一次产生的位置,再看在哪里被捕获和转换;如果日志只有最后一层 message,说明中间 catch 把 cause 吞了。练习是为每条错误路径写“产生者—处理者—客户端输出”三列。
把错误原因和恢复动作一起写入测试:输入错误返回字段提示,资源不存在返回 not_found,状态冲突返回 conflict,数据库故障保留 cause 并触发告警。调用方由结果类型或异常契约知道下一步,不再靠 null 猜测。
验证清单:让解析器收到未知状态码,确认它在映射边界失败而不是默认为草稿;让 Repository 抛出带 cause 的数据库异常,检查 Service 只增加业务上下文而没有丢堆栈;让发布接口重复调用,确认第二次是稳定的冲突结果且文章状态仍为已发布。最后检查 Result<Order>、Result<List<Order>> 和 Result<Object> 的调用代码,比较哪一种能在编译期阻止错误强转。复盘每条路径时写出“产生者—处理者—恢复动作—HTTP 输出”,异常是否被吞掉一眼就能看出。
还可以做一个小型状态表:DRAFT + publish -> PUBLISHED 成功,PUBLISHED + publish -> conflict,DRAFT + cancel -> CANCELED,未知 code -> parse_error。把表分别实现成枚举映射、领域方法和统一异常处理器,观察同一个失败如何经过三层而仍保持同一错误码。若三层各自重新拼接 message,测试会提醒你把稳定 code 和面向人的 message 分开。
进阶附录:通配符与 try-with-resources
List<? extends Number> 适合读取一组 Number,List<? super Integer> 适合写入 Integer;口诀只是辅助,真正要看生产者和消费者方向。实现 AutoCloseable 的资源应使用 try-with-resources,它会在异常路径也关闭资源,并把关闭异常作为 suppressed exception 保留。
本课按「Java 21 异常机制、枚举与泛型基础」的学习范围组织,正文与示例均为本站原创整理。