Skip to content

都是包一层,意图却不同:适配器、装饰器与代理 ​

适配器、装饰器、代理的类图几乎一样:持有一个对象、实现一个接口、转发调用。真正区分它们的是意图,而每种意图都有一个最容易做错的地方:适配器丢掉失败语义,装饰器的顺序改变结果,代理被自调用绕过。

用一个通知网关说明。业务层定义了自己的接口:

java
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关键:错误码与重试语义要翻译完整装饰器NotificationGateway → 同一接口关键:叠加顺序就是语义代理同一接口,控制访问关键:自调用、异常包装实测:错误一律翻译成一个异常无效号码也重试,调用 17 次 vs 13 次实测:计量放在最内层报告 5 个错误,用户请求全部成功实测:sendAll 内部调用 send3 次发送只经过代理 1 次判断顺序:接口或失败语义不兼容 → 适配器;接口相同、要叠加能力 → 装饰器;要控制访问 → 代理三者经常叠在一起,装配顺序应集中写在一处
图 1 · 结构都是「持有一个对象并转发调用」,区别在于包装的目的:适配器翻译接口与失败,装饰器在同一接口上叠加能力,代理决定调用能不能、何时、在哪里发生
问题意图最容易遗漏的
第三方的接口、数据或失败方式和内部契约对不上适配器:翻译错误码、可重试性、精度和时区在翻译中丢失
接口不变,要加上重试、缓存、计量、日志装饰器:叠加顺序改变结果;调用栈变深
要控制访问:权限、事务、延迟加载、远程调用代理:控制自调用绕过;代理类型与异常包装
两个维度独立变化(渠道 × 消息格式)桥接:组合维度并不真正独立时,抽象反而增加

同一个对象可以同时被三种包装套住:适配器把 SDK 变成 NotificationGateway,装饰器在外面加重试和计量,Spring 再在最外层生成一个事务代理。判断的依据始终是「这一层为什么存在」。

三、适配器:翻译接口,也翻译失败 ​

适配器的价值在于隔离:业务只依赖自己定义的接口,换供应商只换一个类。代价是翻译必须完整,尤其是失败:

java
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 次无意义的供应商调用;调用方只知道「失败了」,没法区分是号码填错还是供应商在限流。

翻译时至少保留三件事:

  • 原始错误码与消息,用于排查和告警;
  • 能否重试,由适配器判断,因为只有它了解供应商的语义;
  • 是否可能已经生效。超时的请求可能已经发出了短信,重试前要考虑重复发送,这类调用需要幂等键,见 可复用的服务端组件。

四、装饰器:顺序就是语义 ​

retry(cache(metrics(下游)))RetryCache命中 5 次Metrics计数 10、错误 5下游调用 10 次metrics(cache(retry(下游)))Metrics计数 10、错误 0Cache命中 5 次Retry下游调用 10 次内层计量回答「下游有多不稳定」,外层计量回答「用户感受到什么」;两者都需要时各放一个,并在名字里写清楚
图 2 · 同样三个装饰器、同样 10 次请求(5 个消息各发两次,每个消息第一次下游超时):计量在最内层时统计的是下游尝试,记录 5 个错误;计量在最外层时统计的是用户请求,错误为 0,而且把缓存命中也算了进去

装饰器保持接口不变,一层层叠加能力。实测三个装饰器:计量(统计调用数与错误数)、缓存(相同消息直接返回上次的回执)、重试(只重试可重试的失败)。10 次请求是 5 条消息各发两次,下游对每条消息的第一次调用都会超时:

装配计量的调用数计量的错误数缓存命中下游调用
retry(cache(metrics(下游)))105510
metrics(cache(retry(下游)))100510

两组数字都对,只是回答的问题不同:内层计量统计的是下游尝试,能看出下游有一半的调用失败;外层计量统计的是用户请求,用户感受到的错误是 0,但缓存命中也被算进了调用量和延迟。需要两种视角时就各放一个,并在指标名里写清楚是哪一层。

缓存与重试的相对位置同样重要:缓存在重试外面时,重试成功的结果被缓存;缓存在重试里面时,每次重试都会先查一次缓存。把这些顺序写在一个装配方法里,而不是分散在各处的配置里:

java
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 IOExceptionIOException
没有声明UndeclaredThrowableException,原因是 IOException

所以代理层里调用远程服务、访问文件时,要么把受检异常转换成接口声明的异常,要么在接口上声明,否则调用方的 catch 永远匹配不到。

六、装配与排查 ​

  • 集中装配:多层包装写在一个工厂方法或配置类里,一眼能看出从外到内的顺序。
  • 每层一句话:给每个包装类写清楚它为什么存在(翻译、叠加什么、控制什么),名字里体现层级(Metered、Retrying)。
  • 排查时先确认调用链:从异常栈里数一数包装层,确认调用有没有经过代理(Spring 的 CGLIB 代理在栈里显示为 $$SpringCGLIB$$)。
  • 不要为了模式而包装:只在一两处需要的能力,直接写在调用方更清楚。

七、常见误区 ​

  • 「代理和装饰器是一回事」:结构相同,意图不同。代理控制访问,装饰器叠加能力;前者常由框架生成,后者由自己装配。
  • 「适配器只是改个方法名」:它还要翻译数据和失败语义,实测粗糙的翻译让无效号码被重试了 3 次。
  • 「装饰器顺序无所谓」:实测计量放在里外,错误数分别是 5 和 0。
  • 「加了注解就一定被拦截」:内部调用不经过代理,实测 3 次发送只拦截 1 次。

小结 ​

遇到「包一层」的需求时,先写下这一层的目的:翻译、叠加还是控制。翻译要把失败语义一起翻译过来;叠加要把顺序写在一处,并想清楚每一层看到的是什么;控制要确认调用真的经过了代理。模式名称只是方便沟通,决定设计质量的是这三个问题有没有答案。

多种算法怎样选择、流程怎样扩展、处理链怎样短路,见 可扩展流程怎样设计。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权