装饰器模式中如何处理异常、副作用和资源释放?
简化版
装饰器处理异常时要保持语义透明:不该吞的异常不能吞,副作用要有明确提交或回滚策略,资源必须在 finally 或等价机制中释放。日志、缓存、事务、压缩、加密等装饰器都要考虑异常路径,否则增强逻辑可能改变目标对象原本的业务语义。
详细版
装饰器包在目标对象外面,天然能在调用前后做事。但这也带来一个风险:装饰器可能改变异常传播、副作用执行顺序和资源生命周期。
例如:
- 日志装饰器吞掉异常,调用方误以为成功;
- 缓存装饰器在目标方法失败后仍写入缓存;
- 压缩装饰器写到一半失败,底层流没有关闭;
- 计数装饰器只统计成功,不统计失败;
- 重试装饰器对非幂等写操作重复执行;
- 加密装饰器异常后留下半成品文件。
好的装饰器应该明确:
- 哪些异常继续抛出;
- 哪些异常可以转换;
- 调用前副作用失败时是否继续;
- 调用后副作用失败时是否影响主流程;
- 资源释放放在哪里;
- 是否需要补偿或清理半成品。
面试中要强调:装饰器不是随便加 before/after,异常路径才是检验工程质量的关键。
完整版教学
一、为什么装饰器特别容易改变异常语义
装饰器包住目标对象后,可以捕获目标对象抛出的异常。捕获异常本身没问题,问题是捕获后怎么处理。如果装饰器把异常吞掉并返回默认值,调用方看到的语义就和目标对象原本语义不同。
public String read() {
try {
return target.read();
} catch (IOException e) {
return ""; // 危险:调用方可能以为读到了空内容
}
}
这段代码看似增强了“容错”,但它把读取失败和读取到空字符串混为一谈。调用方无法区分真实空内容和异常失败。除非接口契约明确允许这种降级,否则装饰器不应该随意吞异常。
记忆钩子:装饰器可以加行为,但不能偷偷改契约。
二、异常处理的三种策略
装饰器处理异常通常有三种策略:透传、转换、降级。透传是保持原异常继续往外抛;转换是把底层异常包装成领域异常;降级是返回默认结果或备用结果。
| 策略 | 含义 | 适合场景 |
|---|---|---|
| 透传 | 原样或近似原样抛出 | 日志、指标、简单增强 |
| 转换 | 包装成更上层异常 | 适配底层库、统一错误模型 |
| 降级 | 返回备用结果 | 明确允许降级的读场景 |
转换异常时要保留 cause,不要丢失原始堆栈:
catch (IOException e) {
throw new StorageException("read failed", e);
}
降级要非常谨慎,尤其不能对写操作随意降级。写订单失败不能返回成功,扣款失败不能返回已支付。
三、调用前副作用失败怎么办
装饰器常在调用前做副作用,例如权限检查、限流扣令牌、打开资源、创建临时文件、记录开始事件。这些动作失败时,要决定是否继续调用目标对象。
调用前动作:
权限校验失败 -> 不应继续
限流失败 -> 不应继续
指标开始记录失败 -> 通常不应影响主流程
打开输出流失败 -> 不能继续写入
不同副作用的关键性不同。权限、限流、资源准备属于前置条件,失败就不能继续。指标、非关键日志属于旁路能力,失败通常只记录内部告警,不应影响主流程。
这个判断要写进装饰器契约,否则不同开发者会用不同方式处理异常,系统行为会不一致。
四、调用后副作用失败怎么办
调用后副作用更微妙。比如目标方法成功了,但装饰器写审计日志失败,是否要让整个调用失败?目标方法成功写库后,缓存更新失败,是否要抛异常?这取决于副作用是否属于业务成功的一部分。
目标方法成功:
写审计日志失败 -> 有些合规场景必须失败,有些业务可异步补偿
更新缓存失败 -> 通常不应让主写入回滚,但要保证后续缓存一致性
发送通知失败 -> 常用消息表或重试补偿
不能简单说 after 失败就吞掉或抛出。要按业务关键性分级。缓存更新失败通常可通过删除缓存、延迟重试、消息补偿解决;合规审计失败可能必须阻断主流程。
五、资源释放必须覆盖成功和失败路径
装饰器常用于资源增强,例如缓冲流、压缩流、加密流。这类装饰器必须正确释放资源。Java IO 装饰器中,关闭外层流通常会级联关闭内层流,但自定义装饰器不能默认正确。
public void write(String data) throws IOException {
TempFile temp = createTempFile();
try {
String compressed = compress(data);
target.write(compressed);
temp.commit();
} catch (IOException e) {
temp.rollback();
throw e;
} finally {
temp.close();
}
}
如果没有 finally,压缩失败、写入失败、提交失败都可能留下临时文件或未关闭句柄。资源泄漏在低流量下不明显,在高并发下会变成文件句柄耗尽、内存增长、连接池耗尽。
六、用数字例子看资源泄漏的危害
假设一个装饰器每次失败泄漏 1 个文件句柄,系统每分钟有 20 次失败。1 小时泄漏:
20 * 60 = 1200 个文件句柄
如果系统文件句柄上限是 4096,几个小时就可能耗尽。此时表面错误可能是“无法打开文件”或“连接失败”,根因却是装饰器异常路径没有释放资源。
类似地,如果每次失败泄漏 256 KB 缓冲区,10000 次失败就是:
256 KB * 10000 = 2,560,000 KB ≈ 2.44 GB
异常路径不是边角料,它常常决定系统是否能长期稳定运行。
七、装饰器和重试副作用的边界
重试装饰器尤其需要关注副作用。如果目标操作是读操作,重试通常比较安全;如果目标操作是写操作,必须配合幂等键、结果查询或事务机制。
安全倾向较高:
查询库存
拉取配置
读取缓存
风险较高:
扣款
创建订单
发放优惠券
发送短信
对写操作做重试时,装饰器不能只知道“异常了再来一次”,还要知道异常是否发生在服务端已执行之后。网络超时不代表服务端没成功,重复提交可能造成双写。
八、常见误区与追问
- 误区:装饰器捕获异常后返回默认值就是增强健壮性。 如果接口契约没有允许降级,这会掩盖真实失败。
- 误区:after 逻辑失败不重要。 审计、缓存、通知等后置副作用是否关键,要按业务语义判断。
- 误区:资源释放交给底层对象就行。 自定义装饰器创建的临时资源必须自己负责清理。
- 追问:日志装饰器异常该不该影响主流程? 普通日志通常不影响,合规审计日志可能必须影响,要看契约。
- 追问:缓存更新失败怎么办? 常见策略是删除缓存、异步补偿、短 TTL 或重试,不应悄悄留下错误缓存。
- 追问:重试装饰器为什么危险? 对非幂等写操作,重试可能重复执行副作用。
- 追问:异常转换要注意什么? 保留原始 cause 和上下文,避免丢失排查信息。
九、加强记忆
装饰器异常治理记住“契约透明、资源必清、副作用分级”。能透传就透传,需要转换就保留 cause,允许降级才降级;凡是创建了资源,就在失败路径清理;凡是做了副作用,就明确它失败时是否影响主流程。