都是包一层,意图却不同:适配器、装饰器与代理
适配器、装饰器、代理的类图几乎一样:持有一个对象、实现一个接口、转发调用。真正区分它们的是意图,而每种意图都有一个最容易做错的地方:适配器丢掉失败语义,装饰器的顺序改变结果,代理被自调用绕过。
用一个通知网关说明。业务层定义了自己的接口:
public interface NotificationGateway {
Receipt send(Message m);
}外面接的是第三方短信 SDK,要加重试、缓存和计量,还要做访问控制。实测 10 条短信(2 条号码无效、3 条第一次被限流),同样套一层「最多尝试 3 次」的重试:
| 适配器的写法 | 供应商调用 | 调用方看到的失败 |
|---|---|---|
所有非 OK 都翻译成 SmsFailed("短信发送失败") | 17 次 | 2 次「短信发送失败」 |
| 区分可重试与不可重试,保留供应商错误码 | 13 次 | 2 次 INVALID_NUMBER |
第一种写法让无效号码也被重试了 3 次,而且调用方永远不知道失败原因。本文用 JDK 21 的实测,把三种包装的意图和各自的坑分开讲。
一、先说结论
- 先问包装的目的,再决定叫什么。 接口或失败语义不兼容 → 适配器;接口相同、要叠加能力 → 装饰器;要控制调用能不能、何时、在哪里发生 → 代理。
- 适配器要翻译完整的失败语义。 错误码、是否可重试、是否可能已经生效,这些信息丢在翻译里,上层的重试和告警就全部失真。实测把失败分成可重试与不可重试后,供应商调用从 17 次降到 13 次。
- 装饰器的叠加顺序就是语义。 实测同样三个装饰器,计量放在最内层时报告 5 个错误,放在最外层时报告 0 个错误,而用户请求全部成功。
- 代理只拦截经过代理的调用。 实测
sendAll内部用this调用send,发送 3 条只经过代理 1 次;未声明的受检异常会被包装成UndeclaredThrowableException。 - 装配顺序集中写在一处。 多层包装由扫描顺序、配置文件或各处
@Order拼出来时,谁在外、谁在里没人说得清。
二、同样的结构,三种意图
| 问题 | 意图 | 最容易遗漏的 |
|---|---|---|
| 第三方的接口、数据或失败方式和内部契约对不上 | 适配器:翻译 | 错误码、可重试性、精度和时区在翻译中丢失 |
| 接口不变,要加上重试、缓存、计量、日志 | 装饰器:叠加 | 顺序改变结果;调用栈变深 |
| 要控制访问:权限、事务、延迟加载、远程调用 | 代理:控制 | 自调用绕过;代理类型与异常包装 |
| 两个维度独立变化(渠道 × 消息格式) | 桥接:组合 | 维度并不真正独立时,抽象反而增加 |
同一个对象可以同时被三种包装套住:适配器把 SDK 变成 NotificationGateway,装饰器在外面加重试和计量,Spring 再在最外层生成一个事务代理。判断的依据始终是「这一层为什么存在」。
三、适配器:翻译接口,也翻译失败
适配器的价值在于隔离:业务只依赖自己定义的接口,换供应商只换一个类。代价是翻译必须完整,尤其是失败:
record PreciseAdapter(VendorSmsClient client) implements NotificationGateway {
public Receipt send(Message m) {
VendorResponse r = client.sendMessage(m.to(), m.text());
return switch (r.code()) {
case "OK" -> new Receipt(r.messageId());
case "RATE_LIMITED", "TIMEOUT" -> throw new RetryableFailure(r.code()); // 可以重试
default -> throw new PermanentFailure(r.code()); // 重试也没用
};
}
}重试装饰器只重试 RetryableFailure。实测对比开头的表格:翻译粗糙时,无效号码被重试到上限,多出 4 次无意义的供应商调用;调用方只知道「失败了」,没法区分是号码填错还是供应商在限流。
翻译时至少保留三件事:
- 原始错误码与消息,用于排查和告警;
- 能否重试,由适配器判断,因为只有它了解供应商的语义;
- 是否可能已经生效。超时的请求可能已经发出了短信,重试前要考虑重复发送,这类调用需要幂等键,见 可复用的服务端组件。
四、装饰器:顺序就是语义
装饰器保持接口不变,一层层叠加能力。实测三个装饰器:计量(统计调用数与错误数)、缓存(相同消息直接返回上次的回执)、重试(只重试可重试的失败)。10 次请求是 5 条消息各发两次,下游对每条消息的第一次调用都会超时:
| 装配 | 计量的调用数 | 计量的错误数 | 缓存命中 | 下游调用 |
|---|---|---|---|---|
retry(cache(metrics(下游))) | 10 | 5 | 5 | 10 |
metrics(cache(retry(下游))) | 10 | 0 | 5 | 10 |
两组数字都对,只是回答的问题不同:内层计量统计的是下游尝试,能看出下游有一半的调用失败;外层计量统计的是用户请求,用户感受到的错误是 0,但缓存命中也被算进了调用量和延迟。需要两种视角时就各放一个,并在指标名里写清楚是哪一层。
缓存与重试的相对位置同样重要:缓存在重试外面时,重试成功的结果被缓存;缓存在重试里面时,每次重试都会先查一次缓存。把这些顺序写在一个装配方法里,而不是分散在各处的配置里:
NotificationGateway gateway(VendorSmsClient client, MeterRegistry meters) {
NotificationGateway g = new PreciseAdapter(client); // 最里层:翻译
g = new Retry(g); // 只重试可重试的失败
g = new Cache(g);
return new Metrics(g, meters, "notification.request"); // 最外层:用户视角
}装饰器的另一项代价是调用栈:实测一个异常穿过三层装饰器,抛出点到调用方之间从 1 帧变成 4 帧,见 设计模式决策图。
五、代理:控制访问,只对经过它的调用生效
代理和装饰器实现上都持有被包装的对象,区别在意图:代理决定调用能不能发生、在什么事务里发生、在哪台机器上发生。Spring 的 @Transactional、@Cacheable、@Async 都是代理。
代理有两个直接来自机制的限制。
自调用绕过代理。 实测一个 JDK 动态代理包住网关,sendAll 内部通过 this.send(...) 发送 3 条短信,代理只拦截到外部调用的 sendAll 这 1 次。Spring AOP 的同类问题,以及 final 方法、private 方法的表现,见 Spring AOP 为什么会失效。
异常会被包装。 代理的处理逻辑抛出受检异常时:
| 接口方法 | 调用方收到 |
|---|---|
声明了 throws IOException | IOException |
| 没有声明 | UndeclaredThrowableException,原因是 IOException |
所以代理层里调用远程服务、访问文件时,要么把受检异常转换成接口声明的异常,要么在接口上声明,否则调用方的 catch 永远匹配不到。
六、装配与排查
- 集中装配:多层包装写在一个工厂方法或配置类里,一眼能看出从外到内的顺序。
- 每层一句话:给每个包装类写清楚它为什么存在(翻译、叠加什么、控制什么),名字里体现层级(
Metered、Retrying)。 - 排查时先确认调用链:从异常栈里数一数包装层,确认调用有没有经过代理(Spring 的 CGLIB 代理在栈里显示为
$$SpringCGLIB$$)。 - 不要为了模式而包装:只在一两处需要的能力,直接写在调用方更清楚。
七、常见误区
- 「代理和装饰器是一回事」:结构相同,意图不同。代理控制访问,装饰器叠加能力;前者常由框架生成,后者由自己装配。
- 「适配器只是改个方法名」:它还要翻译数据和失败语义,实测粗糙的翻译让无效号码被重试了 3 次。
- 「装饰器顺序无所谓」:实测计量放在里外,错误数分别是 5 和 0。
- 「加了注解就一定被拦截」:内部调用不经过代理,实测 3 次发送只拦截 1 次。
小结
遇到「包一层」的需求时,先写下这一层的目的:翻译、叠加还是控制。翻译要把失败语义一起翻译过来;叠加要把顺序写在一处,并想清楚每一层看到的是什么;控制要确认调用真的经过了代理。模式名称只是方便沟通,决定设计质量的是这三个问题有没有答案。
多种算法怎样选择、流程怎样扩展、处理链怎样短路,见 可扩展流程怎样设计。
配套实验
- codesphere-labs/design/wrapper-patterns:适配器的错误翻译与重试、装饰器的两种叠加顺序、JDK 动态代理的自调用与异常包装(验证记录)
参考资料
- Gamma 等,《Design Patterns: Elements of Reusable Object-Oriented Software》(Adapter、Decorator、Proxy)
- JDK 21 API:java.lang.reflect.Proxy
- JDK 21 API:UndeclaredThrowableException
- Spring Framework Reference:Understanding AOP Proxies