开头先用一段真实经历把话题扯进来:之前在维护一个订单对接系统时,同一个订单对象要在不同渠道展示不同包装,又不愿意为每个渠道生成一堆子类,最后靠装饰器模式把代码从“意大利面”恢复成了“流水线”。装饰器模式是GoF二十三种设计模式里的老面孔,属于结构型模式,核心一句话就能说清:在不修改原有类代码的前提下,动态地给对象叠加职责。适合正在写业务代码时发现“继承越来越难维护”的开发者,也适合准备系统过一遍设计模式、想搞懂JDK IO流为什么层层套娃的读者。
1. 装饰器模式是什么:先弄懂结构型模式想解决什么
1.1 结构型模式的共同点
结构型模式这一族,处理的核心矛盾是“类和类、对象和对象怎么组合才舒服”。继承、聚合、组合、接口实现,这些连接手法本身不复杂,复杂的是选型。适配器解决“接口对不上”,外观模式解决“子系统太乱想给个门面”,代理模式解决“不直接操作目标对象”,而装饰器解决的是“要给对象动态加料”。
我之前带过一个项目,需求是给报表数据增加脱敏、加密、格式转换三层处理。最初设计方案的人直接写了一个大工具类,里面判断开关满天飞。那个工具类迭代了三个月后,每次改动都要重新测试全部路径。后来重构时借鉴装饰器模式,把脱敏、加密、格式转换各封装成一个独立的装饰器,数据层层传递,新需求只需要在链上插一个新节点,老代码完全不用动。
结构型模式都有一个共同点:它们是关于“怎么组织代码结构”的答案,不是关于“某个算法怎么写”的答案。装饰器在结构型里的特殊之处在于,它组合的对象可以是无限层级的,前面加一个,后面还可以再加一个,顺序还能自由排列。
1.2 装饰器模式的四个角色
装饰器模式的标准结构由四个角色组成,理解角色边界比背类图更重要。
- 组件接口:定义被装饰对象和装饰器共同遵守的契约。比如咖啡接口里定义“获取描述”和“计算价格”两个方法。
- 具体组件:实现组件接口的真实对象,是被装饰的起点。比如一杯没有任何配料的普通咖啡。
- 抽象装饰器:持有组件接口的引用,默认把方法调用转发给被装饰对象。它存在的意义是让具体装饰器可以嵌套。
- 具体装饰器:继承抽象装饰器,在转发前后插入自己的逻辑。比如加奶、加糖、加焦糖。
这四个角色里,抽象装饰器最容易被忽略。很多初学者直接让具体装饰器实现组件接口,结果也能跑,但一旦出现多层装饰,每个装饰器都要维护一份“持有被装饰对象”的代码,复用性差很多。抽象装饰器就是把这个机械的转发逻辑收拢到一处。
我还想强调一点:装饰器模式里,装饰器和被装饰者实现的是同一个接口,这是它和静态代理最本质的区别。两者都是包装一个对象,但代理模式往往隐藏被代理者,而装饰器一定要保持接口一致,让外部看起来“你加了这么多层,还是原来那个东西”。
2. 为什么非要用装饰器:继承方案是如何走向“爆炸”的
2.1 继承扩展的困局
遇到“给对象加职责”的需求,很多人的第一反应是继承。拿咖啡举例:普通咖啡,加奶咖啡,加糖咖啡,加奶加糖咖啡。继承结构会变成四个类。再加一种配料“焦糖”,就变成八种组合类。这还只是两种配料的笛卡尔积,实际业务里配料种类往往长期增长,类数量呈指数膨胀,这就是经典的“类爆炸”。
我见过一个真实系统:一个支付回调对象,按支付渠道、商户类型、是否境外、是否退款分成十几个类,全部用继承实现。后来加了一个“是否分账”维度,团队评估后直接放弃,因为新增一个维度意味着所有子类都要重新梳理一遍继承链。
继承还带来一个隐秘问题:继承关系在编译期就固定了,对象创建之后就无法改变。如果你的业务场景里,同一个对象今天需要A能力,明天需要B能力,后天需要A加B,继承结构会非常尴尬,而装饰器可以在运行时自由组合。
2.2 装饰器怎么把组合用起来
装饰器把“加职责”从继承的静态关系,变成了对象组合的动态关系。它不创建新的子类来表达“咖啡加奶”,而是创建一个“加奶装饰器”,把原来的咖啡对象传进去,包装后返回一个新的对象。
这个思路的本质是组合优于继承。组合让每一层装饰器只关心“自己在原行为之上增加什么”,不用关心前后其他装饰器做了什么。每个装饰器都是独立单元,测试也可以单独做。
以前面报表处理的例子来说:脱敏装饰器只负责把身份证号、手机号打码;加密装饰器只负责对指定字段做AES加密;格式转换装饰器只负责把时间戳转成日期格式。它们谁都不需要知道另外两个存在,业务方想用哪几个就手动组装哪几个。
2.3 装饰器、适配器、代理之间的边界
这三个模式都涉及“包装一个对象”,很容易混。我给团队画过一个简单的判断表,直接套用就能选型:
| 对比维度 | 装饰器 | 适配器 | 代理 |
|---|---|---|---|
| 重点 | 增加职责 | 转换接口 | 控制访问 |
| 接口关系 | 与被装饰者同接口 | 把A接口转成B接口 | 与被代理者同接口 |
| 外部感知 | 感知功能变丰富 | 感知接口变了 | 感知不到被代理者 |
| 典型场景 | IO流、权限包装 | 老接口兼容新系统 | 远程调用、懒加载 |
一个最容易踩的坑是装饰器和动态代理混用。Spring AOP底层是动态代理,它也能在方法前后插入逻辑,但代理更强调“控制”和“拦截”,装饰器更强调“增强”和“叠加”。如果你需要链式叠加多个增强,用装饰器;如果你需要横切某个方法做事务、日志、鉴权,用代理更合适。
3. 一个能跑的实战:咖啡加料场景完整落地
3.1 场景设计与需求
我用一个咖啡点单场景来演示,因为它的需求变化非常适合装饰器:基础咖啡价格固定,配料可以任意组合,每种配料叠加不同价格。需求是支持动态组合,同时不能改动已有咖啡类。
这个场景我选Java来写,因为Java的接口体系最直观地体现了“装饰者和被装饰者同接口”的特点。步骤分四层:定义咖啡接口,写普通咖啡的具体实现,写抽象装饰器,写若干个具体装饰器。
3.2 核心代码实现
先定义组件接口和具体组件:
public interface Coffee { String getDescription(); double cost(); } public class SimpleCoffee implements Coffee { @Override public String getDescription() { return "普通咖啡"; } @Override public double cost() { return 10.0; } }然后是抽象装饰器,它持有一个Coffee引用,把两个方法默认转发给被装饰对象:
public abstract class CoffeeDecorator implements Coffee { protected Coffee decoratedCoffee; public CoffeeDecorator(Coffee coffee) { this.decoratedCoffee = coffee; } @Override public String getDescription() { return decoratedCoffee.getDescription(); } @Override public double cost() { return decoratedCoffee.cost(); } }再写两个具体装饰器:
public class MilkDecorator extends CoffeeDecorator { public MilkDecorator(Coffee coffee) { super(coffee); } @Override public String getDescription() { return decoratedCoffee.getDescription() + " + 牛奶"; } @Override public double cost() { return decoratedCoffee.cost() + 5.0; } } public class SugarDecorator extends CoffeeDecorator { public SugarDecorator(Coffee coffee) { super(coffee); } @Override public String getDescription() { return decoratedCoffee.getDescription() + " + 白糖"; } @Override public double cost() { return decoratedCoffee.cost() + 2.0; } }使用的时候直接嵌套构造:
Coffee coffee = new SimpleCoffee(); coffee = new MilkDecorator(coffee); coffee = new SugarDecorator(coffee); System.out.println(coffee.getDescription()); System.out.println(coffee.cost());输出结果是“普通咖啡 + 牛奶 + 白糖”和17.0元。整个过程,SimpleCoffee的源码一个字都没改。
3.3 运行结果与关键点解读
这个例子跑通后,有两个细节值得琢磨。
第一个是“构造顺序即装饰顺序”。MilkDecorator包SugarDecorator,包SimpleCoffee,所以描述先追加牛奶再追加白糖。如果反过来,输出会变成“普通咖啡 + 白糖 + 牛奶”。业务上可能无所谓,但如果你做的是敏感数据处理,顺序错了就全错了。先脱敏再加密和先加密再脱敏,结果完全不同。
第二个是“递归式的方法调用”。当外部调用cost时,SugarDecorator先调用decoratedCoffee.cost拿到内层价格,再加2.0;内层MilkDecorator又调用它的decoratedCoffee.cost拿到10.0,再加5.0。每一层只负责自己的增量,这正是装饰器可以无限叠加的原因。
我在实际写代码时会注意给抽象装饰器加一个抽象方法或者模板方法,把“公共增强逻辑”和“装饰特有逻辑”分开。比如在转账通知场景里,每个装饰器都要记录日志,这行代码放在具体装饰器里会重复,放在抽象装饰器里收口更干净。
4. 框架源码里的装饰器:从JDK IO到Spring、MyBatis
4.1 JDK IO流:最经典的装饰器现场
Java的IO包是全网公认的装饰器模式教科书级案例。看一眼代码就明白什么叫层层嵌套:
BufferedReader reader = new BufferedReader( new InputStreamReader( new FileInputStream("data.txt")));FileInputStream负责读文件字节,InputStreamReader负责把字节流转成字符流,BufferedReader负责加缓冲。三个类各干一件事,通过构造器嵌套组合。这比让一个类同时干三件事清晰得多,也让能力的组合变得极其灵活:你可以不需要缓冲,就不用BufferedReader;你可以直接读字符串,就不用InputStreamReader。
刚开始学Java时很多人觉得IO流难,就是因为这个层层包装的写法反直觉。如果能先理解装饰器模式,再看IO流会顺畅很多,反过来也一样。
4.2 Servlet与Spring中的包装器
在Java Web领域,Servlet规范里有一个HttpServletRequestWrapper,就是典型的装饰器实现。它包装原始request对象,开发者继承它就能覆盖getParameter、getHeader等方法,给请求参数加统一处理逻辑,而不用改动Servlet容器里的原始实现。
Spring框架里我常用的是TransactionAwareCacheDecorator,它给缓存操作包上一层事务感知能力,能够在事务尚未提交时不更新缓存,提交后再更新。还有一个场景是Spring中的ResponseBodyAdvice,虽然实现方式不完全一样,但思路很像:在响应写出前,给结果统一加一层包装,比如统一时间格式、统一敏感字段脱敏。
MyBatis的二级缓存机制也是装饰器模式的典型应用。基础的PerpetualCache是具体组件,然后通过BlockingCache、ScheduledCache、SerializedCache等装饰器逐层包装,形成一套带阻塞、定时刷新、序列化能力的缓存链。每一层只做一件事情,组合起来就是完整方案。
4.3 业务系统里最常见的三个用武之地
除了框架层面,业务代码里我至少见过三个高频使用场景。
第一个是权限包装。用一个PermissionDecorator包裹原始对象,在调用写入方法前检查当前用户是否有权限,没有就直接抛异常。曾经有一个导出功能,把数据导出、日志记录、权限校验拆成三个装饰器,比在业务方法里写三套if判断清爽得多。
第二个是日志埋点。用一个LogDecorator包裹服务对象,方法执行前打一条入参日志,执行后打一条耗时日志。它和AOP的区别在于,装饰器可以更精准地选择要包装哪个实例,而不是一刀切地切所有Bean。
第三个是缓存。读操作先查缓存,缓存没有走原方法,再把结果写入缓存。相比把缓存代码写死在业务方法里,缓存装饰器可以独立测试,也可以在不需要缓存的场景直接去掉装饰器。
5. 进阶:透明、半透明与函数式装饰
5.1 透明装饰器与半透明装饰器
装饰器模式在标准形态之外,还有两种变体,理解它们能帮你应付面试题,也能帮你在实际落地上更灵活。
透明装饰器要求装饰器完全保持被装饰对象的接口,所有方法都转发,外部根本感知不到有装饰存在。前面咖啡例子就是透明装饰器,MilkDecorator没有新增任何接口方法,只是改变了getDescription和cost的返回值。好处是嵌套无限层也不会破坏类型;坏处是你没法调用“装饰器独有”的方法。
半透明装饰器则允许装饰器增加自己的独有方法。比如一个WiFiDecorator在Computer接口之外增加了一个connectWiFi方法。这样做增强了功能,但破坏了“完全等价”的特性,外部不能继续把它当作纯Computer使用,多层嵌套也可能出问题。
我的建议是,在核心链路尽量使用透明装饰器,保证可以自由拆卸和换序;只在确实需要暴露新能力的边界场景,才有节制地使用半透明装饰器。
5.2 装饰顺序与职责链的边界
装饰器模式有时候看着很像责任链模式,很多初学者会把它们混在一起。区别在于责任链的核心是“把请求沿着链路传递,让最合适的节点处理”,它只选一个处理者;装饰器的核心是“每一层都参与处理,层层叠加结果”。
举一个例子:一个订单数据链路中,先做参数校验装饰器,再做权限校验装饰器,最后做落库装饰器。这三个如果按照责任链的方式设计,第一个校验没过就直接返回,不会走到权限和落库;按照装饰器的方式设计,每一层都会执行,校验装饰器可以抛异常中断链路,但指导思想依然是“叠加处理”。
实际项目中装饰器和责任链经常组合使用,比如Spring Security的过滤器链本身就沿用了装饰器包装的思想。我在设计一个工单流转系统时,就让质量检测节点用责任链筛选节点,让每个节点内部的增强逻辑用装饰器叠加,两层配合,代码比单一思路清晰不少。
5.3 Python装饰器与Java注解型设计的异同
Python语言里有一个同名语法糖:装饰器,用@符号挂在函数上方,在函数定义时就完成了包装。例如:
def timer(func): def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) print(f"耗时 {time.time() - start}") return result return wrapper @timer def fetch_data(): passJava没有原生语法糖,传统做法是构造器嵌套。两者本质都是装饰器模式,但控制时机完全不同:Python的@装饰器是在函数定义阶段静态绑定,而Java构造器嵌套可以在运行期自由组合。如果你用Java做动态组合,但期望达到Python那种“写在方法上就自动包装”的效果,就需要借助注解和反射去做类似Spring AOP的事情。
另外,Python装饰器也可以叠加多个,这一点和Java的多层构造器嵌套一致。区别只是Python的装饰器是面向函数的,Java经典装饰器是面向对象的。理解了思想后,在两个语言之间切换没有太大认知负担。
6. 排查实战与避坑指南
6.1 常见问题速查表
把装饰器模式用到生产环境,经常会碰上一批典型问题。我把这几年遇到的整理成一个速查表,排在前面的是踩中概率最高的三件事。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 装饰后调用方法走了两遍逻辑 | 装饰器内部不小心调了super方法后又调用decoratedCoffee方法 | 明确转发方向,避免双路执行 |
| 多层装饰后结果顺序不对 | 构造顺序与预期相反 | 画一个洋葱模型,严格按照由内到外核对 |
| 类型转换失败 | 半透明装饰器暴露了新方法,外部却把它当原接口用 | 检查是否需要回调原始类型,控制装饰器层级 |
| equals/hashCode行为异常 | 装饰器没有重写equals hashCode,或者重写时不包含被装饰对象 | 按业务需求决定是否重写,避免把装饰器带进集合 |
| 序列化失败 | 装饰器持有不可序列化字段 | 给装饰器加serialVersionUID,必要时使用自定义序列化 |
6.2 一次“缓存装饰器失效”的排查记录
去年维护一个数据服务时,遇到一个非常隐蔽的问题:缓存装饰器在部分机器上失效,表现是数据偶尔陈旧,偶尔新鲜。排查过程分为三个步骤。
第一步,怀疑缓存键。检查后发现缓存键生成依赖业务参数,参数在装饰器里经过了一次加工,这个加工只处理了部分字段。看起来不是键问题。第二步,怀疑缓存过期策略。看配置发现有些热点数据的过期时间不一致,但这无法解释同城同配置机器之间的差异。第三步,查看日志后发现,有一台机器的服务在启动时没有加载缓存装饰器那一层,原因是配置中心下发时多了一个开关字段,布尔值解析失败后走了默认的false分支,导致装饰链被短路。
这个问题让我印象很深。装饰器模式本身没有问题,问题常常出在创建装饰链的工厂方法里。调试时不能只盯着装饰器内部,还要检查链是怎么被组装起来的,配置开关、依赖注入、反射生成,任何一环出问题都可能让整条链静默失效。
6.3 我在团队里推广装饰器的三条铁律
第一,链的组装必须集中管理。我会要求团队把装饰链的创建收敛到工厂方法或者配置类里,禁止在业务代码中手动new三层以上嵌套。这样排查时只需要看一个地方,而不是满项目搜。
第二,尽量保持装饰器小而专。一个装饰器只加一种能力,如果发现一个装饰器里做了两件不相干的事,就拆成两个。装饰器模式的代码不该超过一百行,超过就要反思是不是把业务逻辑塞进来了。
第三,明确装饰器是手段,不是目的。如果只有一两个扩展点,直接用if判断并不丢人。装饰器是组合爆炸、需求多变的场景下才真正发挥价值。我在实际项目里见过有人为了套模式硬写出五个装饰器,那反而增加了维护成本。
结尾
做了这些年设计和重构,我的体会是:装饰器模式是所有结构型模式里最贴近“加料”直觉的一个。代码的组织方式一旦和人的直觉对齐,理解成本和维护成本都会显著下降。实际落地中,我不建议把它当作一个需要背诵的类图去套用,更重要的是掌握“动态叠加职责”这个核心思路,然后让代码自然生长出这种结构。最开始可以从Java IO流这样的现成案例入手,再迁移到自己的项目里,慢慢就会形成条件反射:当需求总是在给对象加责任时,第一个想到的就该是装饰器。