ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

装饰者模式详解:从咖啡加料到JDK IO的层层包装

装饰者模式详解:从咖啡加料到JDK IO的层层包装 咖啡店点单系统大概是每个学设计模式的人都绕不过去的场景而装饰者模式又恰好是这场景里讲得最多的解法。很多人一开始接触装饰者模式总觉得它绕明明一个继承就能加功能为什么非要用一层套一层的包装等到真在项目里写出一个六个调料排列组合出来的继承树才明白什么叫“类爆炸”。这篇文章就把装饰者模式从头到尾拆开讲透包括它的动机、四个核心角色、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比较时通常是不相等的。举个例子你在ListBeverage里存了一个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 缓存叠加淘汰策略之后才慢慢体会到一个很朴素的道理装饰者模式就是理直气壮地把“附加功能”变成一层一层的包装让每个包装只关心自己那点事剩下的一律委托给内部对象。想明白这一点这个模式就真正是你的了。
返回列表