☰
装饰者模式详解:从咖啡加料到JDK IO的层层包装
2026/9/24 23:16:15 网站建设 项目流程

咖啡店点单系统大概是每个学设计模式的人都绕不过去的场景,而装饰者模式又恰好是这场景里讲得最多的解法。很多人一开始接触装饰者模式,总觉得它绕:明明一个继承就能加功能,为什么非要用一层套一层的包装?等到真在项目里写出一个六个调料排列组合出来的继承树,才明白什么叫“类爆炸”。这篇文章就把装饰者模式从头到尾拆开讲透,包括它的动机、四个核心角色、Java代码手写实现、JDK和主流框架里的真实应用、以及与代理模式的区别,还有面试里最常被问到的几个点。不管你是刚开始看23种设计模式的学生、准备软考刷设计模式速记的考生,还是做设计模式大作业的开发者,这篇都能帮你把装饰者模式一次性搞明白。

1. 一个加料咖啡的需求,为什么用继承会越写越痛苦

1.1 用继承“硬刚”需求,类会爆炸成什么样子

先看一个最经典的业务场景。你开了一家咖啡店,系统里有个Beverage(饮料)抽象类,下面有Espresso、DarkRoast、HouseBlend等几个具体咖啡类。每个类都实现cost()方法,返回价格。这时候只卖四种纯咖啡,代码干净得像刚擦过的玻璃。

但现实中的咖啡店不可能只卖纯咖啡。顾客会在里面加摩卡酱、加奶泡、加豆浆、加焦糖糖浆、加双份浓缩……这时候如果你的第一反应是“用继承呗”,那就会发生以下情况:

  • EspressoWithMocha表示浓缩加摩卡;
  • EspressoWithMochaAndWhip表示浓缩加摩卡再加奶泡;
  • DarkRoastWithMochaAndSoyAndWhip表示深焙加摩卡加豆浆加奶泡。

还没写几个类,你就发现组合数是指数级的。假设有 4 种基础咖啡,外加 10 种可选的加料,每一种加料都可能加或不加,那就是4 × 2^10 = 4096个类。4096 个类,就算你复制粘贴到手抽筋也写不完,何况后期还要改价格、改配料描述。

有同学会说,那我不为每个组合建类,只在每个咖啡类里面放几个布尔属性,比如mocha、whip、soy,然后cost()里根据属性叠加价格不就行了?这个方案在料比较少的时候确实能用,但一旦新增一种加料,你就要去修改所有咖啡类的代码——这违反了设计模式里非常重要的开闭原则:对扩展开放,对修改关闭。改一个老类,就可能引入回归 bug。前几天还好好的浓咖啡,你加了一种新糖浆,结果它价格算错了,顾客投诉电话直接打爆。

这就是继承方案的两个根本痛点:一是组合数量爆炸,二是新增需求会迫使你反复修改已有代码。装饰者模式就是专门来解决这两个痛点的。

1.2 装饰者模式的解题思路:把你的“附加功能”变成一层层包装

装饰者模式的思路,说白了就是一句话:不修改原始类,也不为每个组合新建类,而是写一个“包装类”,把原始对象包起来。包装类和原始对象实现的是同一个抽象接口,所以对调用方来说,包装完还是一个Beverage,能继续被下一个包装类再包一层。

这里我拿俄罗斯套娃来类比特别形象。你买了一个礼物,想先包一层彩纸,再系一条丝带,再套一个防水袋。你并没有改变礼物本身,也没有为“彩纸+丝带+防水袋+礼物”专门定制一个新礼物。你只是给礼物外面套了一层、又套一层。每一层都可以独立选择,组合方式完全自由。装饰者模式里的核心概念就是这种“层层包装”。

抽象到代码层面就是:有一个Beverage抽象类,具体的Espresso是原始对象;Mocha、Whip这些装饰者类也继承Beverage,但它们在构造时要接收一个Beverage参数并保存起来。调用cost()时,装饰者先调用被包裹对象的cost(),再加上自己的价格;调用getDescription()时,先拼接被包裹对象的描述,再追加上自己的描述。

这种设计带来的好处非常明显:要加一种新配料,你只需要新增一个装饰者类,所有已有咖啡类一行都不用动。而且同一款咖啡可以任意组合配料,无论是“浓缩+摩卡”还是“浓缩+摩卡+奶泡+豆浆”,都是在运行时动态拼装出来的,不是编译期写死的。

2. 装饰者模式的四个角色,一个都不能少

2.1 Component、ConcreteComponent、Decorator、ConcreteDecorator 各自的职责

教科书上说到装饰者模式,一定会提四个角色。我们结合咖啡例子来逐一认识它们,这样比背概念有意义得多。

抽象组件(Component):这是所有对象的统一抽象。在我们的例子里就是Beverage抽象类。它定义了getDescription()和cost()两个接口,让调用方可以一致地对待“没加料的咖啡”和“加了三层料的咖啡”。它决定了整个装饰者模式的类型基础。

具体组件(ConcreteComponent):这是最初的那个对象,也就是真正干活的类,比如Espresso、DarkRoast。它会被一个或多个装饰者一层层包裹。它实现了抽象组件的所有方法,是整条调用链里最早被调用的那一环,也是价格和描述信息的原始来源。

抽象装饰者(Decorator):这是一个抽象类,它继承自抽象组件,但在内部保存了一个抽象组件的引用。这个引用指向谁?指向“再往里一层”的对象。这个角色很关键,没有它,装饰器和具体组件就失去了统一的类型约束。咖啡例子里我们把它定义成CondimentDecorator,它继承Beverage,并且强制要求子类重新实现getDescription(),因为在咖啡店里,一份加料必须能说清楚自己加的是什么。

具体装饰者(ConcreteDecorator):真正给对象添加新职责的类。Mocha、Whip、Soy、Milk都属于这一类。它们继承抽象装饰者,在构造时接收一个Beverage对象保存下来,然后重写所有方法:在cost()里返回“被包装对象的费用 + 自己的费用”;在getDescription()里返回“被包装对象的描述 + 自己的描述”。

这四者的关系用一句话总结:具体组件是起点,抽象装饰者是桥梁,具体装饰者是轻量附加层,抽象组件是整个模式的类型基石。

2.2 为什么装饰者和被装饰者必须实现同一个接口

这是我见过很多人搞不懂的地方,也是面试时最容易卡壳的一点。装饰者对象和被装饰者对象为什么要实现同一个接口?答案是:为了“类型透明”,为了能够无限嵌套。

想象一下这个过程:new Mocha(espresso)返回的是一个Mocha对象。根据 Java 的多态性质,Mocha继承了CondimentDecorator,而CondimentDecorator继承了Beverage,所以这个Mocha对象本质上也是一个Beverage。这时候你把它传给另一个装饰者new Whip(mochaLatte),Whip的构造函数接收的是一个Beverage参数,于是它顺理成章地收下了这个对象并继续包装。

关键在于,如果装饰者不实现和被装饰者相同的接口,而是搞一个独立的包装类,那么Mocha包装完Espresso之后,产出的对象就不再是Beverage了,那它就不能被下一次包装,装饰者模式的“动态叠加”能力就彻底失效了。所以,同一个抽象类型是这个模式的燃料,没有类型透明,整个模式就跑不动。

这就像快递包装:不管里面包了多少层泡沫膜、纸箱、防水袋,送到驿站时它仍然是一个包裹,可以继续套下一层编织袋。如果每包一层就变成完全不同的另一类东西,那就没法继续套了。

3. Java代码实现:从零手写一个可动态加料的咖啡系统

3.1 先定义抽象组件 Beverage 和抽象装饰者 CondimentDecorator

空谈概念没有意义,直接上代码。我用 Java 写一个极简但完整的例子,你可以原样跑起来看效果。

// 抽象组件 public abstract class Beverage { protected String description = "Unknown Beverage"; public String getDescription() { return description; } public abstract double cost(); }

接着定义抽象装饰者。这里有个细节,CondimentDecorator继承Beverage,但这还不够,它里面要声明一个Beverage类型的成员变量。这个变量会在子类构造时被赋值。

// 抽象装饰者 public abstract class CondimentDecorator extends Beverage { protected Beverage beverage; // 强制子类重新实现 getDescription, // 因为加料的描述不能直接用父类的默认描述 @Override public abstract String getDescription(); }

为什么CondimentDecorator必须重新定义getDescription()为抽象方法?因为一个具体配料(比如摩卡)的描述,必须包含被包裹咖啡的描述,比如“浓缩咖啡,加摩卡”。如果直接用Beverage里那个getDescription(),那所有装饰者都只能返回一个默认的“Unknown Beverage”,就完全错了。

3.2 写两个具体组件:浓缩咖啡和深焙咖啡

具体组件就是最底层的那个对象,它不包裹任何东西。

public class Espresso extends Beverage { public Espresso() { description = "浓缩咖啡"; } @Override public double cost() { return 18.0; } }
public class DarkRoast extends Beverage { public DarkRoast() { description = "深焙咖啡"; } @Override public double cost() { return 20.0; } }

这两杯咖啡都是最基本的价格。任何附加的配料都会在这个价格之上叠加。

3.3 写几个具体装饰者:摩卡、奶泡、豆浆

具体装饰者的写法有三个固定套路:构造函数接收一个Beverage并保存;重写getDescription()拼接描述;重写cost()递归结算费用。

public class Mocha extends CondimentDecorator { public Mocha(Beverage beverage) { this.beverage = beverage; } @Override public String getDescription() { return beverage.getDescription() + ",加摩卡"; } @Override public double cost() { return beverage.cost() + 4.5; } }
public class Whip extends CondimentDecorator { public Whip(Beverage beverage) { this.beverage = beverage; } @Override public String getDescription() { return beverage.getDescription() + ",加奶泡"; } @Override public double cost() { return beverage.cost() + 3.0; } }
public class Soy extends CondimentDecorator { public Soy(Beverage beverage) { this.beverage = beverage; } @Override public String getDescription() { return beverage.getDescription() + ",加豆浆"; } @Override public double cost() { return beverage.cost() + 5.0; } }

这里面的核心是cost()方法的递归调用。你可以看到,装饰者并不单独计算自己的完整价格,而是先问“内部那个对象花了多少钱”,再加上自己这层配料的钱。一层层问下去,最后问到最底层的Espresso.cost(),它返回一个基础价格 18 元,然后每一层再往上累加。这个递归模型是装饰者模式的主心骨,也是理解整个模式的关键。

3.4 客户端点单:动态组合并验证结果

写一个主方法,模拟顾客点单:

public class CoffeeShop { public static void main(String[] args) { // 点一杯浓缩咖啡 Beverage espresso = new Espresso(); System.out.println(espresso.getDescription() + " ¥" + espresso.cost()); // 给浓缩咖啡加一份摩卡、一份奶泡 Beverage mochaWhipEspresso = new Whip(new Mocha(espresso)); System.out.println(mochaWhipEspresso.getDescription() + " ¥" + mochaWhipEspresso.cost()); // 再点一杯深焙咖啡,加双份摩卡、加奶泡、加豆浆 Beverage doubleMochaDarkRoast = new Whip(new Soy(new Mocha(new Mocha(new DarkRoast())))); System.out.println(doubleMochaDarkRoast.getDescription() + " ¥" + doubleMochaDarkRoast.cost()); } }

运行结果如下:

浓缩咖啡 ¥18.0 浓缩咖啡,加摩卡,加奶泡 ¥25.5 深焙咖啡,加摩卡,加摩卡,加奶泡,加豆浆 ¥37.0

第二杯的计算过程是:18 + 4.5(摩卡)+ 3.0(奶泡)= 25.5。第三杯是:20 + 4.5 + 4.5(双份摩卡)+ 5.0(豆浆)+ 3.0(奶泡)= 37.0。双份摩卡就是连续包两层Mocha,不需要专门写一个DoubleMocha类,这也是这套设计优雅的地方。

3.5 写装饰者模式时最容易踩的几个坑

我在实际写代码和帮别人看作业时,至少见过三次以下这些问题,写在这给大家提个醒:

第一,装饰者里 new 了一个具体组件而不是接收外部传入的组件。比如有人把Mocha的构造函数写成this.beverage = new Espresso(),这样写等于把对象写死了,装饰者失去了灵活性。装饰者的意义就在于它可以包裹任意一个Beverage,包括已经被其他装饰者包裹过的对象。所以构造函数必须接收外部传入的Beverage参数。

第二,cost()里没有调用被装饰者的cost()。有人统计价格时直接return 4.5,完全不管内部那一层的价格。这会导致点一杯“浓缩加摩卡”只算出 4.5 元,基础咖啡的价格直接丢了。记住,装饰者只是“附加层”,它的职责是在原对象结果上做加法,而不是替代原对象。

第三,getDescription()的拼接方向反了。描述信息要像剥洋葱一样从里往外拼,所以顺序必须是beverage.getDescription() + ",加XXX",而不是反过来。虽然这不是什么致命错误,但展示给用户时读起来会很别扭。

4. JDK与主流框架里,装饰者模式早就无处不在

4.1 Java IO 的 FilterInputStream 是教科书级别的应用

很多人在学校学装饰者模式时觉得它抽象,但 Java 开发实际上天天都在用。最经典的例子就是java.io包里的输入流体系。

InputStream是抽象组件,FileInputStream是具体组件,FilterInputStream是抽象装饰者,而BufferedInputStream、DataInputStream、PushbackInputStream都是具体装饰者。你写的这段代码就是标准的装饰者用法:

InputStream in = new BufferedInputStream(new FileInputStream("data.txt"));

FileInputStream负责从文件读原始字节;BufferedInputStream为它加上缓冲功能,每次从内存缓冲区读取而不是频繁操作磁盘。BufferedInputStream内部保存了一个InputStream引用,这个引用在构造时通过参数传递进来——和我们的咖啡例子结构一模一样。

再升级一下:

InputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream("data.txt")));

这一层套一层,就是装饰者模式在真实 JDK 里的场景。如果 Java IO 设计者用继承来实现“带缓冲的文件输入流”“带缓冲的字节数组输入流”“带缓冲的网络输入流”,那又是一张巨大的组合爆炸类图。

同理,OutputStream、Reader、Writer系列也都是一个模子刻出来的。所以学 Java IO 实在理解不了过滤器们的关系时,回去复习一遍装饰者模式,很多疑团会直接散开。

4.2 Spring、MyBatis、Servlet 里的装饰者身影

除了 JDK,主流框架里装饰者模式也随处可见。这里举几个特别有代表性的。

Spring 的HttpServletRequestWrapper和HttpServletResponseWrapper。这两个类本质就是装饰者模式中的抽象装饰者。做 Web 开发时,如果你想给请求对象加一个“过滤危险字符”的能力,不要改 Tomcat 的源码,只要继承HttpServletRequestWrapper,把原始HttpServletRequest传进去,然后重写getParameter()方法,在返回值之前做一层 XSS 过滤,再用这个包装后的对象替换原来的 request 传给业务层。整个 Servlet 链条根本感知不到请求被换过,因为包装后的对象仍然是HttpServletRequest类型。这个场景我项目里用过很多次,是装饰者模式在 Java Web 里最实用的落地方式之一。

MyBatis 的缓存装饰器。MyBatis 的Cache接口有PerpetualCache这样的基础实现,也有LruCache、FifoCache、BlockingCache、SynchronizedCache等一堆装饰器。它们都实现了同一个Cache接口,构造时接收另一个Cache对象,在调用getObject()、putObject()等方法时加上自己的策略。你可以任意组合出“带 LRU 淘汰的同步缓存”这种效果,而不用为每一种组合单独写一个类。这就是装饰者模式在中间件里应用的标准姿势。

4.3 装饰者模式与代理模式,怎么快速区分

很多人把装饰者模式和代理模式搞混,因为它们从代码结构上看确实很像:都有一个对象包装另一个对象,都实现了相同的接口,都会在调用目标前做点额外的事。但它们的核心目的完全不同。

装饰者模式的核心是增强功能。它关心的是“给这个对象加新的能力”,加料是它的本分。你用一个装饰者包咖啡,是为了让咖啡变得更好喝、更贵、描述更丰富。调用方通常很清楚自己在使用装饰者拼接功能。

代理模式的核心是控制访问。它关心的是“对目标对象的访问权和管理权”,比如延迟加载、权限控制、远程访问、日志记录。代理不关心给目标对象添加新的业务能力,它关心的是“谁在什么时候能调用目标,调用前后要做什么管控”。

用表格对比一下:

维度装饰者模式代理模式
核心意图动态增强对象功能控制对对象的访问
关注点功能叠加与组合访问权限、延迟、日志、事务等横切管控
调用方是否知情通常知情,明确在拼装功能通常不知情,代理对调用方透明
对象创建方式由调用方手动一层层包装通常由工厂或框架创建
类结构抽象装饰者和具体装饰者围绕统一接口展开代理类和被代理类实现同一接口

另外,如果你面试时听到“JDK 动态代理和装饰者模式的区别”,注意这个说法有点微妙的陷阱。JDK 动态代理是基于反射和接口在运行时动态生成代理类,和装饰者这种编译期写好包装类的结构完全不同。JDK 动态代理更适合做代理模式意义上的横切逻辑,而不是一步一步叠加业务能力。

5. 装饰者模式的优缺点与适用边界

5.1 好处和代价,都得认清楚

装饰者模式最大的好处是符合开闭原则,可以动态地给一个对象添加功能而不修改它的代码。这意味着项目里新增一种加料、一种装饰逻辑,不会影响现有的稳定代码,能有效降低回归风险。第二个好处是组合方式灵活,而且可以做到很细的粒度,不同的装饰者可以自由拼接出各种口味。

但它也有代价。最明显的问题是会产生大量的小类,每个装饰者一个类,类数量比继承方案少很多,但依然不少。想象一下 10 种装饰者的项目,光维护这些类、给它们起名字、组织包结构,就要花不少精力。如果装饰层次过深,比如包了五六层,那么行为跟踪和 debug 会很困难,调用链像洋葱一样一层层拨开,在 IDE 里看堆栈也不那么直观。另外每一层调用的递归叠加,也会带来少量内存和 CPU 的开销,但通常可以忽略不计。

还有一个不太容易被注意到的坑:装饰者模式本质上仍然是在代码里动态拼装行为,所以如果团队里有人看不懂这个模式,很容易用错。我在代码评审里见过不少这样的场面:同事热烈地讨论“这里到底应该用装饰者还是策略”,结果讨论了半天发现这个需求只是加一个简单参数就够,根本不需要引入这么大的设计。设计模式不是越多越好,是刚好够用最好。

5.2 什么时候该用,什么时候别硬用

适用装饰者模式的场景有几个特征:

  • 你需要在不影响其他对象的情况下,以透明、动态的方式给单个对象添加职责;
  • 你希望这些职责可以被自由组合和排列,而且要支持未来随时新增职责;
  • 用继承生成子类扩展功能已经不现实,因为组合数量爆炸。

不适合用装饰者模式的场景也有几个:

  • 只有一个固定额外功能,不需要自由组合,那直接写一个类或简单继承就够;
  • 你的“装饰者”和“被装饰者”之间的行为差异极大,几乎无法抽象成同一个接口;
  • 系统里大量使用对象的精确类型来判断身份、做类型转换,装饰者包装后的对象可能会让你的类型判断逻辑处处失效。

5.3 一个容易被忽略的隐患:包装后的对象“不认原类型”

这一点我觉得值得单独拿出来说。很多同学学着学着就忽略了一个细节——装饰者包装出来的对象,虽然可以当作同一个接口类型使用,但它和一个干干净净的原始对象做equals比较时,通常是不相等的。

举个例子,你在List<Beverage>里存了一个Espresso对象,之后你拿new Mocha(espresso)包装后的对象去查找,期望能找到这个订单,但List.contains()返回false。因为包装后的对象和原始对象是两个不同的引用,而且如果Beverage没有重写equals,那就是对象地址比较,肯定不相等。

如果业务代码里有类似“根据商品对象查找订单明细”的需求,你就要在装饰者里重写equals和hashCode,或者在做这种查找时用别的字段做唯一匹配,而不是直接用整个包装对象去比对。这个坑在真实项目里是能让你排查半天的。

6. 面试与学习建议:把装饰者模式变成你自己的知识体系

6.1 高频面试题与答题要点

设计模式在 Java 相关岗位的面试里是常客,装饰者模式又尤其喜欢被拿来和代理模式做对比。以下几个问题我几乎每次面人都能看到:

“装饰者模式是什么?解决了什么问题?”答题要点:先说它是结构型模式,用于动态给对象增加职责,比继承更灵活;然后用咖啡加料、IO 流过滤器举例说明它解决了继承体系下类爆炸的问题。

“装饰者模式和继承的区别?”答题要点:继承是静态的、编译期确定的,一个类只能有一个父类;装饰者是动态的、运行时拼装的,一个对象可以被多种装饰者无限组合,同时不会污染其他同类型的对象。

“Java IO 中哪些地方用到了装饰者模式?”答题要点:直接说BufferedInputStream、DataInputStream、PushbackInputStream等包装InputStream的实现就是装饰者模式的典型应用,结构上是FilterInputStream持有一个InputStream引用。能把这个关系画清楚,这题基本就过了。

“手写一个装饰者模式,给一个发通知的接口动态加功能。”这是最常见的上机题。我给一个参考思路:

public interface Notifier { void send(String message); }
public class EmailNotifier implements Notifier { @Override public void send(String message) { System.out.println("[邮件] " + message); } }
public abstract class NotifierDecorator implements Notifier { protected Notifier wrapped; public NotifierDecorator(Notifier wrapped) { this.wrapped = wrapped; } @Override public void send(String message) { wrapped.send(message); } }
public class SmsNotifier extends NotifierDecorator { public SmsNotifier(Notifier wrapped) { super(wrapped); } @Override public void send(String message) { super.send(message); // 先让原通知发出 System.out.println("[短信] " + message); // 再叠加短信通知 } }

使用时:

Notifier notifier = new SmsNotifier(new EmailNotifier()); notifier.send("订单已发货");

输出:

[邮件] 订单已发货 [短信] 订单已发货

这个例子能体现一个关键点:你可以一层一层叠加不同的通知渠道,而且新加一个渠道只需要增加一个装饰者类,不需要改动原有发送逻辑。如果你能顺手指出NotifierDecorator抽象装饰者里send()默认调用wrapped.send()的意义,面试官基本就会觉得你真懂装饰者,而不是背了个模板。

6.2 结合记忆口诀,把装饰者放进整体知识框架

现在“23种设计模式记忆口诀”这类内容在网上很火,我也不反对用口诀做辅助记忆。结构型模式里常见的一句话是:“适配器桥接组合,装饰外观享元代理”。你看,装饰者、适配器、代理都在这句话里。但口诀只负责让你回忆出模式名字,真正让你灵活应用的是理解每个模式在解决什么“痛点”。

我个人整理设计模式知识体系时习惯按“问题驱动”来记,而不是按模式名称背。装饰者这个模式对应的最核心问题就是:“如何在不修改类的前提下动态组合功能?”把这个问题和咖啡案例绑定在一起,功能就记得特别牢。

6.3 学装饰者模式最重要的一件事

根据我接触到的项目经验和带新人经验,装饰者模式学到最后,最重要的一件事是多动手包几层、多看几层调用链。纸上谈兵永远不如亲手写一遍new Whip(new Soy(new Mocha(new DarkRoast()))),然后调试看看每个cost()方法是怎么一层层递归回来的。

我自己早期看这个模式也绕了好久,总想着“万一里面有个对象是 null 怎么办”“万一大家包装顺序不同导致同一个订单价格不一致怎么办”。后来在项目里真的给请求对象做 XSS 过滤装饰、给 MyBatis 缓存叠加淘汰策略之后,才慢慢体会到一个很朴素的道理:装饰者模式就是理直气壮地把“附加功能”变成一层一层的包装,让每个包装只关心自己那点事,剩下的一律委托给内部对象。想明白这一点,这个模式就真正是你的了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询