
说到装饰器模式我脑子里冒出来的第一个画面不是UML类图而是当年那个被我拆掉重写的点餐系统。咖啡店需求很简单点一杯咖啡可以加糖、加牛奶、加奶泡、加焦糖。最开始我图省事用继承来扩展结果不到俩月类膨胀到二十几个光SugarMilkSoyEspressoDoubleWhip这种名字就把人看吐。后来痛定思痛用装饰器模式重构不仅代码量砍了大半新增一种配料只需要加一个类不动任何已有代码。这篇文章就把装饰器模式的原理、Java实现、实际应用和踩坑经验一次讲清楚既有代码也有思路软考和面试也够用。1. 用一杯加料咖啡说清继承方案为什么撑不住1.1 需求演进从“加糖”到“加一切”假设我们一开始只卖美式咖啡Americano需求很简单客户可以加一份糖。用继承写第一版没问题class Americano { double cost() { return 18.0; } } class SugarAmericano extends Americano { Override double cost() { return super.cost() 2.0; } }好了第二天客户说想加牛奶。好办再加一个类。第三天客户说要既有糖又有牛奶的。也行加一个组合类。第四天客户说要加奶泡加焦糖加豆浆加双份糖加脱脂奶……这时候你再看类图Americano AmericanoWithSugar AmericanoWithMilk AmericanoWithSugarMilk AmericanoWithFoam AmericanoWithSugarFoam AmericanoWithMilkFoam AmericanoWithSugarMilkFoam AmericanoWithCaramel ...这就叫类爆炸Class Explosion。三种配料就有 2³ 8 种组合四种配料就是 16 种五种配料就是 32 种。再加上换基底比如浓缩咖啡、拿铁基底、冷萃基底组合数直接指数上升任何项目都扛不住。1.2 继承的根本矛盾编译期就锁死了组合可能继承本身没有错错在用继承表达“排列组合”。继承表达的是“is-a”关系SugarAmericano是Americano的一种。但“加糖的美式”并不是一个新的物种它只是“美式”的某个状态、某个附加职责。用术语说继承是在编译期就把父子关系钉死了。这个类一旦定义出来它的组合能力就固定了。以后任何新需求要么改已有类要么再加一个新类而改已有类往往违背开闭原则加新类又会导致类爆炸。这时候就该往回找那个更朴素的思路与其用继承去枚举所有组合不如用组合去动态拼装。就像做咖啡基底还是那个基底糖、奶、奶泡这些是后加进去的“装饰”加多少层都不会影响基底本身。装饰器模式的核心思想说白了就是四个字组合优于继承。它把一个对象的职责一层一层包起来每一层只做一件事层与层之间互不知道对方的存在。代价是类数量从“组合数”降回“配料数”也就是从指数级降回线性级。2. 装饰器模式的四个角色与透明性原理2.1 四个角色一张图记住装饰器模式一共有四个参与角色名字听着多其实分工非常清晰角色名字职责抽象组件Component定义业务接口所有组件和装饰器都实现它具体组件ConcreteComponent被装饰的核心对象比如原始咖啡抽象装饰器Decorator持有组件引用转发接口调用具体装饰器ConcreteDecorator在转发前后增强行为比如加糖、加奶关键点在于抽象装饰器为什么要继承抽象组件为了让装饰器能替换组件也能被其他装饰器继续包装。这是整个模式能“套娃”的语法基础。如果装饰器不继承组件接口客户端就没法用同一个类型引用它递归嵌套也就无从谈起。2.2 透明性这是装饰器模式的灵魂“透明性”这个词听起来玄乎实际含义很简单客户端根本不知道自己在用装饰器它只认为自己操作的是组件接口。还拿咖啡举例Beverage drink new MilkDecorator(new SugarDecorator(new Espresso()));客户端拿到的是Beverage类型的对象调用cost()方法。它不需要知道里面套了几层也不需要关心每一层怎么加价。外层装饰器对于客户端是“透明”的。这种透明性带来两个好处调用方代码零改动。新增装饰器不影响任何使用方。装饰器和组件可以互相替换。装饰器就是一个“带额外功能的组件”递归套多少层都成立。2.3 递归组合一次调用如何穿过层层包装理解透明性之后再看调用过程就顺理成章了。普通继承是“逐级向上调用父类”装饰器则相反是从外层向里递归层层转发最后再层层返回。假设调用cost()外层 MilkDecorator.cost() ↓ 转发 SugarDecorator.cost() ↓ 转发 Espresso.cost() 返回 18.0 ↓ SugarDecorator 加 2.0返回 20.0 ↓ MilkDecorator 加 4.0返回 24.0整个过程有点像剥洋葱从最外层开始一层层往里剥最里层给出基础值然后每层再根据自身的逻辑加上增量逐层回传。这个“递归组合”的特点决定了装饰器模式非常适合处理逐层叠加、职责正交的场景比如加价、加日志、加缓存、加权限校验。3. Java代码落地从接口到装饰器完整实现3.1 先定义抽象组件和具体组件与其空谈原理不如直接上一个能跑的完整例子。我们用“饮料 配料”演示代码量很小但把四个角色全占了。第一步抽象组件public interface Drink { String getDescription(); double cost(); }第二步具体组件——浓缩咖啡这是最核心的被装饰对象public class Espresso implements Drink { Override public String getDescription() { return 浓缩咖啡; } Override public double cost() { return 15.0; } }第三步抽象装饰器这是整个模式的枢纽public abstract class DrinkDecorator implements Drink { protected Drink drink; public DrinkDecorator(Drink drink) { this.drink drink; } Override public String getDescription() { return drink.getDescription(); } Override public double cost() { return drink.cost(); } }抽象装饰器重点理解两件事持有Drink引用。这是包裹的对象编译期不知道具体是谁运行时才知道。默认转发所有方法。子类只需要重写需要增强的方法其他方法一律透传。3.2 两个具体装饰器加糖和加奶写两个最常见的配料注意它们都不关心自己包装的对象具体是什么只要是Drink就行public class SugarDecorator extends DrinkDecorator { public SugarDecorator(Drink drink) { super(drink); } Override public String getDescription() { return drink.getDescription() , 加糖; } Override public double cost() { return drink.cost() 2.0; } }public class MilkDecorator extends DrinkDecorator { public MilkDecorator(Drink drink) { super(drink); } Override public String getDescription() { return drink.getDescription() , 加奶; } Override public double cost() { return drink.cost() 4.0; } }注意细节getDescription()也不是简单返回父类值而是拼接当前层的描述。这样最终打印出来的描述才是完整链条比如“浓缩咖啡, 加糖, 加奶”。3.3 组装调用看动态组合的效果客户端代码如下public class CoffeeBar { public static void main(String[] args) { Drink drink new Espresso(); // 加一份糖 drink new SugarDecorator(drink); // 再加一份奶 drink new MilkDecorator(drink); // 觉得不够甜再加一份糖 drink new SugarDecorator(drink); System.out.println(drink.getDescription()); System.out.println(总价: drink.cost()); } }输出结果浓缩咖啡, 加糖, 加奶, 加糖 总价: 23.0这个例子能看出装饰器模式的三个特征运行时动态组装。drink变量被反复赋值每一次都是在原对象上包一层新装饰器。同一装饰器可以重复使用。加了两次糖只要每次 new 一个新的SugarDecorator就能实现。客户端只依赖抽象接口。从头到尾只接触Drink不需要知道任何装饰器内部细节。如果哪天上“加奶泡”只需要新写一个FoamDecorator extends DrinkDecorator所有已有代码一行不动。4. Java IO体系就是装饰器模式最真实的教科书4.1 一条典型的IO包装链Java 新手学 IO 时经常被各种 Stream 搞懵FileInputStream、BufferedInputStream、DataInputStream、ObjectInputStream…… 为什么要套这么多层其实这就是装饰器模式在 JDK 里最自然的一次应用。最常见的写法InputStream in new DataInputStream( new BufferedInputStream( new FileInputStream(data.txt) ) );这个对象构造过程本质就是给文件字节流装饰了两个功能BufferedInputStream缓冲能力减少磁盘IO次数DataInputStream读取基本类型的能力方便直接读 int、double 等。每一个装饰器都不是在改原始行为而是在原始行为上“加一层能力”。4.2 源码级拆解InputStream和FilterInputStreamJDK 里这套类的结构和上面我们自己写的四角色一一对应设计模式角色JDK 中的类抽象组件InputStream具体组件FileInputStream、ByteArrayInputStream等抽象装饰器FilterInputStream具体装饰器BufferedInputStream、DataInputStream、PushbackInputStream等FilterInputStream是抽象装饰器源码核心就两行逻辑public class FilterInputStream extends InputStream { protected volatile InputStream in; protected FilterInputStream(InputStream in) { this.in in; } Override public int read() throws IOException { return in.read(); } }这就是标准的“持有引用 转发调用”。所有的具体装饰器都继承它各自重写需要增强的方法。比如BufferedInputStream内部维护了一个byte[] buf它重写了read()先读自己的缓冲区缓冲区空了再调用in.read()从底层文件读。职责与协作关系清清楚楚。4.3 为什么说IO体系是理解装饰器模式的黄金教材原因有两点。第一它在生产级代码中真实存在了二十多年经受了无数开发者的检验不是玩具示例。你去看源码能直观感受到每一步设计都是有意的。第二包装链的组装方式完美体现了装饰器“动态叠加”的特征。同一条数据流在不同场景需要不同包装组合。性能敏感场景可以只包缓冲需要类型读取就再包一层数据流需要对象序列化就包ObjectInputStream。没有装饰器模式这套组合系统用继承根本没法实现——那得有多少个类才能覆盖所有 InputStream 组合所以当你再看到new BufferedReader(new FileReader(...))别再觉得它啰嗦。这不是设计过度这恰恰是把装饰器模式用到了极致。5. 装饰器、继承和代理模式三者到底怎么选5.1 装饰器模式 vs 继承很多初学者会问装饰器模式能做到的继承也能做到区别到底在哪对比维度继承装饰器模式关系建立时机编译期静态绑定运行期动态组合类数量随着组合数指数增长随着功能数线性增长是否侵入原类是原类要参与设计否原类无需感知能否重复加同一功能不能一个类只能继承一次能可多层叠加职责是否单一子类往往承担多种职责每个装饰器只负责一个功能直观感受就是继承是一条路走到黑装饰器是自由拼装的乐高积木。5.2 装饰器模式 vs 代理模式这俩是面试高频对比题。单看类结构代理模式也持有目标对象引用也转发方法调用和装饰器非常像。区别在职责代理模式重在控制访问。比如权限代理、远程代理、延迟加载代理。它的核心目的是决定“要不要放行”。装饰器模式重在增强功能。它不拦截也不控制只负责在原有功能前后追加行为。一个容易记的说法是代理是门口的保安装饰是给房间贴墙纸。保安可以让你进也可以不让你进墙纸只是让房间更好看不会阻止你进门。5.3 实际选型建议我在项目里判断用哪个方案一般按以下顺序问自己能不能直接改原类的行为如果能且改动简单不折腾模式。需要增强的功能是不是正交的也就是说能不能独立成层不依赖其他功能能选装饰器。是不是要严格控制调用方比如权限、代理接口选代理。功能组合数量大不大不超过两三种组合继承其实更快一旦超过四五种立刻转向装饰器。一句话总结函数数量多、功能要任意组合时优先装饰器需要限制访问而不是增强功能时优先代理功能少且稳定时直接继承最省事。6. 实战重构给业务Service加缓存与日志不改一行原逻辑6.1 原始痛点场景这家公司有个老系统核心接口长这样public interface UserService { User getUserById(Long id); }实现类已经运行了六年几百个地方在调用没人敢动它。现在新需求来了查询结果要加缓存能撑一分钟是一分钟另外所有查询要记耗时日志线上排查问题用得上。第一反应是在实现类里加缓存和日志逻辑对不对对但不优雅。因为缓存逻辑和用户查询逻辑掺在一起以后想单独停掉缓存做不到改核心类有回归风险一大堆依赖方可能被影响测试变难要同时准备缓存、日志、数据三层测试数据。这就是典型的“能用装饰器却去改原类”的坏味道。6.2 用装饰器实现缓存与日志两层包装保持UserService和UserServiceImpl完全不动新写两个装饰器。缓存装饰器public class CacheUserServiceDecorator implements UserService { private final UserService delegate; private final MapLong, User cache new HashMap(); public CacheUserServiceDecorator(UserService delegate) { this.delegate delegate; } Override public User getUserById(Long id) { if (cache.containsKey(id)) { return cache.get(id); } User user delegate.getUserById(id); cache.put(id, user); return user; } }日志装饰器public class LogUserServiceDecorator implements UserService { private final UserService delegate; public LogUserServiceDecorator(UserService delegate) { this.delegate delegate; } Override public User getUserById(Long id) { long start System.currentTimeMillis(); try { return delegate.getUserById(id); } finally { long cost System.currentTimeMillis() - start; System.out.println(getUserById( id ) 耗时 cost ms); } } }组装只需要改一处启动代码UserService userService new LogUserServiceDecorator( new CacheUserServiceDecorator( new UserServiceImpl() ) );6.3 重构后的实际收益做完这个改动之后最直观的感受是每个类的职责重新变得纯粹UserServiceImpl只关心查数据库CacheUserServiceDecorator只关心缓存命中LogUserServiceDecorator只关心耗时长不长。拆开之后单元测试也好写得多。测缓存就单独 mock 一个 delegate测日志就检查输出格式两个装饰器互不干扰。还有一个隐藏收益功能可以按需组合。内网接口不想记日志不套日志装饰器就行。某类用户不做缓存不套缓存装饰器就行。这些变化都不需要改动核心实现类符合开闭原则。7. 实际开发中容易踩的坑和面试速记要点7.1 坑一包装后对象身份变了equals/hashCode 失效装饰器包装后返回的对象类型虽然还是同一接口但equals()和hashCode()行为发生变化。如果这个对象被放进HashSet或作为HashMap的 key很可能出现“同一个对象找不到”的问题。我踩过一次缓存装饰器包装后的对象和裸对象在 Set 里被当成两个对象导致去重逻辑失效。解决方案不是改装饰器里的equals而是在系统设计中提前定义业务主键比如用userId作为身份标识不要直接用对象引用做去重。7.2 坑二嵌套层数过多排查问题像剥洋葱理论上装饰器可以无限嵌套但实践中每层嵌套都会增加一次方法调用。一旦出问题异常堆栈会非常长调试成本剧增。比如框架本身就装饰了三层业务代码再套两层日志打出来的调用链又长又绕。我现在的习惯是装饰器超过三层就考虑是否该换个模式。另外给装饰器类命名时带上明确的功能词比如CacheDecorator、RetryDecorator而不是笼统的EnhanceDecorator。名字本身就是文档排查时能少一半开销。7.3 坑三装饰器间接依赖具体组件退化成一个“伪装饰器”有一种错误写法装饰器的构造方法只接受Espresso不接受通用Drink接口。比如public class SugarDecorator extends DrinkDecorator { public SugarDecorator(Espresso espresso) { // 错误 super(espresso); } }这样写装饰器就失去了通用性没法装饰其他基底本质上又退回成组合类了。判断标准很简单如果你的装饰器只能包一种对象它就不是装饰器它只是那一个类的增强版。7.4 软考与面试高频考点速记这部分送给备考和面试的同学。装饰器模式在软考中属于结构型模式常见考点包括定义动态地给一个对象添加一些额外的职责就增加功能来说比生成子类更灵活别名Wrapper所以看到XxxWrapper类大概率是装饰器模式参与角色Component、ConcreteComponent、Decorator、ConcreteDecorator核心特征透明性、递归组合与其他模式区分和代理模式结构类似但目的不同和继承相比更灵活典型应用Java IO中的FilterInputStream。记忆口诀可以浓缩成一句“装饰加新衣组合不继承”。八个字记住核心面试开头能稳住后面再逐步展开代码和案例。最后分享一个小经验。装饰器模式我在刚工作时觉得它麻烦一个功能拆了好几个类绕来绕去不直接。但后来在真实项目中见过太多次“继承撑爆代码”的场面才理解这种“绕”是有代价价值的它让你把修改从核心逻辑里拆出来让每一层只做一件事。现在每当我要给一个稳定类加新功能第一反应已经从“改这个类”变成了“包一层装饰器”。习惯之后你会觉得这样写代码特别踏实——核心类不用再动了新增功能都在外围整洁地排好队想加就加想摘就摘。