装饰器模式这东西,我最早接触的时候也觉得就那样,无非是包装一下对象嘛。直到后来在项目里被继承结构逼到墙角,才真正体会到这个模式的精妙之处。如果你也在为“怎么优雅地给类加功能”发愁,或者准备面试被问到设计模式,这篇文章可以帮你把它彻底吃透。我尽量用大白话加上完整可跑的代码,把装饰器模式讲明白。
1. 装饰器模式到底解决什么问题
先别急着看定义,咱们先想一个特别常见的场景:你写了一个类,核心功能已经稳定了,现在产品经理过来说要加个新功能,你怎么办?多数人第一反应是加个子类,重写一下方法,再调一下super。这个思路本身没问题,但一旦功能多了,你就会发现事情开始不对劲。
1.1 继承扩展的尴尬与痛点
假设你有一个Coffee类,里面有getCost()和getDescription()两个方法。现在要支持加牛奶、加糖、加奶泡,用继承去做就是:
CoffeeWithMilkCoffeeWithSugarCoffeeWithMilkAndSugarCoffeeWithMilkAndFoam- ...
这还只是3个调料,到了4个、5个的时候,类的数量会指数级膨胀。你想一下,加牛奶和加糖的顺序不同,是不是还得搞两个类?这种组合爆炸的问题,用继承基本无解。更麻烦的是,这些子类之间大量重复代码,改动一个公共逻辑,可能得同步改七八个文件。
1.2 装饰器模式的核心思想
装饰器模式的核心就一句话:用组合代替继承,把功能一层层包上去。它不改变原有对象的内部结构,而是在外面套一层“壳”,这层壳既能保留原对象的能力,又能附加新的行为。你随时可以决定包几层、包什么、按什么顺序包。
这个思路放到生活里特别像外卖包装:核心的餐品不变,外面可以套保温袋,保温袋外面还能套防水袋,再外面套个纸袋。每个袋子都有自己的功能,但又不会改变餐品本身的配方。装饰器模式就是这种“套娃”思想在代码层面的落地。
1.3 开闭原则的最佳实践
设计模式的初衷大部分都指向一个原则:对扩展开放,对修改关闭。装饰器模式是把这个原则体现得最淋漓尽致的一种。你不需要动原有的类,不需要动调用方的代码,只需要新增一个装饰器类就能完成功能扩展。
这意味着什么?意味着你可以在不破坏现有代码稳定性的前提下,持续给系统加新功能。这对线上系统的价值非常大——你不太可能为了加一个日志功能,把线上正在跑的类改一遍,风险太高了。而装饰器模式,新增一个类就够了,老代码一行不动。
2. 装饰器模式的四个角色,缺一不可
先把这个模式的结构看清楚,后面写代码才不会乱。整个模式一共有四个角色,每个角色都有明确的职责分工。
2.1 组件接口(Component)
这是整个模式的“锚点”,定义了一组稳定的业务方法。不管是原始对象还是装饰器,都实现这个接口。它的存在保证了“所有对象都能被一视同仁地对待”,这也是装饰器能层层嵌套的基础。
2.2 具体组件(ConcreteComponent)
这是最核心的原始对象,实现了组件接口,提供了最基础、最原始的功能。它就是那个被一层层包装的“餐品本体”。在装饰器模式里,具体组件不需要知道装饰器的存在,它只管做好自己分内的事。
2.3 抽象装饰器(Decorator)
这个角色很容易被忽略,但它恰恰是整个模式的灵魂。它是一个抽象类,实现了组件接口,并且在内部持有组件接口的引用。这个引用就是被包装的对象。构造的时候传入谁,它包装谁。这个类的核心工作只有两个:把请求转发给持有的组件对象,以及为子类提供“加料”的入口。
2.4 具体装饰器(ConcreteDecorator)
每个具体装饰器都继承抽象装饰器,重写需要增强的方法。在调用父类转发结果的同时,加入自己的额外逻辑。比如“加牛奶”是一个具体装饰器,“加糖”是另一个具体装饰器,它们互不干扰,可以自由组合。
这四个角色配合起来,就形成了一条“责任链”。每个装饰器只关心自己的附加逻辑,其他事情一律丢给持有对象去处理。
3. 实战:从零搭建一个咖啡订单系统
纸上谈兵没什么意思,直接看代码。我以一个咖啡订单系统为例,把装饰器模式从头到尾写一遍。这个例子非常经典,而且特别直观。
3.1 先定义组件接口和基础实现
首先是组件接口,也就是所有咖啡的公共抽象:
public interface Coffee { double getCost(); String getDescription(); }接口很简单,一个算价格,一个描述。接下来是具体组件,也就是最基础的咖啡:
public class Espresso implements Coffee { @Override public double getCost() { return 6.0; } @Override public String getDescription() { return "浓缩咖啡"; } } public class Americano implements Coffee { @Override public double getCost() { return 5.0; } @Override public String getDescription() { return "美式咖啡"; } }到这一步为止,一切都很正常。系统里已经有两种基础咖啡了。现在需求来了:要支持给咖啡加各种配料。
3.2 抽象装饰器:关键中的关键
按照惯例,先创建一个抽象装饰器类。这里最核心的代码就是那个持有Coffee接口引用的构造方法:
public abstract class CoffeeDecorator implements Coffee { protected Coffee coffee; public CoffeeDecorator(Coffee coffee) { this.coffee = coffee; } @Override public double getCost() { return coffee.getCost(); } @Override public String getDescription() { return coffee.getDescription(); } }这里有两个设计细节值得注意。第一,CoffeeDecorator实现了Coffee接口,所以在任何需要Coffee的地方,它都能被当作Coffee用。第二,默认的getCost()和getDescription()直接转发给内部的coffee对象,这保证了不重写也不会破坏原有行为。
3.3 具体装饰器:给咖啡加料
有了抽象装饰器,具体装饰器写起来就非常轻松了。比如加牛奶:
public class MilkDecorator extends CoffeeDecorator { public MilkDecorator(Coffee coffee) { super(coffee); } @Override public double getCost() { return super.getCost() + 2.0; } @Override public String getDescription() { return super.getDescription() + ",加牛奶"; } }加糖:
public class SugarDecorator extends CoffeeDecorator { public SugarDecorator(Coffee coffee) { super(coffee); } @Override public double getCost() { return super.getCost() + 1.0; } @Override public String getDescription() { return super.getDescription() + ",加糖"; } }你没看错,就这么简单。加奶泡、加焦糖、加榛果,全都是同一个套路。这就是装饰器模式最让人舒服的地方:每种配料对应一个类,类之间完全独立,不需要知道彼此的存在。
3.4 组合使用,效果拉满
现在看看实际使用的效果,这才是重点:
public class CoffeeShop { public static void main(String[] args) { Coffee coffee = new Espresso(); System.out.println("基础款:" + coffee.getDescription() + ",价格:" + coffee.getCost()); coffee = new MilkDecorator(coffee); System.out.println("加牛奶后:" + coffee.getDescription() + ",价格:" + coffee.getCost()); coffee = new SugarDecorator(coffee); System.out.println("再加糖后:" + coffee.getDescription() + ",价格:" + coffee.getCost()); coffee = new FoamDecorator(coffee); System.out.println("最后加奶泡:" + coffee.getDescription() + ",价格:" + coffee.getCost()); // 也可以一次性从基础咖啡开始包装 Coffee another = new SugarDecorator(new MilkDecorator(new Americano())); System.out.println("另一杯:" + another.getDescription() + ",价格:" + another.getCost()); } }运行结果:
基础款:浓缩咖啡,价格:6.0 加牛奶后:浓缩咖啡,加牛奶,价格:8.0 再加糖后:浓缩咖啡,加牛奶,加糖,价格:9.0 最后加奶泡:浓缩咖啡,加牛奶,加糖,加奶泡,价格:12.0 另一杯:美式咖啡,加牛奶,加糖,价格:8.0看到没,每次包装之后,返回给我们的依然是一个Coffee对象。这就是装饰器模式“套娃”的精髓——包装完的对象还能继续被包装,想包几层就包几层,想怎么组合就怎么组合。
3.5 为什么说装饰器比继承更优秀
咱们把这个例子和开头的继承方案对比一下。同样的功能,用继承实现需要多少类?不加调料是两种基础咖啡,3种调料下全组合就是8种,5种调料就是32种。而装饰器模式只需要:2个基础咖啡类 + 1个抽象装饰器 + N个调料装饰器类,完全线性增长。
更重要的是,装饰器模式在运行期可以动态组合。今天想喝“美式+牛奶+糖”,明天想喝“浓缩+奶泡”,不需要预先定义好这种组合,临时拼装就行。这种灵活性,继承给不了。
4. 框架与源码中的装饰器模式
其实你早就在用装饰器模式了,只是可能没意识到。很多框架和JDK源码里都有装饰器模式的影子,只是换了一层马甲。
4.1 JDK里的IO流,最经典的示范
Java IO类库是整个JDK中装饰器模式应用得最彻底的地方。看一下这个常见写法:
BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("data.txt")));这个嵌套结构,从里往外一层层看:
FileInputStream是具体组件,负责读文件字节InputStreamReader是装饰器,负责把字节流转换成字符流BufferedReader是装饰器,负责提供缓冲功能
每一层都扩展了上一层的能力,但没有改变文件读取这个最核心的行为。你换一个StringReader给它包装,BufferedReader照样能用——这就是面向接口编程的好处。
还有DataOutputStream、BufferedInputStream、PushbackInputStream,全都是这个套路。学IO流的时候如果能意识到这是装饰器模式,理解起来会顺很多。
4.2 JDK集合类里的低调应用
java.util.Collections里有两个方法,核心类都应用了装饰器思想:
List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>()); List<String> unmodifiableList = Collections.unmodifiableList(new ArrayList<>());这两个方法返回的其实是Collections内部定义的静态内部类,它们实现了List接口,内部持有原始集合的引用。SynchronizedCollection给集合的所有方法都加上了synchronized锁,UnmodifiableCollection把所有修改方法都改成了抛异常。
这就是典型的具体装饰器——原始ArrayList的代码完全没动,但能力被包装成了线程安全的、只读的。注意区分下:Collections.synchronizedList这里通过静态工厂方法返回装饰器,没有显式命名为Decorator,但包装的机制和装饰器模式完全一致。
4.3 Spring等框架中的实际应用
Spring框架里也有很多装饰器模式的影子。比如TransactionAwareCacheDecorator,它包装了一个Cache对象,在操作缓存之前自动检查Spring事务,确保事务存在时把缓存操作延迟到事务提交后才执行。
还有HttpServletRequestWrapper和HttpServletResponseWrapper,这俩更是装饰器模式的教科书级应用。为什么要包装?因为Servlet规范里请求和响应对象都是接口,你没法直接修改实现类的代码。这时候就搞了个HttpServletRequestWrapper,内部持有原始请求对象,默认全部转发。你想加个自定义参数?继承这个Wrapper,重写getParameter()就行。
public class MyRequestWrapper extends HttpServletRequestWrapper { private Map<String, String> extraParams = new HashMap<>(); public MyRequestWrapper(HttpServletRequest request) { super(request); extraParams.put("userId", "12345"); } @Override public String getParameter(String name) { String value = super.getParameter(name); return value != null ? value : extraParams.get(name); } }这里的核心价值是:过滤器链里传的这个包装对象,对业务代码来说是透明的——它们看到的还是一个HttpServletRequest,但行为已经被悄悄改写了。这个例子特别适合用来理解装饰器“透明增强”的特性。
4.4 开源项目里的装饰器运用
再看几个大家熟知的中间件,一旦看懂它们的包装机制,你就能理解它们为什么这么设计。
- MyBatis的
Cache接口:执行器里的二级缓存用了大量的装饰器设计。SynchronizedCache、LoggingCache、SerializedCache、LruCache,它们全都实现了同一个Cache接口,然后一层层互相包装,从基础的PerpetualCache开始,按配置组合出各种带缓存策略的Cache对象。 - Dubbo的
Protocol、Cluster、Invoker:职责链上层层封装,每个装饰器只负责一项能力(比如失败重试、负载均衡、超时控制)。当请求进来,先从最外层经过各种逻辑,最后到达真正干活的实现。 - Netty的
ChannelHandler:虽然不叫装饰器,但管道中每个handler都是对前一个handler的进一步增强或处理,这种链式处理模型和装饰器“逐层附加职责”的思路一脉相承。
5. 这些坑,我替你先踩过了
装饰器模式虽然好用,但真用到生产环境里,有几个坑非常容易踩。下面都是我在实际开发和Code Review时总结出来的血泪经验。
5.1 不要用instanceof去判断具体类型
因为装饰器包装的是接口,运行期的真实类型是装饰器类,不是原始的具体类。如果你代码里有这种判断:
if (coffee instanceof Espresso) { // 做一些特殊处理 }一旦coffee被MilkDecorator包了一层,这个判断就永远为false了。这是个非常隐蔽的bug,排查的时候让人头大。
我的建议是:在使用装饰器模式的情况下,尽量避免依赖具体类型判断。如果你真的需要根据原始类型走不同逻辑,最好在组件接口里加一个类型标识的方法,或者用别的方式绕过这个问题。
5.2 别在装饰器里包this和方法回调
这一点相当诡异但真实存在。假设有个上报统计的框架,你在装饰器里给方法加了统计逻辑:
public class MetricDecorator implements Service { private Service target; public MetricDecorator(Service target) { this.target = target; } @Override public void execute() { long start = System.currentTimeMillis(); target.execute(); System.out.println("耗时: " + (System.currentTimeMillis() - start)); } }问题出在:如果target在执行过程中内部调用了自己的另一个方法,这个调用走的是target内部的方法,不会经过你的装饰器。如果你以为“包装了就万事大吉,所有方法都被增强了”,那就大错特错了。装饰器只对“外部通过引用调用”的方法生效,对对象内部自调用完全无感。
这在Spring AOP里面也有对应问题,叫“自调用失效”。理解了装饰器的这个特性,你就能理解为什么Spring要引入AopContext和ExposeProxy来处理内部调用的场景。
5.3 装饰器类别搞太深,三层四层是上限
理论上装饰器可以无限嵌套,但实际上嵌套太多会带来一连串问题:调试的时候调用栈深得吓人,排查一个异常要翻十几层包装;反射和序列化在某些框架下会出幺蛾子;代码可读性急剧下降,新人根本看不懂那一长串构造函数。
我的个人经验是:装饰器嵌套尽量控制在两三层以内。如果发现“加这个能力”“加那个能力”已经超过三层了,应该停下来考虑下是不是该换一种设计思路,比如策略模式、责任链模式,或者干脆把这些功能内聚到一个大的实现里面。
5.4 装饰器要保证“透明性”
官方给装饰器模式的定义里有个关键词叫transparent,翻译过来是透明性。意思是装饰器对外呈现的类型必须和原始对象一致,调用方感知不到装饰器的存在。
这里有一个注意点:如果装饰器类新增了额外的方法,而调用方强转成装饰器类型去调用这些方法,那代码就变得不“透明”了,整个体系的自由度就会大打折扣。这相当于在装饰器内部留下了对具体实现的依赖,后面换装饰器的时候,调用方代码也得跟着改。我一般会在装饰器里尽量不新增公开方法,只增强接口里已有的方法。实在要新增,也要想清楚是不是用别的方式更合理。
5.5 equals、hashCode等基础方法要慎重处理
装饰器包装后,equals()和hashCode()的行为也会变。默认情况下,MilkDecorator包装的对象和一个独立构造的Espresso,虽然内部依赖同一个对象,但它们的equals()结果是false,因为类都不同。
这在集合场景下特别容易出问题:把一个装饰器对象放到HashSet里,再放一个原始对象进去,你可能期望它们是相等的,但实际会出现重复元素。如果业务上对相等性有要求,建议在装饰器里显式委托给持有对象的equals()和hashCode(),避免这种边界情况。
6. 面试必问:装饰器模式 vs 代理模式
只要你面的是后端或者Java相关岗位,面试官十有八九会问这个问题:装饰器模式和代理模式有什么区别?这两个模式的结构极其相似,都是“包装一个对象”,很多人分不清。
6.1 从目的上区分
这两个模式的根本区别是意图不同:
- 装饰器模式的目标是增强功能。你使用装饰器,是为了给对象添加新的行为,比如给咖啡加糖、给IO加缓冲。
- 代理模式的目标是控制访问。你使用代理,是为了控制对原始对象的访问权,比如延迟加载、访问控制、日志记录。代理不关心也不改变原始对象的功能逻辑。
一句话概括:装饰器是“加东西”,代理是“控制入口”。
6.2 从类型透明度上区分
装饰器对客户端是透明的。客户端完全可以把装饰后的对象当成原始对象来用,它只关心接口方法的能力是不是够用,不在乎谁在内部执行。
代理模式则相反。虽然代理也实现了和目标对象相同的接口,但从客户端的角度看,它面对的是一个“受控的访问点”,有时候代理类还会新增一些管理类的方法,比如检查权限、监控运行状态。对客户端而言,代理更像一个“把关者”,而不是一个“增强版”。
6.3 从创建时机上区分
装饰器通常在需要增强时动态组装,对象已经存在了,然后一层层包上去。代理类的创建则往往发生在原始对象创建之前,或者在访问原始对象的那一刻,由代理决定要创建什么、暴露什么。
6.4 一张表看清所有区别
| 对比维度 | 装饰器模式 | 代理模式 |
|---|---|---|
| 设计意图 | 增强、附加职责 | 控制访问、保护目标 |
| 客户端感知 | 感知不到装饰器,透明使用 | 常能感知代理层存在 |
| 对象关系 | 递归嵌套,层层包装 | 一对一的控制关系 |
| 生命周期 | 原始对象已存在,动态包装 | 可以控制原始对象的创建时机 |
| 典型应用 | IO流、HttpServletRequestWrapper | 懒加载代理、Spring AOP、RPC代理 |
| 扩展方式 | 通过增加新装饰器扩展能力 | 通过增加新代理策略扩展控制逻辑 |
这个表记熟了,面试的时候能稳很多。但要记住,回答的时候别只背表,要结合具体例子展开,证明你是真的懂,而不是背概念。
7. 什么时候用装饰器,什么时候别用
任何设计模式都有它的适用边界,装饰器模式也不例外。用得好是利器,用不好是负担。
7.1 适合使用装饰器模式的场景
总结下来,下面四种情况特别适合用装饰器模式:
- 需要给一组兄弟类动态添加功能。功能种类多且组合灵活,用继承会类爆炸。
- 功能可以叠加且顺序可自由组合。就像咖啡加糖加奶,顺序不同体验不同,但也不需要为每种顺序建一个类。
- 希望增强功能但不想修改原始类代码。原始类是公共组件或第三方库,改不了源码,那就包装它。
- 需要严格遵守开闭原则的场景。系统核心类已经稳定,不希望在加功能时动到核心代码。
7.2 不适合使用装饰器模式的场景
同时,这几种情况我建议你绕道:
- 功能种类固定且完全不会有变化。没必要为了一个固定功能建一堆类,直接用普通继承或者组合就行。
- 装饰层数可能特别多场景。超过三层后,调试和理解成本会大幅上升。
- 对对象类型判断敏感的系统。如依赖
instanceof做逻辑分支、序列化/反序列化要求类型严格准确的场景,装饰器会给你带来不必要麻烦。 - 并发环境下的共享可变对象。多个线程同时调用同一个装饰器对象,装饰器本身是无状态的还好,一旦在装饰器里放入状态字段,可能会出现并发问题。这种场景优先考虑能不能用无状态策略,或者用责任链替代。
7.3 可以和别的模式配合使用
工厂模式和装饰器模式简直是天作之合。实际项目中,我不会在业务代码里到处new装饰器,而是把“拼装装饰器”的逻辑收敛到工厂类里。客户端只需要给一个配置项,工厂负责把装饰器一层层组装好返回。
public class CoffeeFactory { public static Coffee createCoffee(String base, String... toppings) { Coffee coffee; switch (base) { case "espresso": coffee = new Espresso(); break; case "americano": coffee = new Americano(); break; default: throw new IllegalArgumentException("未知咖啡类型: " + base); } for (String topping : toppings) { switch (topping) { case "milk": coffee = new MilkDecorator(coffee); break; case "sugar": coffee = new SugarDecorator(coffee); break; case "foam": coffee = new FoamDecorator(coffee); break; default: throw new IllegalArgumentException("未知配料: " + topping); } } return coffee; } }这样设计之后,客户端只需要一句话:
Coffee coffee = CoffeeFactory.createCoffee("americano", "milk", "sugar");装饰器模式的灵活性和工厂模式的分层装配结合起来,既体现了组合的优势,又降低了使用成本。这也是我们平时项目中比较推荐的组合方式。类似地,如果装饰器的创建过程很复杂,也可以用建造者模式去辅助构建,或者用策略模式动态选择装饰器类型,顶层设计上可以很灵活。
7.4 我的选型判断标准
说实话,我平时写业务代码时,第一个想到的方案通常还不是装饰器模式,而是先考虑这个功能到底要不要抽象。很多时候,几个if-else就能搞定的事情,根本不需要上升到模式层面。
但是一旦出现了两个信号,我就会立刻考虑装饰器模式:第一,同样类型的增强后续还会不断出现;第二,增强的组合方式是灵活且不可预测的。满足这两个条件时,用装饰器模式几乎不会后悔。
8. 写在最后的几点心得
用了这么多年的设计模式,我最大的体会是:不要为了用模式而用模式,模式是工具,不是目的。装饰器模式看着简单,但真正用好它需要考验你对系统边界的理解、对扩展性的预判、以及对代码可读性的把握。
我自己在项目里最常用到装饰器模式的地方,一个是给第三方SDK做能力增强,另一个是在不动核心接口的情况下增加横切逻辑。前者因为改不了源码,只能包装;后者因为核心接口被很多地方引用,不想因为加一个功能就波及所有调用方。这两种场景下,装饰器模式都是性价比极高的方案。
最后再分享一个小技巧:如果你拿不准自己的设计算不算真正的装饰器模式,就检查一下这个类能不能继续被同样类型的类包装。如果你自己都说不准,那大概率还不是一个合格的装饰器。多写、多看、多琢磨,模式这种东西,用着用着就内化了。