资讯动态

装饰器模式实战:从继承爆炸到动态叠加职责

发布时间:2026/10/16 8:03:56 来源:尧图企业网站定制
开头先用一段真实经历把话题扯进来之前在维护一个订单对接系统时同一个订单对象要在不同渠道展示不同包装又不愿意为每个渠道生成一堆子类最后靠装饰器模式把代码从“意大利面”恢复成了“流水线”。装饰器模式是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、MyBatis4.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流这样的现成案例入手再迁移到自己的项目里慢慢就会形成条件反射当需求总是在给对象加责任时第一个想到的就该是装饰器。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑