异常处理虽然看似一个小话题,但几乎每个Java项目都踩过它的坑。尤其是受检异常(Checked Exception),当年Ja va语言设计者将其加入语法时,初衷是好的——强制开发者处理潜在的错误场景。然而在实际项目里,SQLException、IOException层层传递,方法签名上堆满了throws声明,代码读起来就像在啃一块硬骨头。
今天我们要聊的重构方向,并非彻底抛弃受检异常,而是采用一种更聪明的策略:将它们包装成带有业务语义的运行时异常。这样一来,编译检查不会阻止代码运行,同时异常本身也成为了业务表达的载体。
在 DAO 与 Service 边界做一次干净剥离
很多项目里都能看到这样的场景:Controller 层直接捕获 SQLException,甚至只在最上层打印堆栈就完事。技术细节和业务逻辑搅在一起,想拆都拆不开。
重构的关键,是划出一个清晰的“异常转化区”。
- DAO 层(包括 MyBatis Mapper 的实现类)统一拦截底层的 IO 异常、SQL 异常,然后转成语义明确的运行时异常——比如 Spring 自带的
DataAccessException,或者你自己定义的PersistenceException。 - Service 层的方法签名里,再也看不到
throws SQLException这样的声明。Service 只关心业务规则,它根据运行时异常的类型来决定要不要重试、降级,而不是依赖原始异常的类名。 - 特别提醒:不要在 Service 里 catch 住异常之后,又 throw 出原始的受检异常。那样等于把技术细节重新塞回业务逻辑,白忙一场。
用带业务含义的运行时异常替代泛型 RuntimeException
throw new RuntimeException(e) 这种写法,虽然编译能过,但后续维护的人会想打人。因为没人知道这个异常到底代表了什么场景,只能靠看堆栈去猜测。
正确的做法是:按失败场景归类,封装成不同的异常子类。
- 参数解析失败(比如 JSON 反序列化出错、日期格式不对)→ 用
IllegalArgumentException的子类,比如InvalidRequestException。 - 外部服务不可用或超时 → 定义
ExternalServiceException,最好带上服务名、超时阈值等上下文信息。 - 业务状态不一致(例如订单已支付却尝试取消)→ 用
IllegalStateException的子类,比如IllegalOrderStateException。 - 所有自定义异常,必须提供
Throwable cause构造器,并且在日志里把原始异常的类名和消息原样记录下来。
这样一来,异常本身就变成了业务信号。看异常名就知道问题出在哪个环节,比看堆栈猜快得多。
为 Stream、CompletableFuture 等现代 API 提供安全适配
受检异常在 Lambda 表达式里简直是噩梦——你没法在 Lambda 内部直接 throw 一个受检异常,除非你到处写 try-catch 然后转成运行时异常。但每个地方都这么干,代码冗余度会变得很高。
一个更轻量的做法是:借助工具方法做一次性包装。
Files.readAllLines()、BufferedReader.lines()这类标准 IO 方法,优先使用 Ja va 8 自带的UncheckedIOException来包装。- 对于自定义的 IO 逻辑,可以写一个工具方法,比如
IOFunctions.unchecked(Files::readAllLines),内部完成 try-catch 并构造UncheckedIOException。 - 这个工具方法返回的是
Function这样的无异常声明接口,流式操作就能保持简洁。>
这套适配方案,本质上就是“一次包装,处处使用”,既不用重复 try-catch,也不会让流式链被异常处理打断。
配套全局处理器与可观测性补全链路
包装成运行时异常后,最大的隐忧是问题定位能力下降。因为运行时异常默认不会在编译期强制处理,万一漏掉,线上错误日志可能变得难以追踪。
所以配套的支撑机制必须跟上。
- 用
@ControllerAdvice或@ExceptionHandler统一拦截BusinessException及其子类,返回结构化的错误响应,而不是直接抛 500。 - 所有异常在抛出之前,打点埋标:记录异常分类、触发路径、重试次数等维度,接入监控告警系统。
- 日志里必须保留完整的堆栈,让原始异常 cause 和当前层包装异常同时可见。这样才能支持逐层下钻排查,不会因为包装而丢失根源。
一句话总结:异常包装不是糊弄编译器,而是给错误起个有业务含义的名字,同时把各层职责理清。剩下的,靠全局处理和可观测性来兜底。

