1. 从一次“类爆炸”的改造说起:装饰者模式到底解决了什么
如果你写代码超过两年,大概率经历过这种场景:产品经理提了一个需求,要给现有的消息推送服务增加“加密传输”能力。你一看,好办,继承一个子类就完事了。没过一周,又要求支持“压缩传输”。行,再写个子类。再往后,需求变成“既要压缩又要加密”“先加密再压缩”“带签名的加密压缩”……你会发现继承体系开始失控。4种独立能力排列组合,理论上你要维护几十个子类才能覆盖所有情况。这就是设计模式里说的“类爆炸”。
我第一次意识到装饰者模式的价值,就是在处理一个类似的报表导出功能时。当时的导出器有PDF、Excel两种基础格式,要叠加水印、加密、签名、压缩四类附加能力。如果用继承硬写,组合数量是2乘以2的四次方,也就是32个类。而引入装饰者模式之后,基础组件2个,装饰器4个,一共6个类就解决了全部排列组合问题,而且以后增加一种新能力,只需要新增一个装饰器类,完全不碰已有代码。
装饰者模式的核心思想一句话就能讲明白:不修改原有代码,不通过继承扩展子类,而是用“包装”的方式,把新职责动态地附加到对象上。这种扩展方式比继承更灵活,因为它是在运行时组合的,你可以在程序运行过程中像搭积木一样,决定到底给对象叠加哪些能力。
这个模式非常高频。你要是做Java开发,天天用的BufferedReader、FileInputStream这一整套IO流,就是装饰者模式的标准教科书。要是做Android开发,ContextWrapper体系也赫然是装饰者的忠实拥趸。后面我会详细拆这两个源码案例。
这篇内容我准备这样讲:先用最直观的代码给你看装饰者的四个角色,再走一个完整实战案例,然后重点聊聊调用链执行顺序、与相似模式的边界,最后从JDK和Android源码里找实证,再分享几个实战中容易踩的坑。通篇会用Java写示例,但思路是通用的,C++、Python、TypeScript都能直接平移。
2. 四个角色与一个最小骨架:先敲明白装饰者长什么样
很多教程上来就贴UML图,看得人昏昏欲睡。我换个方式,先讲清楚四个角色,再带你写一个能跑的最小例子。
2.1 四个角色分别是谁、各自干什么
装饰者模式涉及4个角色,我用人话翻译一遍:
- 抽象组件(Component):定义业务接口。它是整个继承和包装体系的公共父类型,让装饰器和被装饰对象能互相替换。
- 具体组件(ConcreteComponent):被装饰的原始对象,实现核心业务逻辑。它就是功能扩展的“地基”。
- 抽象装饰器(Decorator):持有一个抽象组件的引用,同时继承/实现抽象组件。这个角色是承上启下的关键,它保证了装饰器的嵌套能力。
- 具体装饰器(ConcreteDecorator):在调用被包装对象方法的前后,附加额外的职责。
关键点在于:装饰器和被装饰对象都实现了同一个接口。这保证了无论外面包了多少层,外层拿到的仍然是一个“组件”,可以继续被包装,也可以直接调用。
2.2 用20行代码搭一个最小的装饰者骨架
假设我们有一个TextProcessor接口,负责处理文本,然后有一个基础实现,再加一个给文本加HTML标签的装饰器。完整代码如下:
// 1. 抽象组件:定义业务接口 public interface TextProcessor { String process(String text); } // 2. 具体组件:基础实现,原样返回文本 public class PlainTextProcessor implements TextProcessor { @Override public String process(String text) { return text; } } // 3. 抽象装饰器:核心是持有TextProcessor引用 public abstract class TextDecorator implements TextProcessor { protected TextProcessor wrapped; public TextDecorator(TextProcessor wrapped) { this.wrapped = wrapped; } @Override public String process(String text) { return wrapped.process(text); } } // 4. 具体装饰器:给文本包一层HTML标签 public class HtmlTagDecorator extends TextDecorator { public HtmlTagDecorator(TextProcessor wrapped) { super(wrapped); } @Override public String process(String text) { String result = wrapped.process(text); return "<html>" + result + "</html>"; } }用起来是这个效果:
TextProcessor processor = new HtmlTagDecorator(new PlainTextProcessor()); System.out.println(processor.process("hello")); // 输出: <html>hello</html>你再写一个UpperCaserDecorator,就可以这样嵌套:
TextProcessor processor = new UpperCaserDecorator(new HtmlTagDecorator(new PlainTextProcessor())); System.out.println(processor.process("hello")); // 输出: <html>HELLO</html>注意观察执行顺序:外层装饰器的process方法先拿到wrapped.process(text)的内层结果,再附加自己的逻辑。
2.3 为什么装饰者必须和被装饰对象实现同一个接口
这是装饰者模式最容易忽略、也最核心的设计约束。很多初学者想不通:装饰器为什么不能单独定义一个类,内部持有组件引用,然后想加什么方法就加什么方法?
原因是:如果装饰器不实现组件的抽象接口,它就无法被继续装饰,也无法在原有组件类型被使用的地方无缝替换。装饰者模式最大的优势是“可以无限嵌套”,这种能力完全建立在“所有参与者都是同一抽象类型”之上。一旦装饰器本身变成了一个独立类型,你就没法给装饰器再套装饰器了,动态叠加的能力当场消失。
另一个原因是面向对象的多态。调用方只需要依赖抽象组件TextProcessor,完全不用关心这个对象内部被包装了几层。如果你在业务代码里到处写PlainTextProcessor这种具体类型,那就废掉了装饰者模式一大半功力。
注意:有不少人把静态代理和装饰者模式混为一谈,二者的代码结构确实很像,区别在于意图。代理模式侧重于控制访问、延迟加载、权限控制,装饰者模式侧重于增强功能。这个边界我放在第5节详细展开。
3. 实战:用装饰者模式给“奶茶加料”写一套自动计价系统
理论说再多,不如来一个能跑通的完整案例。这次我用一个大家都能秒懂的场景:奶茶店点单系统。一杯基础奶茶可以加珍珠、加椰果、加奶盖、加布丁,每种料价格不同,还可以无限叠加。请看这个需求如何用装饰者模式优雅实现。
3.1 需求抽象与接口定义
每杯饮品都有一个描述和一个价格,核心行为就两个。于是抽象组件设计如下:
public interface MilkTea { String getDescription(); double cost(); }这个接口就是整个体系的公共父类型。基础饮品实现它,所有加料装饰器也实现它。后边你会看到,无论嵌套多少层,MilkTea这个类型始终是唯一需要对外暴露的门面。
3.2 实现基础饮品组件
这里做两个基础单品:原味奶茶和红豆奶茶。
public class OriginalMilkTea implements MilkTea { @Override public String getDescription() { return "原味奶茶"; } @Override public double cost() { return 10.0; } } public class RedBeanMilkTea implements MilkTea { @Override public String getDescription() { return "红豆奶茶"; } @Override public double cost() { return 13.0; } }注意,这里我刻意没有把“红豆”做成装饰器。因为红豆是基础配方的一部分,不是可选项。判断一个能力到底属于“基础组件”还是“装饰器”,标准很简单:没有它,这个产品还算不算这个品类的产品?没有加料的原味奶茶,依然是奶茶;但没有红豆的红豆奶茶,就是另一杯原味奶茶。前者用装饰器,后者就必须进基础组件。
3.3 实现抽象装饰器
现在先定义顶层装饰器。它持有一个MilkTea引用,并且将接口方法的调用透传给内层对象:
public abstract class ToppingDecorator implements MilkTea { protected MilkTea milkTea; public ToppingDecorator(MilkTea milkTea) { this.milkTea = milkTea; } @Override public String getDescription() { return milkTea.getDescription(); } @Override public double cost() { return milkTea.cost(); } }很多初学者会问:既然ToppingDecorator只是透传,为什么不能把milkTea改成public直接暴露字段?这里有一个隐含设计:protected字段搭配构造器注入,可以限制子类必须通过构造传入依赖,避免出现一个没有目标对象的“悬浮装饰器”。这是面向对象封装性的基本素养。
3.4 实现具体装饰器:珍珠、椰果、奶盖
重点来了。以珍珠为例,它的逻辑是在内层描述后面追加描述,在内层价格基础上加价:
public class PearlDecorator extends ToppingDecorator { public PearlDecorator(MilkTea milkTea) { super(milkTea); } @Override public String getDescription() { return milkTea.getDescription() + " + 珍珠"; } @Override public double cost() { return milkTea.cost() + 2.0; } } public class CoconutDecorator extends ToppingDecorator { public CoconutDecorator(MilkTea milkTea) { super(milkTea); } @Override public String getDescription() { return milkTea.getDescription() + " + 椰果"; } @Override public double cost() { return milkTea.cost() + 3.0; } } public class CheeseFoamDecorator extends ToppingDecorator { public CheeseFoamDecorator(MilkTea milkTea) { super(milkTea); } @Override public String getDescription() { return milkTea.getDescription() + " + 奶盖"; } @Override public double cost() { return milkTea.cost() + 5.0; } }如果每个装饰器的getDescription和cost都要自己拼接,你会发现重复代码不少。这里可以做一个优化:把字符串拼接和价格叠加提升到抽象层。比如ToppingDecorator增加抽象方法toppingName()和toppingPrice(),子类只需要返回自己的加料名和价格。这个优化不是装饰者模式本身要求的,但却是实战中减少样板代码的有效手段,类似模板方法模式思想在装饰器内部的局部应用。
3.5 动态组合验证
到了点单环节,你会瞬间体会到装饰者模式的魅力:
public class MilkTeaShop { public static void main(String[] args) { // 一杯原味奶茶加珍珠 MilkTea tea1 = new PearlDecorator(new OriginalMilkTea()); System.out.println(tea1.getDescription() + ",价格:" + tea1.cost()); // 原味奶茶 + 珍珠,价格:12.0 // 一杯原味奶茶加珍珠,再加椰果 MilkTea tea2 = new CoconutDecorator( new PearlDecorator(new OriginalMilkTea())); System.out.println(tea2.getDescription() + ",价格:" + tea2.cost()); // 原味奶茶 + 珍珠 + 椰果,价格:15.0 // 一杯红豆奶茶什么都不加 MilkTea tea3 = new RedBeanMilkTea(); System.out.println(tea3.getDescription() + ",价格:" + tea3.cost()); // 红豆奶茶,价格:13.0 // 一杯原味奶茶加珍珠、椰果、奶盖、布丁 MilkTea tea4 = new CheeseFoamDecorator( new CoconutDecorator(new PearlDecorator(new OriginalMilkTea()))); System.out.println(tea4.getDescription() + ",价格:" + tea4.cost()); // 原味奶茶 + 珍珠 + 椰果 + 奶盖,价格:20.0 } }所有排列组合,都不需要为“原味加珍珠加椰果”这样的组合单独建一个类。杯子还是那个杯子,你在运行时决定往里面加什么料。这比继承方案要清爽太多——用继承实现时,每加一种新料,你都要考虑现有类的组合关系,系统内类数量呈指数级上升。
新增一款配料,比如“芋圆”,只需要这样:
public class TaroBallDecorator extends ToppingDecorator { public TaroBallDecorator(MilkTea milkTea) { super(milkTea); } @Override public String getDescription() { return milkTea.getDescription() + " + 芋圆"; } @Override public double cost() { return milkTea.cost() + 4.0; } }不需要动OriginalMilkTea,不需要动RedBeanMilkTea,不需要动任何一个其他装饰器。这是典型的对修改关闭、对扩展开放。
4. 层层装饰的执行顺序:从递归视角理解调用链
这一节值得单独拎出来讲。我见过很多代码,看懂了装饰者的类结构,但一到“多重装饰后的行为顺序”就犯迷糊,调试起来一头雾水。
4.1 装饰器的嵌套构造与递归调用过程
以new CheeseFoamDecorator(new CoconutDecorator(new OriginalMilkTea()))为例。构造过程由内到外:先创建OriginalMilkTea,再把它包进CoconutDecorator,最后将整个对象包进CheeseFoamDecorator。
当你调用最外层对象的cost()方法时,发生了什么呢?用递归视角拆解:
CheeseFoamDecorator.cost()执行,先调用milkTea.cost(),即CoconutDecorator.cost();CoconutDecorator.cost()执行,先调用milkTea.cost(),即OriginalMilkTea.cost();OriginalMilkTea.cost()直接返回10.0;- 回到第2步,
CoconutDecorator.cost()拿到10.0,加上自己的3.0,返回13.0; - 回到第1步,
CheeseFoamDecorator.cost()拿到13.0,加上自己的5.0,返回18.0。
整个调用过程像洋葱一样,一层剥开,露出下一层,直到最内层返回结果,再一层层往回穿。理解了这个递归结构,你就能准确预测任何多层装饰下的行为。
我建议你在IDE里给接口方法打断点,观察调用栈的变化。第一次跟踪时你会非常直观地看到:调用栈是从最外层一路压进最内层的,然后逐层弹栈返回。这个观察经验对理解装饰器的行为模型帮助巨大。
4.2 设计装饰逻辑时的顺序考究
既然装饰器是层层包裹的,那么不同层的装饰逻辑就有“先后顺序”问题。同样三个装饰器A、B、C,以A(B(C(obj)))和C(B(A(obj)))两种方式嵌套,执行效果可能完全不同。
举个真实项目的例子。数据传输管道有三个装饰器:加密、压缩、日志。如果按new EncryptDecorator(new CompressDecorator(new LogDecorator(...)))的顺序包装,那么数据流向是:进入加密装饰器时先加密,再交给压缩装饰器压缩,最后落到日志装饰器记录。从调用方的视角看,日志记录的内容是外层加密后的密文还是压缩前的原始数据,取决于整体管线如何组合。
常见的误区是:在不知道各层职责边界的情况下随意堆叠装饰器,导致最终行为与预期不符。举个例子,给奶茶加料的顺序对价格没有影响——加法交换律保证了结果一致。但描述字符串的顺序会变化,如果业务上需要“珍珠在前、椰果在后”,你就必须按特定顺序构造。
这里我的建议是:
- 存在前置/后置逻辑的装饰器,要明确它在整条链里的位置,最好在命名上体现出来,比如
PreEncryptDecorator、PostCompressDecorator; - 同一层级的装饰器,如果叠加顺序无所谓,要保证代码可读性,按业务习惯固定一种顺序,不要今天这个顺序、明天那个顺序;
- 构造层次较深时,考虑用工厂或者Builder封装组合逻辑,避免业务代码变成一条超长的构造链。
4.3 装饰器对接口方法透传的注意点
抽象装饰器里,getDescription()和cost()是逐层透传之后再做自己的增强。但如果某个组件有更多方法,比如增加了一个getCalories()(卡路里)方法,抽象装饰器要一并透传,否则具体装饰器会丢失这部分职责。
一个实战经验:接口越膨胀,装饰器透传越痛苦。这也从侧面反映出装饰者模式的适用边界——它最适合接口方法少且稳定的场景。如果接口有十几个方法,定义一个装饰器类要写十几遍透传代码,这时代价会变得很大。
5. 装饰者模式为什么总被拿来和继承、代理、适配器比较
面试里关于装饰者模式的高频问题,就是让你区分它与代理模式、适配器模式之间的差别。把它们放在一起对比,不是为了考概念,而是帮助你判断在真实需求里到底该选谁。
5.1 组合优先于继承:动态与静态的本质差异
继承是静态的、编译期确定的。用继承扩展功能,你在写代码时就必须知道需要哪些组合,运行时无法改变。装饰者是动态的、运行期确定。你可以在程序跑起来之后根据条件决定给对象套哪几层装饰器。
用一个类比:继承是一栋楼开盘前就定好了户型,你在图纸上选了“三室一厅+书房”这个子类;装饰者是毛坯房交付后,你想刷墙就请刷墙队,想铺地板就请地板队,今天先刷墙明天再铺地板都行,而且两队还可以同时进场。
设计模式第一原则“组合优于继承”,在装饰者模式里体现得淋漓尽致。组合带来的可插拔性,让系统扩展的粒度从“类级别”降到了“行为级别”。
代价是什么?继承体系类型清晰,每个子类都是一个明确的类型;而装饰者体系下,对象的具体类型被层层包装掩盖了。后面第7节我会讲它在实战中带来的类型识别问题。
5.2 装饰者与代理模式:结构相似,意图不同
代理模式和装饰者模式的Java代码结构非常像,都持有目标对象引用,都实现目标接口,都在外层附加逻辑。它们可以做到几乎相同的代码形态。
关键区别在意图上:
- 代理模式的核心是控制访问。比如你不想让调用方直接触达真实对象(远程代理、安全代理、延迟加载代理),代理的主要职责是“管理对象的生命周期与访问权限”。
- 装饰者模式的核心是增强能力。它不管对象能不能被访问,只负责给对象添加责任。
网上有句话总结得很到位:代理模式说“我要控制你”,装饰者模式说“我要包着你变得更强”。面试时如果能说出这个区别,顺带补充一句“结构相似不代表可以互换,应用场景取决于意图”,这是比较加分的回答。
5.3 装饰者与适配器模式:接口变换与功能叠加的区别
适配器模式做的事情是“接口转换”。比如你有Type-C接口,但设备只接受USB-A,你需要一个转接头,把Type-C变成USB-A。源代码被适配后,对外暴露出的是另一套接口。
装饰者模式不改变接口,它保持原有接口不变,只是在原有接口行为执行前后增加职责。装饰者不做接口转换,只做行为叠加。
这个差异直接影响了使用方式:适配器通常在系统集成阶段出现,用来把外部系统接口“翻译”成本系统需要的接口;装饰者通常在业务扩展阶段出现,用来在不修改源码的前提下扩展能力。
| 对比维度 | 装饰者模式 | 代理模式 | 适配器模式 |
|---|---|---|---|
| 目标 | 动态增强功能,叠加职责 | 控制访问,管理对象生命周期 | 将接口转换成客户端需要的接口 |
| 接口 | 保持不变 | 通常保持不变 | 改变接口 |
| 嵌套 | 支持多层嵌套 | 一般单层 | 一般单层 |
| 关注点 | 对象内部行为的扩展 | 对象访问的外部控制 | 对象接口的匹配 |
| 实例 | BufferedReader, ContextWrapper | 延迟加载代理、远程代理 | InputStreamReader, Slf4j门面实现 |
5.4 与继承方案的成本对比
还有一个常见的思考角度:装饰者模式到底比继承好在哪?我们算一笔代码账。
假设基础组件是MilkTea,加料有珍珠、椰果、奶盖三种。用继承实现的话:
- 一个基础奶茶类;
- 三个单料子类:珍珠奶茶、椰果奶茶、奶盖奶茶;
- 三个双料子类:珍珠椰果奶茶、珍珠奶盖奶茶、椰果奶盖奶茶;
- 一个三料子类;
这已经是8个类了。加料种类越往后增长,组合数越多。同时类与类之间的共性会越来越难抽取,维护成本会呈指数级上升,新建一个类还可能影响已有类的逻辑。
用装饰者实现呢?1个抽象组件接口+2个基础组件+1个抽象装饰器+3个具体装饰器,总共7个类,已经覆盖了所有组合。再加一种新料,只需要新增1个具体装饰器类。随着系统规模增大,装饰者的优势越来越明显。
6. 真实世界里的装饰者影子:JDK与Android源码的底层证据
聊设计模式不能只停留在demo层面。源码里的应用才是检验你是否真正理解模式的试金石。我会挑两个最经典的实例:JDK的IO流和Android的ContextWrapper。
6.1 JDK IO流:一个教科书级别的装饰者结构
Java开发者几乎每天都在用new BufferedReader(new FileReader("test.txt"))。这套IO类结构堪称装饰者模式最普及的标本。
Reader是抽象组件,定义了读字符的接口;FileReader、StringReader、CharArrayReader这些是具体组件;BufferedReader、LineNumberReader是具体装饰器——它们把“带缓冲”“带行号”的能力动态加到任意Reader之上。
关键在于:FilterReader这个类是抽象装饰器,它内部持有一个Reader引用,而BufferedReader并不是直接继承FilterReader,但整体结构与装饰者是完全一致的。
你还能看到更随意的组合:new BufferedReader(new InputStreamReader(new FileInputStream("a.txt"), StandardCharsets.UTF_8))。这一行里嵌套了文件读取、字节转字符、缓冲三层装饰。每一层都解决一类问题,却彼此完全解耦。如果没有装饰者模式,Java标准库大概要为每种组合都搞一个专属类,那IO体系早就爆炸了。
通常大家不会觉得BufferedReader很难用,因为依赖的抽象类型始终是Reader。这就是装饰者的设计魔力:你拿到的是一个“看起来像Reader、用起来像Reader,但行为已经被加强”的对象。
6.2 Android的ContextWrapper:装饰者的安卓身影
做过Android开发的人都知道Context是一个抽象类,ContextImpl是真真正正干活的底层实现,而ContextWrapper则是装饰者的抽象角色。
ContextWrapper内部持有Context mBase,所有方法都通过mBase透传。开发者经常接触的Activity、Service、Application,本质上都继承自ContextWrapper,在特定场景覆写了部分行为。比如ContextThemeWrapper就给ContextWrapper包装了主题信息。
这种做法的价值在于:系统可以在不修改ContextImpl的情况下,通过不同的ContextWrapper子类给应用提供不同“视角”的上下文能力。比如你拿到的Context如果是Application的,它和应用进程生命周期绑定;如果是Activity的,它可能额外携带界面主题等信息。
当面试官问“Android里哪里用到了装饰者模式”,ContextWrapper是绝对不会错的答案。这比单纯背概念深刻得多,也从侧面说明装饰者是Android系统层面的基础架构选择之一。
6.3 微信小程序与前端生态中的装饰者痕迹
不局限于Java,装饰者思想在前端领域也广泛存在。比如Express框架的中间件机制、Koa的洋葱模型、Redux的middleware,本质上都可以理解为装饰者模式的变体:每个中间件包住下一个中间件,请求贯穿整条链。Java Web开发常用的Filter过滤器,其职责链模型也与装饰者互为表里。
理解设计模式这件事,最忌讳的就是“背类图”。当你发现同一个结构在不同语言、不同框架里反复出现,就开始真正形成架构直觉了。
7. 实战中必须避免的坑:类型丢失、过度包装与调试噩梦
模式虽好,用错了场景、用多了程度,照样会让代码变成灾难。这一节总结我自己代码评审中经常看到的几个真实反例。
7.1 装饰后类型丢失,无法调用具体类型的特色方法
这是使用装饰者模式最常踩的坑。
比如MilkTea接口定义了getDescription()与cost(),而RedBeanMilkTea自己还有一个isGlutenFree()方法,调用方传入的原始类型是RedBeanMilkTea,代码想调用它特有的方法:
MilkTea tea = new PearlDecorator(new RedBeanMilkTea()); tea.isGlutenFree(); // 编译错误,MilkTea接口中没有这个方法这是装饰者模式天然的代价:因为所有层次都抽象为MilkTea,你一旦给RedBeanMilkTea套上装饰器,就会丢失其具体子类的特有方法。如果业务上这些方法不可或缺,要么把那些方法也提升到接口里,要么就需要从设计层面避免“对具体子类做装饰后再调用其特有方法”这种用法。
选择装饰者模式前,需要思考:你的组件接口是否覆盖了未来可能需要的关键能力?如果接口预留不足,装饰器会掩盖子类的多样性。所以接口设计在这个模式里尤为重要。
7.2 装饰顺序产生的逻辑差异与歧义
前面说过,装饰顺序可能显著影响结果。在写代码时,如果同一套装饰器在不同地方以不同顺序组装,未来维护的人会非常头疼。尤其在业务含义上,有些操作天然有先后逻辑。
举例,加密和压缩。如果先加密再压缩,压缩模块面对的是密文,压缩率通常很差;如果先压缩再加密,压缩模块面对的是明文,压缩率高,最终密文也没泄露明文信息。两种顺序都合理,但结果完全不同。
建议在核心入口用一个工厂方法或者配置中心统一管理装饰顺序,禁止业务代码随意嵌套。这样既能保留装饰者的灵活,又能避免因顺序不一致引发的线上事故。
7.3 无节制的多层装饰导致调试困难
装饰者很灵活,但是灵活到了滥用程度,调试体验会断崖式下降。我曾经在一个遗留系统里见过包装了6层的对象,光是想搞清楚一次方法调用最终执行到哪个具体组件,就得在IDE里踩断点踩好几个来回。
还有一种变体问题:同一个装饰器被重复套了两次。比如误把日志装饰器套了两遍,日志就会打印两遍;缓冲装饰器套两层,可能不会产生功能问题,但白白浪费内存。排查这些问题,通常要靠肉眼逐层检查构造链,效率很低。
我的经验是:
- 装饰层数尽量控制在3层以内。超过这个范围就要反思是不是职责拆得过细了。
- 给装饰器起名时把职责讲清楚。
BufferedMilkTea这种名字谁也看不懂,最好命名为PearlDecorator这种自解释风格。 - 必要时提供一个静态工厂或Builder来组织装配过程,业务方只需要传参表示“要珍珠、椰果、不要奶盖”,由内部负责构造装饰链。一旦出现问题,只需要排查这一个地方。
7.4 与静态代理的误用:在一个项目里同时出现大量相似的包装类
很多团队会把装饰者和代理混着用。比如给Service层做日志、做权限、做事务,这类需求如果用装饰者模式去实现,每个业务Service都要提供一个对应装饰器,代码量不小,而且容易和Spring AOP这类已有方案重叠。
判断用装饰者还是用代理,还要看扩展方向:
- 如果是给同一个业务行为无限叠加细节能力,那装饰者是合理的;
- 如果是给所有业务方法统一加交关注逻辑(日志、权限、缓存),其实更像代理或者面向切面编程的范畴。硬套装饰者会导致灾难性的类膨胀。
提示:很多框架层面已经有成熟的AOP机制或过滤器链,能解决大部分横切逻辑的问题。装饰者模式应该留给确实需要“运行时动态可选叠加”的业务场景。
7.5 序列化与克隆的额外考量
如果你的装饰器包装的对象需要序列化(比如放到Redis或消息队列里),要格外小心。因为装饰器内部持有的是对象引用,默认的Java序列化会把整个包装链都序列化进去。一旦某层装饰器里有不可序列化的字段(比如数据库连接、线程池),就会出现NotSerializableException。
我在报表服务中就遇过这个坑:给数据源装饰了一层加密逻辑,这层内部持有加密密钥对象(包含SecretKey),结果把这个对象序列化到缓存时直接爆异常。解决方案是给装饰器实现自定义writeReplace方法,或者将装饰器设计成不含运行时状态、只含处理逻辑的形式。装饰器最好是无状态对象,这样才能安全地放入各种容器。
8. 写在最后:设计模式的落地判断比背类图更重要
回到开头那个报表导出的例子。最终我用6个类替换了原本预想的数十个子类,每增加一种导出能力,就递交一个一次性修改的PR,其他代码全部不碰。上线之后,需求变化还在继续,不同的客户要不同的能力组合,部分客户甚至会在后台配置里动态指定“导出PDF时加密且压缩,导出Excel时只加水印”。这种灵活度,继承体系基本做不到,装饰者模式却可以建立在运行时配置之上。
不过我也要泼一盆冷水:不是所有“动态加功能”的需求都适合装饰者模式。如果功能叠加的类型极少、组合固定,用继承硬写几个子类反而更简单直观。如果你只是想在调用前后各加一行打印,写一个代理或者直接改原方法都行,没必要为模式而模式。装饰者模式真正的威力场景是:组合数多、扩展频繁、希望保持运行时装配的自由度。
另外,我对设计模式本身的理解是:它们不是用来背的“标准答案”,而是建立设计交流语言的工具。当你跟团队说“这里用装饰者包装一下”,实际上是在说“我们需要在不修改原类的前提下支持新的可选行为组合”。掌握了这种语义,比会画类图重要一百倍。
如果你是在系统学习设计模式,建议把这个模式与组合模式、策略模式、责任链模式放在一起学,你会看到它们解决类似问题的不同切面。比如责任链模式和装饰者模式都由层层传递构成,区别在于接口方法的分发方式以及消费上的差异。多画调用时序、多在实践中复盘,比看几十篇教程都管用。