主题
结构型模式:代理、适配器、装饰器、外观、桥接、组合、享元
一句话:结构型模式都在加一层间接——代理控制访问,适配器转换接口,装饰器叠加功能,外观收敛入口,桥接拆开两个变化维度,组合统一树形结构,享元共享不变部分。间接层换来可替换和可组合,代价是调用链变长、调试变难。
为什么需要
三类问题反复出现:
- 接口对不上:调用方要
OrderRepository,第三方只给Map和long; - 功能要叠加:读数据要加缓存、解密、压缩,组合数随功能数量指数增长;
- 调用要控制:事务、日志、权限不能写进每个业务方法。
共同解法是在调用方和目标之间插入一个实现同一接口(或转换接口)的对象。区别只在意图:控制访问、转换协议、增强行为、简化入口、拆分维度、统一树形、共享状态。
核心机制
七种模式的意图与对象关系
| 模式 | 接口关系 | 对象由谁创建 | 意图 | 典型场景 |
|---|---|---|---|---|
| 代理 Proxy | 与目标相同 | 代理内部创建或持有目标 | 控制访问 | Spring AOP、RPC stub、延迟加载 |
| 适配器 Adapter | 与目标不同 | 外部传入被适配者 | 转换接口 | 遗留系统对接、InputStreamReader |
| 装饰器 Decorator | 与目标相同 | 外部传入被装饰者 | 增强行为 | Java IO、MyBatis 二级缓存 |
| 外观 Facade | 新接口 | 内部组合多个子系统 | 简化入口 | 下单门面、PlatformTransactionManager |
| 桥接 Bridge | 抽象持有实现 | 构造时注入实现 | 拆开两个维度 | JDBC Driver、支付类型 × 渠道 |
| 组合 Composite | 叶子和容器同接口 | 容器持有子节点 | 统一树形 | 文件系统、MyBatis SqlNode |
| 享元 Flyweight | 共享对象 | 工厂缓存 | 共享不变状态 | Integer 缓存、字符串常量池 |
代理 vs 装饰器最容易混:结构几乎一样,都是「实现同一接口 + 持有目标」。区分看意图和装配方式——代理通常由框架创建,调用方往往不知道目标存在;装饰器由调用方显式嵌套,组合顺序就是语义。
代理:JDK 动态代理与 CGLIB
JDK 动态代理基于接口:Proxy.newProxyInstance 在运行时生成一个实现指定接口的类,方法调用统一进入 InvocationHandler#invoke。CGLIB 基于继承:生成目标类的子类,重写非 final 方法。Spring AOP 的选择:
- 目标实现了接口且
proxyTargetClass=false→ JDK 动态代理; - 没有接口,或
proxyTargetClass=true→ CGLIB; - Spring Boot 2.x 起,AOP 自动配置默认
spring.aop.proxy-target-class=true(CGLIB),可用该属性改回 JDK 代理。
性能不要写死倍数。 JDK 8 起 Method.invoke 做了大幅优化,JDK 9+ 反射内部使用 MethodHandle;JDK 动态代理仍比直接调用多一次 handler 分发和 Object[] 参数装箱,CGLIB 生成子类后是直接调用,通常更快,但差距随 JDK 版本、JIT 预热、调用次数、参数数量变化很大。正确做法是用 JMH 在同一 JDK、同一负载下测,而不是引用一句「慢十倍」。
实战场景
一、装饰顺序写反:压缩流套在缓冲流外层
现象:报表导出走“先压缩再落对象存储”。某次改动把包装顺序写成 new GZIPOutputStream(new BufferedOutputStream(new FileOutputStream(f))),导出文件在部分机器上解压失败(unexpected end of input stream),且大小不一致;本地单文件测试正常,批量并发时才出现。
排查:装饰器叠加顺序决定行为,不是性能细节。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 包装链形态 | 断点或日志打印 toString() 类名序列,看谁包谁 | 压缩在外、缓冲在内 → 关闭顺序错误 |
| 关闭顺序 | close() 是否先关最外层 | 只关最内层会让压缩尾部未写出 |
| 数据完整性 | 解压一次导出文件并与源行数比对 | 尾部缺失即写入未完成 |
| 并发相关性 | 是否只在多任务/大文件时出现 | 缓冲未满时不暴露,量大才暴露 |
| 异常路径 | try-with-resources 是否覆盖所有包装 | 部分包装在资源块外创建 → 泄漏 |
根因是关闭语义:外层装饰器(压缩)在 close() 时才写出结束标记与校验;缓冲放在外层反而无害,位置反过来就会丢尾部。
对症做法:
java
// 1. 由内向外:真实目标 → 缓冲 → 装饰功能;用 try-with-resources 一次声明整条链
try (OutputStream file = new FileOutputStream(target);
OutputStream buffered = new BufferedOutputStream(file, 64 * 1024);
OutputStream gzipped = new GZIPOutputStream(buffered)) {
writer.writeTo(gzipped); // 写最外层,close 会自动从外向内收口
}
// 2. 把“链的构造”收进一个工厂方法,调用点看不见顺序,也就写不错
static OutputStream newExportStream(Path p) throws IOException {
return new GZIPOutputStream(new BufferedOutputStream(new FileOutputStream(p.toFile()), 64 * 1024));
}
// 3. 测试直接断言行为:解压后行数与源一致
@Test void gzipRoundTripKeepsAllRows() throws Exception {
byte[] gz = exportTo(p -> newExportStream(p), rows(10_000));
assertThat(readGzip(gz).size()).isEqualTo(10_000);
}text
4. 自定义装饰器同理:日志装饰、限流装饰、重试装饰的顺序会改变语义,
“重试在最外层、限流在里层”与相反写法的结果不同,顺序要在工厂里固定。复盘要点:判据是“装饰链是否只有一个构造点”和一个往返测试。结构型模式换来可组合,代价是调用链变长、调试变难——这条代价在顺序问题上体现得最直接:链越长,写反的概率越高,所以顺序必须由工厂而不是调用方负责。
二、外观把异常吞了:调用方以为导出成功
现象:ReportFacade.export(reportId) 内部串起查询、组装、写文件、上传四步,为了“接口干净”加了 catch (Exception e) { log.warn("export fail", e); }。上游批量任务据此认为成功,把状态置为 EXPORTED;用户下载时 404。三天内 200 余份报表处于“已完成但无文件”状态(经验值,需校准)。
排查:外观的价值是收敛入口,不是抹平失败。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 异常出口 | Facade 方法是否有 catch 后不重抛 | 命中即语义丢失 |
| 返回值约定 | 是否用 boolean/void 表达结果 | 无失败信息可回传 |
| 上游状态机 | 状态推进是否依赖返回值而非事实 | “无异常即成功”是本次根因 |
| 日志可见性 | warn 级别是否被采集与告警 | 未告警的失败等于没有失败 |
| 可观测出口 | 是否有成功率、失败原因分类指标 | 缺失时只能靠用户投诉发现 |
对症做法:
java
// 1. 外观保留失败语义:要么抛出,要么返回带原因的结果
public ExportResult export(long reportId) {
try {
var data = queryService.load(reportId);
var file = assembler.build(data);
var ref = storage.upload(file);
return ExportResult.ok(reportId, ref.url(), file.rows());
} catch (StorageUnavailableException e) {
throw new ExportRetryableException(reportId, e); // 可重试:让上游决定
} catch (DataLoadException e) {
return ExportResult.failed(reportId, e.reason()); // 业务失败:给出原因码
}
}
// 2. 上游按结果推进状态,而不是“没异常就算成功”
ExportResult r = facade.export(id);
if (r.success()) state.mark(reportId, EXPORTED, r.url());
else if (r.retryable()) queue.reEnqueue(reportId, backoff(r));
else state.mark(reportId, EXPORT_FAILED, r.reason());
// 3. 每个出口都要有指标:成功率、按原因码的失败分布、耗时text
4. 保留日志的同时必须让它可被消费:warn 以上的导出失败进告警,
或者失败计数直接进指标——日志不是状态机的输入。复盘要点:判据是“Facade 是否存在吞异常的 catch”——静态检查 catch 块内既无重抛、也无结果标记即报警。外观是给用户和内部模块提供统一入口,但收敛入口不等于收敛语义;一旦把失败折叠成成功,下游所有状态推进都建立在错误前提上。
三、渠道 × 地区两个维度纠缠:接一个地区改八个渠道
现象:支付侧原本有 8 个渠道实现,每个类里都写着本渠道在各地区的差异(限额、币种、路由、对账格式)。业务要求“新增泰国地区”,改动落在 8 个类里;上线后其中 2 个渠道忘了改,泰国订单直接失败。半年后这类“新增维度值”的改动平均要碰 5 个文件。
排查:两个独立变化的维度被压进同一继承层次,就是桥接缺失的信号。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 维度识别 | 能否分别说出“渠道会新增吗”“地区会新增吗” | 两者都会 → 是两个维度 |
| 类数量增长 | 实现数是否接近 渠道数 × 地区数 | 笛卡尔积膨胀即确认 |
| 改动波及面 | 新增一个地区值要改几个类 | 本次为 8(应接近 1) |
| 遗漏记录 | 上线后发现的缺陷是否集中在“漏改的维度组合” | 是 → 组合无处登记 |
| 抽象层次 | 渠道逻辑里是否直接 if (region == ...) | 硬编码 → 实现细节泄漏进抽象 |
对症做法:
java
// 抽象侧:渠道只定义“支付流程”,不关心地区差异
public abstract class PaymentFlow {
protected final RegionPolicy region; // 实现侧的抽象,构造注入
public PayResult pay(PayRequest req) {
region.validate(req); // 限额、币种由地区决定
var order = createOrder(req);
return settle(order, region.reconcileFormat()); // 对账格式同样来自地区
}
}
class CardPayment extends PaymentFlow { CardPayment(RegionPolicy r) { super(r); } }
// 实现侧:地区差异收进一族策略,新增地区只加一个实现
public interface RegionPolicy {
void validate(PayRequest req);
String reconcileFormat();
String currency();
}
class ThailandPolicy implements RegionPolicy { ... }
// 组合由装配层完成:渠道 × 地区的登记表一处可见
@Configuration
class PaymentConfig {
@Bean Map<String, PaymentFlow> flows(RegionPolicy th, RegionPolicy my) {
return Map.of("card|TH", new CardPayment(th), "wallet|TH", new WalletPayment(th),
"card|MY", new CardPayment(my), "wallet|MY", new WalletPayment(my));
}
}
// 新增地区:加一个 RegionPolicy + 一行登记;渠道类不动text
落地时先做“抽维度”而不是“改类名”:
1. 把地区相关的方法从渠道类里摘出成接口(限额、币种、格式、路由);
2. 用参数化/策略把差异移到实现侧,让渠道只保留流程;
3. 组合表由装配层维护,配合一条测试断言“每个 渠道×地区 组合都有实现”。复盘要点:判据是“新增一个维度值需要改动的类数”应从 8 降到 1,并加一条完整性测试:登记的组合数等于渠道数 × 地区数,缺失即启动失败。桥接换来的是两个维度独立扩展,代价是调用链多一层——这笔交换在本案例里由“漏改导致线上失败”的频次买单,值得做。享元、组合、代理在同一个类图上会互相加重,改动前先确认要解决的是“变化维度”还是“访问控制”,见 设计模式在主流框架中的应用。
最小上手
一个可运行的 JDK 动态代理(JDK 11+ 单文件直接运行):
java
import java.lang.reflect.Proxy;
public class ProxyDemo {
interface Greeter { String greet(String name); }
static final class GreeterImpl implements Greeter {
public String greet(String name) { return "hi " + name; }
}
public static void main(String[] args) {
Greeter target = new GreeterImpl();
Greeter proxy = (Greeter) Proxy.newProxyInstance(
Greeter.class.getClassLoader(), new Class<?>[]{Greeter.class},
(p, method, a) -> {
long t0 = System.nanoTime();
try { return method.invoke(target, a); }
finally { System.out.printf("%s took %d ns%n", method.getName(), System.nanoTime() - t0); }
});
if (!"hi ada".equals(proxy.greet("ada"))) throw new AssertionError("proxy broken");
}
}运行:java ProxyDemo.java。输出会包含 greet took ... ns,且不抛 AssertionError。把 Greeter 换成类而不是接口,Proxy.newProxyInstance 会失败——这是 JDK 代理的硬限制,也是 CGLIB 存在的原因。
装饰器的最小形态(片段,省略 import):
java
interface Reader { String read(String key); }
final class FileReader implements Reader { public String read(String key) { return "raw:" + key; } }
final class CachedReader implements Reader {
private final Reader delegate;
private final Map<String, String> cache = new ConcurrentHashMap<>();
CachedReader(Reader delegate) { this.delegate = delegate; }
public String read(String key) { return cache.computeIfAbsent(key, delegate::read); }
}
Reader r = new CachedReader(new FileReader());new CachedReader(new EncryptedReader(new FileReader())) 与 new EncryptedReader(new CachedReader(new FileReader())) 的语义不同:前者缓存的是密文,后者缓存的是明文。装饰顺序是设计的一部分,要写进测试,不能靠调用方记。
生产动作:误用与代价
- Spring AOP 自调用失效:同类中
this.method()不经过代理,@Transactional、@Cacheable不生效。证据:切面断点不进,AopUtils.isAopProxy(bean)为 true 但内部调用无增强。处置:拆到另一个 Bean,或注入自身代理(@Lazy自注入),不要依赖AopContext.currentProxy()这种隐式全局状态。 final/private方法不被 CGLIB 代理:子类无法重写final,代理也拦截不到private。证据:注解在方法上但事务不生效。处置:把注解放到可代理的公开方法,或改用接口 + JDK 代理。- 装饰器顺序错:缓存放在加解密外层,缓存键没包含租户/密钥,导致跨租户读到同一份数据。证据:同 key 不同租户返回相同结果。处置:把租户和密钥纳入缓存键,或用测试固定装饰顺序。
- 适配器吞异常:把第三方的超时/限流异常统一转成「空结果」,上游以为成功。证据:下游错误率上升但业务监控无异常。处置:适配器只做协议转换,异常要保留语义(超时、限流、不可用分别映射),并记录原始错误码。
- 外观变成上帝类:所有跨子系统编排都塞进
XxxFacade,Facade 自身开始写业务规则。证据:Facade 类超过数百行、依赖十个以上子系统。处置:外观只做调用顺序和参数组装,规则回到领域层;按用例拆多个门面。 - 桥接过度设计:只有一个实现维度,却抽出「抽象 + 实现」两套继承树。证据:实现侧只有一个类,抽象侧的方法全是转发。处置:保留一个维度,另一个用组合或配置。
- 组合出现环:
Folder.add(folder)允许把自己或祖先加进去,getSize()无限递归。证据:栈溢出,堆栈在getSize循环。处置:add时检查祖先链,或用不可变树在构建期校验。 - 享元共享了可变状态:把「用户会话」放进共享对象,两个请求互相污染。证据:并发下数据串号。处置:享元只存不可变的内在状态(intrinsic),随调用变化的状态通过参数传入(extrinsic)。
边界
- 只有一个实现、没有替换需求:代理和适配器都只是多一层跳转;直接调用更容易读。
- 调用链超过三层:装饰器嵌套过深时,堆栈和日志都难定位;限制层数或改用责任链显式编排。
- 外观不解决耦合:它只收敛调用入口,子系统之间仍然互相依赖;边界问题要回到上下文划分。
- 桥接要求两个维度真正独立:如果两个维度总是一起变化,拆开只会增加类型。
- 享元只适合大量细粒度对象:对象少、创建不贵时,缓存和查找的开销大于收益。
讲清楚
结构型模式都是「加一层」。代理和目标同接口,控制能不能调,Spring AOP 就是代理加拦截器链;适配器接口不同,负责转换,遗留系统对接和
InputStreamReader都是;装饰器同接口、外部传入目标,用来叠加功能,Java IO 和 MyBatis 缓存是典型,顺序本身就是语义;外观把多个子系统收敛成一个入口;桥接把两个独立变化维度拆开,避免继承爆炸;组合让叶子和容器同接口,递归处理树;享元共享不可变状态,减少对象数量。选型看意图和对象关系,不看类图长得像不像。代理性能不要引用固定倍数,JDK 8 之后反射调用已大幅优化,具体差距用 JMH 在目标 JDK 上测。
初稿 2025-06 | 最后修订 2026-10