适用读者:能背出"代理、装饰、适配器",但一到选型就开始三选一猜名字的人。
前置:S18-J01(选型判据)、S18-J02(创建型)。Java 反射与动态代理基础见 S04。
覆盖旧文:A-2379(代理/装饰/适配器/组合/门面/享元/桥接段)、A-2609(代理 258 点 + 外观 124 点)、A-376(代理 110 点)——合计 312 点。
扩展点:E-S18-004(代理 / 装饰 / 适配器的三分判据)。
上一篇:S18-J02 · 创建型模式:对象怎么来
下一篇:S18-J04 · 行为型模式:谁通知谁、按什么顺序。
1. 一句话结论
七个结构型模式都在做同一件事——用"组合 + 同一接口"替代"继承 + 类爆炸";代理、装饰、适配器的区别不在代码形状,而在意图:代理控制"能不能访问"、装饰增加"访问前后的行为"、适配器把一个接口翻译成另一个接口。
判据只有三问(E-S18-004):
| 问 | 是 → | 否 → |
|---|---|---|
| 调用方用的接口变了吗? | 适配器(换接口) | 继续问 |
| 你是在加能力(可任意叠加)? | 装饰 | 继续问 |
| 你是在控制访问(鉴权、懒加载、远程、计数)? | 代理 | 可能根本不需要结构型 |
三者的代码几乎一模一样:都持有一个同类型(或可转换的)内部对象,都在方法里"做点事再/或者不转发"。所以背代码没有用,背意图才有用。
2. 代理:静态、JDK 动态、CGLIB
| 形态 | 实现方式 | 约束 | 典型用途 |
|---|---|---|---|
| 静态代理 | 手写一个实现类,持有目标对象 | 每个接口都要手写一个 | 编译期就要确定的增强 |
| JDK 动态代理 | Proxy.newProxyInstance + InvocationHandler |
只能代理接口 | Spring AOP(Bean 有接口时) |
| CGLIB / ByteBuddy | 生成子类字节码 | 不能代理 final 类/方法 |
Spring AOP(无接口时) |
interface Repo { String find(String id); }
static final class MetricsProxy implements Repo { // 静态代理:控制访问 + 计数
private final Repo inner; int hits;
MetricsProxy(Repo inner) { this.inner = inner; }
public String find(String id) { hits++; return inner.find(id); }
}
实测(Java 17.0.20,2026-11-06,java OrderLab.java):
[A] Metrics(Cache(Db)): proxyHits=2 dbCalls=1
两次 find("42"):代理被调 2 次,数据库只被调 1 次——因为缓存拦在了里面。这个数字本身没意义,但它证明了一件事:代理和装饰可以自由嵌套,嵌套顺序决定了"你观测到的是什么"。
三个必须知道的坑:
- 自调用不走代理。同类里
this.methodB()直接调用,绕过了代理对象,AOP 事务/日志会"莫名失效"。修法:注入自身代理、拆类、或用 AopContext(不推荐)。 final类/方法代理不了。CGLIB 靠继承,final直接封死;Spring 会抛Cannot subclass final class。- 栈深与调试成本。每加一层代理,栈多 3–5 帧;线上排查时"这个异常到底谁抛的"会变难。这是代理的真实价格。
什么时候用框架注解代替手写代理:只是想要"日志、事务、计时"这类横切能力 → 用 Spring AOP/@Transactional,不要手写代理类;想要"远程调用、懒加载、权限校验"这类与业务语义强绑定的控制 → 手写代理或明确的分层拦截器。
3. 装饰:叠加顺序与调用成本
装饰与代理的唯一结构差别是:装饰通常可叠加多层,代理通常只有一层。但叠加顺序会直接改变行为口径,这一点旧文没讲透,值得单独实测。
实测(同一环境):
[A] Metrics(Cache(Db)): proxyHits=2 dbCalls=1
[B] Cache(Metrics(Db)): proxyHits=1 dbCalls=1 cacheMiss=1
| 组合 | 指标口径 | 含义 |
|---|---|---|
Metrics(Cache(Db)) |
计数 2 次 | 统计的是用户请求数(含缓存命中) |
Cache(Metrics(Db)) |
计数 1 次 | 统计的是真实回源数(不含命中) |
所以"加个监控"之前必须先回答:你要监控的是请求还是回源? 顺序错了,监控数字会系统性偏差,而且这种偏差不会报错——它只是安静地错。
装饰的三条工程约束:
- 每个装饰类只加一种能力(否则又变成"全能类");
- 装饰链要有上限(3 层以内可读;超过就考虑合并成一个"管道"对象);
- 装饰不改变接口语义——如果加了"必须传非空 id"这类约束,它就不再是装饰,而是换了契约。
Java I/O 是教科书级例子:new BufferedReader(new InputStreamReader(new FileInputStream(f))) —— 三层装饰,每层只干一件事(字节→字符→缓冲)。但它也示范了装饰的代价:构造一行嵌套三层,IDE 补全都难读。这就是为什么 NIO 之后的 API 更倾向"配置对象"而不是装饰链。
4. 适配器:类适配与对象适配
| 形态 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 类适配 | class Adapter extends Legacy implements Target |
少一层转发 | 受单继承限制;暴露被适配类的全部方法 |
| 对象适配 | class Adapter implements Target { private Legacy legacy; } |
可适配多个、可组合 | 多一层调用 |
| 默认适配(缺省适配器) | 抽象类给所有方法空实现,子类只覆盖需要的 | 避免实现一堆空方法 | 只适用于接口方法多的场景 |
interface LegacyUser { String getUserName(int uid); } // 外部不可改
static final class LegacyUserAdapter implements LegacyUser {
private final Map<Integer,String> store;
LegacyUserAdapter(Map<Integer,String> store) { this.store = store; }
public String getUserName(int uid) { return store.getOrDefault(uid, "anonymous"); }
}
实测输出:uid=7 -> yg, uid=9 -> anonymous。
注意最后那个 "anonymous":适配器最重要的设计决定不是"怎么转",而是"转不过去时返回什么"。返回 null、抛异常、还是给默认值,决定了故障会在这里被拦住还是扩散到上层。这和 S17 里"防腐层"的职责是一致的。
判据:只有"外部接口不可改 + 我方接口已定"时才用适配器。如果两边都能改,正确做法是统一接口,而不是加一层翻译。
5. 桥接、组合、门面、享元:结构四件套
| 模式 | 一句话 | 真实场景 | 误用信号 |
|---|---|---|---|
| 桥接 Bridge | 把"抽象"与"实现"两个维度的继承,改成"持有" | JDBC Driver / Connection 的双维度 |
只有一个实现维度 → 纯负债 |
| 组合 Composite | 树形结构,叶子与容器同一接口 | 菜单树、组织架构、规则树 | 树只有两层 → 用 List 就够 |
| 门面 Facade | 给一组子系统一个简化入口 | 支付门面(下单+扣款+发券) | 门面里塞业务逻辑 → 变成上帝类 |
| 享元 Flyweight | 共享不可变的内在状态,外部状态传入 | 棋盘棋子、字符样式、连接池 | 状态可变还共享 → 线程安全灾难 |
这四个里最容易误用的是门面:它的初衷是"简化调用",结果常被写成"所有业务都往这里塞"。判据:门面只做编排和参数转换,不写业务规则。一旦门面里出现 if (订单状态 == ...),它就该拆成领域服务(这正是 S18-A03 要讲的重构目标)。
享元在业务系统里其实很少见,因为它的前提是"对象数量巨大且状态可外置"。多数人把它和对象池搞混:池化是为了复用昂贵资源(连接、线程),享元是为了省内存(共享不可变数据)。两者解决的不是同一个问题。
一个真实的管线长什么样(以 Web 请求为例):
Filter(鉴权) → Filter(限流) → Proxy(事务) → Service → Decorator(缓存) → Repository
前两个是责任链(判断放行/拒绝),第三个是代理(控制事务边界),第五个是装饰(加缓存能力)。这条链上有三种模式,但职责界限清楚:谁做判断、谁控访问、谁加能力,一眼可辨。评审时用这句话检查:如果某一段说不清属于哪类,那段大概率被塞了业务逻辑。
5.5 三种"包装"的成本对照
| 方式 | 代码量 | 运行时代价 | 调试代价 | 何时值得 |
|---|---|---|---|---|
| 直接改原类 | 最少 | 零 | 最低 | 只有一个实现、且能力是它的本职 |
| 装饰/代理(手写) | 一个类 | 一次转发 + 一层栈 | 中(断点多一层) | 能力是横切的、可叠加 |
| 框架代理(AOP) | 一个注解 | 一次反射/字节码调用 | 高(自调用/final 失效) | 能力是纯技术的(日志/事务/计时) |
注意第三行:框架代理把"代码量"降到最低,但把"可预测性"也降到最低——出问题时你要同时理解业务代码和代理生成规则。所以技术横切用注解,业务语义用显式代理,别让业务规则藏进切面表达式。
6. 三分判据表(可直接拿去评审)
| 场景描述 | 选 | 为什么不选另外两个 |
|---|---|---|
| 给所有 DAO 方法加执行时间统计 | 代理(或 AOP 切面) | 装饰要逐个手写;适配器不涉及接口转换 |
| 给文件流加缓冲、再加压缩 | 装饰 | 代理不该叠加多层;适配器是换接口 |
把公司老 SDK 的 UserInfo 转成我方 User |
适配器 | 代理/装饰都要求接口不变 |
| 远程调用的本地替身(RPC stub) | 代理 | 装饰不加"远程"语义;适配器是编译期翻译 |
| 权限校验拦截 | 代理 | 装饰加的是能力不是准入 |
| 同一接口要"先缓存、再限流、再重试" | 装饰(管道式) | 代理只做单层控制;三者顺序要显式设计 |
7. 常见误区
| 误区 | 真相 |
|---|---|
| "代理和装饰代码一样,随便叫" | 意图不同:控制访问 vs 增加行为;评审时按意图命名 |
| "装饰越多越灵活" | 每层都是一次调用 + 一段栈;3 层以上可读性骤降 |
| "AOP 一定生效" | 自调用、final、private 方法都不走代理 |
| "适配器里可以写业务逻辑" | 适配器只做协议翻译;业务逻辑属于领域层 |
| "享元 = 对象池" | 享元省内存(共享不可变),池化省创建成本(复用昂贵资源) |
| "门面是架构分层" | 门面是调用便利层,不承担边界职责;边界由限界上下文定义 |
8. 自测
- 你的
Metrics(Cache(Db))统计的是请求数还是回源数?如果老板问"缓存命中率",你需要哪个顺序? - 为什么 CGLIB 代理不了
final类?JDK 动态代理为什么只能代理接口? @Transactional在同类方法自调用时为什么失效?三种修法各自代价是什么?- 适配器遇到"转不过去"的输入,应该返回 null、抛异常还是默认值?依据是什么?
- 门面里出现业务
if分支,说明什么?下一步该怎么拆? - 组合模式要求"叶子与容器同接口",这个约束什么时候会反噬?
下一篇
S18-J04 · 行为型模式:谁通知谁、按什么顺序
结构型管"拼装",行为型管"协作"。下一篇把 11 种行为型压成四组:责任链与模板方法(流程)、策略/状态/命令(行为可变)、观察者与中介者(通知)、迭代器/访问者/备忘录/解释器(遍历与状态快照),并给出三者混用时的判别顺序。
本篇属于系列《S18 · 架构方法与领域建模》。旧文来源:A-2379/A-2609/A-376;扩展点 E-S18-004。代理与装饰的计数为 Java 17.0.20 实测(Metrics(Cache(Db)) vs Cache(Metrics(Db)));Spring AOP 的代理选择规则按 Spring Framework 6.x 文档口径(推断)。