结构型模式
开篇:结构型模式解决的是"组合"问题
上一篇我们学了如何优雅地创建对象,这一篇要解决的问题是:对象创建出来之后,怎样把它们组合成更大、更灵活的结构?
生活中到处都是"组合"的例子:
- 你的手机充电器是 Type-C 接口,但酒店只有 USB-A 的充电线——你需要一个转接头(适配器)
- 你买了一杯奶茶,加珍珠加椰果加奶盖,每加一样就多几块钱——这是装饰器
- 你去银行办业务,不用直接找行长,柜台就能帮你处理——柜台就是代理
- 你用遥控器一键控制空调、电视、灯光,遥控器就是外观(门面)
结构型模式一共有七种,它们的共同特点是:关注怎样组合对象或者类,让系统结构更灵活、更易扩展。由于合成复用原则告诉我们"要尽量用组合代替继承",所以绝大多数结构型模式都是基于组合(而非继承)来实现的。
本篇重点讲解最常用的四种(代理、适配器、装饰器、外观),其余三种(桥接、组合、享元)做简要介绍。
一、代理模式
什么是代理?
生活类比:明星不会自己接商务洽谈,而是交给经纪人。经纪人帮明星筛选合作、谈价格、签合同,明星只管表演。经纪人就是明星的代理——外界以为在和明星打交道,实际上中间隔了一层。
在代码中,代理模式就是给目标对象套一层"壳",在不改动目标对象代码的前提下,在调用前后增加额外逻辑(日志、权限检查、缓存、事务管理等)。
代理模式分为静态代理和动态代理两种。
静态代理
手动编写一个代理类,实现和目标对象相同的接口。
// 接口
public interface UserService {
void save(User user);
}
// 真实对象
public class UserServiceImpl implements UserService {
public void save(User user) {
System.out.println("保存用户: " + user.getName());
}
}
// 代理对象——和真实对象实现同一个接口
public class UserServiceProxy implements UserService {
private UserService target; // 持有真实对象的引用
public UserServiceProxy(UserService target) {
this.target = target;
}
public void save(User user) {
System.out.println("[日志] 开始保存用户...");
target.save(user); // 委托给真实对象执行核心逻辑
System.out.println("[日志] 保存完成");
}
}
// 使用
UserService service = new UserServiceProxy(new UserServiceImpl());
service.save(new User("张三"));
// 输出:[日志] 开始保存用户... → 保存用户: 张三 → [日志] 保存完成问题:每个需要代理的类都要手写一个代理类,代码量巨大,维护困难。
JDK 动态代理
"动态"的意思是:代理类不是你写的,而是程序运行时由 JVM 动态生成的。核心 API 是 java.lang.reflect.Proxy 和 InvocationHandler。
public class DynamicProxyHandler implements InvocationHandler {
private Object target; // 可以代理任意对象
public DynamicProxyHandler(Object target) {
this.target = target;
}
// 获取代理对象的工厂方法
@SuppressWarnings("unchecked")
public static <T> T createProxy(T target) {
return (T) Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 类加载器
target.getClass().getInterfaces(), // 目标对象的接口数组
new DynamicProxyHandler(target) // 调用处理器
);
}
// 目标对象的每个方法调用都会经过这里
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("[代理] 方法调用前: " + method.getName());
Object result = method.invoke(target, args); // 反射调用真实方法
System.out.println("[代理] 方法调用后: " + method.getName());
return result;
}
}
// 使用——一个处理器可以代理任意实现了接口的对象
UserService proxy = DynamicProxyHandler.createProxy(new UserServiceImpl());
proxy.save(new User("张三")); // 自动加上前后日志限制:JDK 动态代理要求目标对象必须实现至少一个接口。如果目标类没有接口怎么办?
CGLIB 动态代理
CGLIB 通过操作字节码,在运行时生成目标类的子类来实现代理。因为是继承关系,所以不需要目标类实现接口。
public class CglibProxy {
@SuppressWarnings("unchecked")
public static <T> T createProxy(T target) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(target.getClass()); // 目标类作为父类
enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> {
System.out.println("[CGLIB] 方法调用前: " + method.getName());
Object result = proxy.invokeSuper(obj, args); // 调用父类(原始)方法
System.out.println("[CGLIB] 方法调用后");
return result;
});
return (T) enhancer.create();
}
}
// 使用——不需要接口
MyService proxy = CglibProxy.createProxy(new MyService());
proxy.doWork();注意:CGLIB 基于继承,所以目标类和目标方法不能是 final 的(final 类不能被继承,final 方法不能被重写)。
JDK 代理 vs CGLIB 代理
| JDK 动态代理 | CGLIB 动态代理 | |
|---|---|---|
| 原理 | 基于接口,运行时生成实现类 | 基于继承,运行时生成子类 |
| 要求 | 目标类必须实现接口 | 目标类和方法不能是 final |
| 性能 | 生成快,调用稍慢 | 生成稍慢,调用快 |
| 依赖 | JDK 内置 | 需要引入 CGLIB 库 |
Spring AOP 中的代理
Spring AOP 底层就是代理模式的最佳实践:
- 目标类实现了接口 → 默认用 JDK 动态代理
- 目标类没有接口 → 自动切换到 CGLIB 代理
- 也可以通过
proxyTargetClass=true强制使用 CGLIB
你在 Spring 中写的 @Transactional、@Cacheable、@Async 等注解,背后都是通过代理在方法调用前后插入额外逻辑。这就是为什么"类内部方法调用不会触发 AOP"——因为内部调用是 this.method(),而不是通过代理对象调用。
二、适配器模式
什么是适配器?
生活类比:你去日本旅游,带了中国的充电器(两扁孔),但日本酒店的插座是两圆孔的。你买了一个转换插头——这就是适配器。它不改变你的充电器,也不改变酒店的插座,只是在中间做了一个"翻译"。
在代码中,适配器模式的作用是:在不修改原有代码的前提下,让两个不兼容的接口能够协同工作。
// 已有的中国播放器
public interface Player {
String play();
}
public class MoviePlayer implements Player {
public String play() {
System.out.println("正在播放中文电影");
return "你好世界";
}
}
// 已有的翻译器
public interface Translator {
String translate(String content);
}
public class JapaneseTranslator implements Translator {
public String translate(String content) {
if ("你好世界".equals(content)) return "こんにちは世界";
return "翻译失败";
}
}
// 适配器:让日本观众能看中国电影
public class JapanesePlayerAdapter implements Player {
private Player target; // 被适配的播放器
private Translator translator = new JapaneseTranslator(); // 翻译能力
public JapanesePlayerAdapter(Player target) {
this.target = target;
}
public String play() {
String content = target.play(); // 原始播放
String translated = translator.translate(content); // 加上翻译
System.out.println("日文字幕: " + translated);
return translated;
}
}核心思想:不修改原有的任何类,新建一个适配器类来连接两个不兼容的接口。
JDK 中的经典适配器:
InputStreamReader:把字节流(InputStream)适配成字符流(Reader)Arrays.asList():把数组适配成List接口Collections.enumeration():把Collection适配成Enumeration
三、装饰器模式
什么是装饰器?
生活类比:你买了一杯原味奶茶(10 元),加珍珠(+3 元),再加奶盖(+5 元)。每次"加料"都不会改变奶茶本身,而是在外面包了一层新的"料"。而且你可以自由组合——只加珍珠、只加奶盖、全加、甚至加两份珍珠。
在代码中,装饰器模式的作用是:动态地给对象增加额外功能,而不修改原有类。
// 抽象组件
public interface Drink {
String getDescription();
double cost();
}
// 具体组件:原味奶茶
public class MilkTea implements Drink {
public String getDescription() { return "原味奶茶"; }
public double cost() { return 10.0; }
}
// 抽象装饰器——实现 Drink 接口,同时持有一个 Drink 对象
public abstract class DrinkDecorator implements Drink {
protected Drink drink;
public DrinkDecorator(Drink drink) { this.drink = drink; }
}
// 具体装饰器:加珍珠
public class PearlDecorator extends DrinkDecorator {
public PearlDecorator(Drink drink) { super(drink); }
public String getDescription() { return drink.getDescription() + " + 珍珠"; }
public double cost() { return drink.cost() + 3.0; }
}
// 具体装饰器:加奶盖
public class CreamDecorator extends DrinkDecorator {
public CreamDecorator(Drink drink) { super(drink); }
public String getDescription() { return drink.getDescription() + " + 奶盖"; }
public double cost() { return drink.cost() + 5.0; }
}
// 使用——像套娃一样层层包装
Drink order = new CreamDecorator(new PearlDecorator(new MilkTea()));
System.out.println(order.getDescription()); // 原味奶茶 + 珍珠 + 奶盖
System.out.println(order.cost()); // 18.0JDK 中最经典的装饰器就是 Java IO 流体系:
// 层层包装:字节流 → 缓冲流 → 对象流
InputStream raw = new FileInputStream("data.bin");
InputStream buffered = new BufferedInputStream(raw); // 加缓冲能力
ObjectInputStream ois = new ObjectInputStream(buffered); // 加对象读取能力BufferedInputStream 给 FileInputStream 加了缓冲功能,但不改变 FileInputStream 本身的任何代码。
装饰器 vs 代理 vs 适配器
这三个模式从结构上看都很像——一个类包着另一个类。区别在于意图不同:
| 装饰器 | 代理 | 适配器 | |
|---|---|---|---|
| 目的 | 增强功能 | 控制访问 | 转换接口 |
| 使用者是否知道 | 知道,主动选择加什么装饰 | 可能不知道代理的存在 | 知道,主动使用 |
| 可以嵌套多层 | 是(核心特性) | 通常只有一层 | 通常只有一层 |
| 接口关系 | 装饰器和被装饰者实现同一接口 | 代理和目标实现同一接口 | 适配器实现目标接口,包装源接口 |
四、外观模式
什么是外观?
生活类比:你想在新城市落户,需要跑公安局办身份证、跑教育局存学籍、跑社保局开社保——来回跑三个地方。后来政府推出了一个"一站式服务大厅",你只需要去大厅提交一次材料,大厅内部帮你协调三个部门。这个大厅就是外观(Facade)。
在代码中,外观模式为一组复杂的子系统提供一个统一的、简化的入口。
// 三个复杂子系统
public class PoliceService {
public void register(String name) {
System.out.println(name + ": 办理身份证");
}
}
public class EduService {
public void archive(String name) {
System.out.println(name + ": 存档学籍");
}
}
public class SocialService {
public void bindInsurance(String name) {
System.out.println(name + ": 开通社保");
}
}
// 外观:一站式服务
public class SettlementFacade {
private PoliceService police = new PoliceService();
private EduService edu = new EduService();
private SocialService social = new SocialService();
// 调用方只需调这一个方法,不用关心内部细节
public void settle(String name) {
police.register(name);
edu.archive(name);
social.bindInsurance(name);
System.out.println(name + ": 落户流程全部完成");
}
}
// 使用
new SettlementFacade().settle("张三");外观模式的价值:
- 简化调用——客户端不需要了解子系统有几个、每个怎么调用
- 解耦——客户端只依赖 Facade,子系统内部的变化不影响客户端
- SLF4J 就是典型的外观模式——它为 Log4j、Logback、JUL 等多种日志框架提供了统一的调用接口
五、其他结构型模式简介
桥接模式
核心思想:将"抽象"和"实现"分离到两个独立的维度,它们可以各自变化而不相互影响。
生活类比:手机有不同品牌(小米、华为),也有不同销售渠道(线上、线下),价格因渠道而异。如果用继承来建模——"小米线上手机""小米线下手机""华为线上手机""华为线下手机"——类的数量是 品牌数 x 渠道数。桥接模式把"品牌"和"渠道"拆成两个独立维度,用组合关系连接,各自可以独立扩展。
组合模式
核心思想:把对象组织成树形结构,让客户端可以用统一的方式操作"叶子节点"和"容器节点"。
生活类比:文件系统——文件夹里面可以放文件,也可以放子文件夹。不管是文件还是文件夹,你都可以执行"删除"操作,都有"名称"和"大小"属性。组合模式让你不用区分是文件还是文件夹,用同一套 API 操作。
享元模式
核心思想:通过共享已有对象来减少内存占用——本质就是对象池。
生活类比:围棋棋子只有黑白两种颜色。棋盘上可能有两百多颗棋子,但颜色(内部状态)只有两种,位置坐标(外部状态)各不相同。你不需要为每颗棋子都创建新对象,只需两个对象(一黑一白)加上位置信息就够了。
JDK 中的经典享元:
String常量池:相同的字符串字面量在内存中只存一份Integer缓存:Integer.valueOf()在 [-128, 127] 范围内返回缓存对象- 连接池、线程池的底层思想也是享元模式的延伸
重要提醒:正因为包装类使用了享元模式,Long、Integer 等不适合做锁对象。看起来不同的两个变量(值都在缓存范围内)实际上指向同一个对象,导致"两把锁其实是同一把锁"。
六、常见面试题精选
Q1:代理模式有哪些应用场景?
代理模式在实际开发中应用非常广泛。Spring AOP 是最典型的动态代理应用——@Transactional 在方法前开启事务、方法后提交或回滚。RPC 框架(如 Dubbo)用代理隐藏了远程调用的网络通信细节,让调用远程方法就像调用本地方法一样。缓存代理可以在查询前先检查缓存,缓存未命中再查数据库。日志代理可以统一记录方法的入参、出参和耗时。
Q2:装饰器模式和代理模式有什么区别?
类图几乎一样,核心区别在于意图。装饰器侧重"增强"——你清楚知道原对象是什么,主动选择给它叠加功能,而且可以多层嵌套。代理侧重"控制"——调用方可能不知道背后有代理层的存在,代理可以决定是否执行真实方法、何时执行。典型的装饰器是 Java IO 流的 BufferedInputStream,典型的代理是 Spring AOP。
Q3:什么是享元模式?为什么包装类不适合做锁?
享元模式通过共享已存在的对象来减少内存使用,本质上就是一个对象池。String 常量池和 Integer 缓存([-128, 127])是最经典的享元实现。正因为如此,包装类不适合做锁对象:Long a = 1L 和 Long b = 1L,虽然是两个变量,但 valueOf() 返回的是同一个缓存对象,所以 synchronized(a) 和 synchronized(b) 锁的其实是同一个对象,会产生意外的互斥。
Q4:适配器模式和外观模式有什么区别?
适配器是为了让两个已有的不兼容接口能一起工作,侧重"接口转换",通常涉及两个接口。外观是为了简化一组复杂子系统的调用,侧重"接口封装",通常面对多个子系统。适配器不改变功能,只做接口适配;外观可能会简化流程、组合多个子系统的调用。
小结
结构型模式的核心理念是用组合代替继承,用包装代替修改:
| 模式 | 核心问题 | 生活类比 | 经典应用 |
|---|---|---|---|
| 代理 | 控制对象访问,增加前后逻辑 | 明星的经纪人 | Spring AOP、Dubbo |
| 适配器 | 让不兼容接口协同工作 | 充电器转换插头 | InputStreamReader |
| 装饰器 | 动态增加对象功能,可嵌套 | 奶茶加珍珠加奶盖 | Java IO 流 |
| 外观 | 为复杂子系统提供统一入口 | 一站式服务大厅 | SLF4J |
| 桥接 | 分离多个变化维度 | 手机品牌 x 销售渠道 | JDBC 驱动体系 |
| 组合 | 树形结构统一操作 | 文件和文件夹 | 菜单树、组织架构 |
| 享元 | 共享对象节省内存 | 黑白棋子只需两个 | String 常量池 |
下一篇我们进入行为型模式——当多个对象需要协作完成一件事时,怎样设计它们之间的分工和通信。