3193 字
约 10 分钟
8
S18-J03 · 结构型模式:对象怎么拼

适用读者:能背出"代理、装饰、适配器",但一到选型就开始三选一猜名字的人。
前置: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 次——因为缓存拦在了里面。这个数字本身没意义,但它证明了一件事:代理和装饰可以自由嵌套,嵌套顺序决定了"你观测到的是什么"。

三个必须知道的坑:

  1. 自调用不走代理。同类里 this.methodB() 直接调用,绕过了代理对象,AOP 事务/日志会"莫名失效"。修法:注入自身代理、拆类、或用 AopContext(不推荐)。
  2. final 类/方法代理不了。CGLIB 靠继承,final 直接封死;Spring 会抛 Cannot subclass final class。
  3. 栈深与调试成本。每加一层代理,栈多 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 次 统计的是真实回源数(不含命中)

所以"加个监控"之前必须先回答:你要监控的是请求还是回源? 顺序错了,监控数字会系统性偏差,而且这种偏差不会报错——它只是安静地错。

装饰的三条工程约束:

  1. 每个装饰类只加一种能力(否则又变成"全能类");
  2. 装饰链要有上限(3 层以内可读;超过就考虑合并成一个"管道"对象);
  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. 自测

  1. 你的 Metrics(Cache(Db)) 统计的是请求数还是回源数?如果老板问"缓存命中率",你需要哪个顺序?
  2. 为什么 CGLIB 代理不了 final 类?JDK 动态代理为什么只能代理接口?
  3. @Transactional 在同类方法自调用时为什么失效?三种修法各自代价是什么?
  4. 适配器遇到"转不过去"的输入,应该返回 null、抛异常还是默认值?依据是什么?
  5. 门面里出现业务 if 分支,说明什么?下一步该怎么拆?
  6. 组合模式要求"叶子与容器同接口",这个约束什么时候会反噬?

下一篇

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 文档口径(推断)。

S18-J03 · 结构型模式:对象怎么拼
https://www.clxhxhhr.top/posts/5437/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。