
组合模式这个设计模式我在实际项目里用了很多次但真正把它讲清楚、用得恰到好处还是踩过不少坑之后才慢慢有感觉的。它是结构型模式里非常经典的一个核心思想其实就一句话让客户端用一致的方式处理单个对象和组合对象。这话听起来简单但落地的时候透明式和安全式的选择、递归结构的边界控制、以及和装饰器模式的区分都有不少门道。这篇博文我就从概念拆解开始结合一个完整的文件系统实例把组合模式的实现、应用场景和常见坑一次性说透。1. 组合模式的核心思想与适用场景1.1 部分与整体的树形结构组合模式解决的问题本质上是如何处理部分与整体的层次关系。生活里最典型的例子就是文件夹和文件一个文件夹里可以装文件也可以装子文件夹子文件夹里又可以继续装文件和文件夹这样一层套一层就形成了一棵递归的树。对于客户端来说不管是双击一个文件还是双击一个文件夹操作动作是类似的——都需要打开它只不过打开文件夹之后还要继续往下看内容。在代码里组合模式就是把这种树形结构抽象出来。它定义一个抽象的组件接口叶子节点文件和容器节点文件夹都实现这个接口。叶子节点没有子节点容器节点持有子节点列表。客户端在面对这棵树的时候不需要关心自己正在操作的是叶子还是容器统一调用同一个方法就行。我最早接触组合模式是从图形界面的控件树开始的。一个窗口包含面板面板里又包含按钮、文本框甚至还能嵌一个子窗口这就是典型的组合结构。后来做文件系统、菜单系统、组织架构树、以及一些多层级的报表配置发现思路都是完全相通的——凡是能形成树状层级、并且对叶子节点和容器节点要做相似操作的场景都适合用组合模式。1.2 组合模式能解决什么痛点没有组合模式的时候客户端代码经常会写出大量if-else分支。比如处理一个菜单项要先判断它是普通菜单项还是子菜单如果是子菜单还要再遍历它的子菜单项每个层级都要写一遍逻辑。层级越多这些判断就越深代码的复杂度和重复度跟着指数级上升。组合模式的价值就在于把这个层级判断从客户端代码里拿掉。客户端只管调用统一的方法至于当前节点是不是容器、容器里有多少子节点、要不要递归展开这些都由组合结构内部去处理。这样带来的直接好处有几个客户端代码大幅简化、业务逻辑和数据结构解耦、新增一种节点类型时不影响已有代码。我自己体会最深的是一次报表配置系统的重构。之前页面控件分了几层业务逻辑里到处都是类型判断和向下转型维护一个配置页面要翻很多代码。后来用组合模式重构成控件树每个控件节点统一实现一套方法配置的渲染和取值逻辑一下就清晰了后续新增自定义控件类型也变得非常简单。1.3 判断是否该用组合模式的两个信号我判断一个场景适不适合用组合模式一般看两个信号。第一个信号是业务数据天然具备树形结构——父子关系明确、层级可以无限嵌套比如文件系统、分类目录、组织架构。第二个信号是客户端希望对单个对象和组合对象做一致处理——不管操作的是叶子还是容器调用方不想关心差异。这两个信号都满足那组合模式基本就是正解。如果只有一个信号满足就得掂量一下是否值得引入。比如数据是树形的但客户端每次都明确区分叶子节点和容器节点做不同的处理那用组合模式反而要额外处理类型判断这时候可能直接用树的数据结构更顺手。模式不是越多越好而是合适才好这个判断标准我一直记着。2. 两种实现方式透明式与安全式2.1 透明式所有节点暴露统一方法透明式组合模式是把管理子节点的方法比如add、remove、getChild直接定义在抽象的组件接口里。这样叶子节点和容器节点在客户端看来毫无区别都是同一个类型接口客户端不需要做任何类型判断就可以调用所有方法。好处非常明显客户端代码最简洁完全符合一致对待的设计意图。打个比方就好比不管你是普通员工还是部门主管公司给你发的工牌长得一样进门都刷同一个闸机。但透明式有一个天然的问题——叶子节点本来不应该有子节点管理操作。如果只定义在接口里而不实现调用的时候就会抛异常或直接报错如果强行实现了又会给叶子节点加上无意义的行为。所以透明式选择把抽象方法默认实现为空或者说抛出异常来解决这个矛盾。这在运行期可能会带来隐患客户端拿到一个叶子节点调用add方法时才发现不支持而这种问题在编译期根本发现不了。2.2 安全式仅在容器节点提供子节点管理安全式组合模式把管理子节点的方法从抽象接口里拿掉只在容器节点里提供。抽象接口只定义叶子节点和容器节点共通的行为比如业务操作方法。这样叶子节点是纯净的不会被强加不合理的方法。代价是需要客户端自己去区分类型。客户端要处理层级关系的时候必须先判断当前节点是不是容器类型再用向下转型或者模式匹配的方式拿到容器的子节点管理接口。这就意味着客户端代码里必然会出现类型判断分支组合模式一致对待的纯度就打折扣了。我倾向于这么理解透明式是理论上的最优解安全式是工程上的稳健解。如果做的系统封闭在自己团队手里节点类型有限用透明式可以让代码非常简洁利落。如果做的是框架或SDK要给外部开发者用那么安全式能避免使用者误操作叶子节点减少运行期异常。2.3 我的选型建议先安全后透明个人经验对于一个会长期演进的系统一开始用安全式更稳妥。不是怂是因为安全式把不合理的调用挡在编译期这对使用方来说其实是保护。等系统的节点类型和调用模式都稳定了真的确认所有客户端都希望无差别处理的时候再往透明式迁移也不迟。而且有个折中的做法抽象接口只保留业务方法然后额外在容器节点里暴露子节点管理方法同时在客户端封装一个工具方法来做安全的类型检查。这样即便主体是安全式客户端也不会被类型判断的代码淹没。我自己在文件系统那个例子里用的就是这个思路代码既安全又干净。3. 组合模式完整实现文件系统管理器3.1 抽象组件与两类节点直接上一个完整的例子我用一个文件系统管理器来演示组合模式。这个例子里文件是叶子节点文件夹是容器节点两者都实现一个抽象的文件系统节点接口。抽象接口定义所有节点的共通行为public abstract class FileSystemNode { protected String name; public FileSystemNode(String name) { this.name name; } public abstract void display(String prefix); public abstract long getSize(); }display方法用于按层级展示目录树getSize用于统计文件大小。这个抽象类是安全式的写法没有定义add、remove之类的子节点管理方法这些方法只存在于文件夹节点里。叶子节点文件实现如下public class FileLeaf extends FileSystemNode { private long size; public FileLeaf(String name, long size) { super(name); this.size size; } Override public void display(String prefix) { System.out.println(prefix name 文件 size 字节); } Override public long getSize() { return size; } }容器节点文件夹实现如下import java.util.ArrayList; import java.util.List; public class FolderNode extends FileSystemNode { private ListFileSystemNode children new ArrayList(); public FolderNode(String name) { super(name); } public void add(FileSystemNode node) { children.add(node); } public void remove(FileSystemNode node) { children.remove(node); } public ListFileSystemNode getChildren() { return children; } Override public void display(String prefix) { System.out.println(prefix name 文件夹); for (FileSystemNode child : children) { child.display(prefix ); } } Override public long getSize() { long total 0; for (FileSystemNode child : children) { total child.getSize(); } return total; } }注意一下文件夹的getSize方法它遍历所有子节点累加大小而子节点有可能是文件也有可能是文件夹。如果是文件夹它又会继续往下递归直到遇到叶子节点为止。这种递归遍历的写法就是组合模式内部处理层级结构的关键。3.2 客户端调用一致的递归遍历看看客户端怎么用这棵树public class Client { public static void main(String[] args) { FolderNode root new FolderNode(项目); FolderNode src new FolderNode(src); FolderNode docs new FolderNode(docs); root.add(src); root.add(docs); src.add(new FileLeaf(Main.java, 2048)); src.add(new FileLeaf(Util.java, 1024)); docs.add(new FileLeaf(readme.md, 512)); root.display(); System.out.println(总大小 root.getSize() 字节); } }运行结果项目文件夹 src文件夹 Main.java文件2048字节 Util.java文件1024字节 docs文件夹 readme.md文件512字节 总大小3584字节这里就能看出组合模式的妙处了客户端调用的display和getSize方法不用关心当前节点到底是文件还是文件夹。文件夹和文件都实现了同样的方法内部会自己处理子节点的分发和递归。哪怕项目里再加一个多级目录这段客户端代码一行都不用改。为了让客户端在处理层级的时候更安全方便可以再加一个工具类来避免直接向下转型public class NodeUtil { public static void addChild(FileSystemNode parent, FileSystemNode child) { if (parent instanceof FolderNode) { ((FolderNode) parent).add(child); } else { throw new IllegalArgumentException(当前节点不是容器节点无法添加子节点); } } }这样既保留了安全式对叶子节点的保护又把类型判断集中到了工具方法里客户端看起来依然很清爽。3.3 统计大小与展示业务逻辑的扩展点这个文件系统例子里getSize方法的实现是组合模式递归的经典演示。一个文件夹的大小就是所有子节点的大小之和子节点如果是文件夹就继续递归。实际项目里这个统计逻辑可以扩展成非常复杂的聚合操作比如统计目录下的文件数量、统计图片文件占用空间、或者计算某种类型节点的数量等。核心套路都一样叶子节点直接返回基础数据容器节点把子节点的数据汇总起来。display方法则展示了如何做带缩进的树形展示。每次向下递归一层就在prefix上追加缩进这样打印出来的结构就和操作系统文件管理器的树形视图一模一样。我经常用这种递归结构来做配置的层级回显、菜单的层级渲染、组织架构图的导出通用的框架代码都是成立。4. 组合模式的真实应用场景4.1 UI组件树几乎所有图形框架都在用要说组合模式应用最普及的地方就是UI组件树。从桌面开发的窗口控件到前端页面的DOM树到安卓的View层级本质上都是组合模式。一个页面是一个容器节点容器节点里面可以放按钮、输入框这些叶子节点也可以嵌套一个列表、一个面板这个的容器节点。UI框架里有一个很典型的例子事件处理。容器节点收到一个点击事件之后要么自己处理要么向父节点冒泡要么分发给子节点。这和组合模式的递归思路是完全一致的。前端常说的组件树把组件间的嵌套、属性继承、样式覆盖串起来都是靠这棵组合树在运转。我在做一个跨平台界面配置工具时直接把组合模式用在了核心模型上。每种UI控件都实现同一个渲染接口容器控件负责遍历子控件并递进渲染叶子控件渲染自己即可。这样无论是预览、导出还是批量修改某类控件的属性都不需要写针对具体控件类型的特殊逻辑。4.2 菜单与组织架构天然的层级数据菜单系统也是组合模式的经典使用场景。一个顶级菜单下面有子菜单子菜单下面又有菜单项甚至还可以继续嵌套子菜单。如果不用组合模式每层菜单都要单独写逻辑处理点击和展开代码非常容易膨胀用了组合模式之后顶层菜单、子菜单、菜单项实现同一个菜单组件接口菜单的加载和事件处理完全统一。组织架构树更直接。公司有集团、子公司、部门、小组每个层级下又可以挂人。人员就是叶子节点组织就是容器节点无论是统计人数、输出花名册、还是渲染组织架构图都可以用组合模式写一个统一的接口。报表系统里也经常会用到类似的结构。报表的模板、区块、行、列一层套一层最终形成完整的报表结构。用组合模式之后不管操作的是整个报表还是一个单元格入口方法都没有区别懒加载和局部刷新的实现也能做得比较干净。4.3 组合模式与装饰器模式好搭档组合模式经常和装饰器模式搭配出现。组合模式解决了树形结构的统一处理问题装饰器模式则解决在不修改底层结构的情况下增强功能的问题。比如文件系统里可以在每个节点外面套一个带缓存功能的装饰器让读写性能提升也可以套一个带权限校验的装饰器控制不同角色对节点的访问。我自己做过一个图像处理参数配置的例子。图像的处理步骤可以组合成一颗处理树每个处理节点增加参数之后又可以用装饰器动态添加附加选项。组合模式管树的搭建和遍历装饰器管参数的动态增强两个模式配合起来非常舒服。这个搭配的核心要点是装饰器节点本身实现了同一个抽象接口所以在组合树里它就能被当成一个普通节点来用。4.4 哪些场景不要硬套组合模式组合模式也不是万能的。我之前见过有人把完全没有层级关系的数据硬建模成树比如把多个独立配置项强行包到一个组合节点里导致代码里到处都是无意义的空容器和空判断整体可读性反而变差了。还有一类是树形结构特别简单层级固定只有两层比如菜单只有一级、分组下面不含子分组。这种情况下直接用普通的列表循环就够用了引入组合模式反而多了一层抽象新手读代码还需要先理解模式收益并不明显。模式的价值在层级复杂、递归结构、统一访问这三个条件不满足的时候别硬套。5. 常见问题与避坑思路5.1 循环引用导致无限递归组合模式最大的一个坑就是循环引用。如果文件夹A里加了文件夹B之后又把A加到B里面这两棵树就形成了环。递归遍历的时候代码会在A和B之间无限循环下去直到栈溢出或系统卡死。我碰到过一次这种情况就是配置导入的时候没有做完整性校验导致配置树里出现了循环引用页面渲染直接无响应。之后我在所有add方法里都加上了循环检测插入节点之前先检查目标节点的祖先链中是否有待插入节点。对于配置导入这种复杂场景还会额外做一个事后遍历来验证全树的唯一性和无环性。5.2 共享子节点组合模式默认不支持的场景组合模式默认认为每棵树是独立的节点只有一个父节点。但实际业务里可能会出现两个文件夹共享同一个快捷方式或者同一份配置被多个菜单引用的场景。如果直接用组合模式共享节点一旦在某个父节点下被修改另一个引用处也会受影响容易出问题。解决思路有两个。一个是在容器节点里保存共享节点ID而不是直接持有引用在使用时再通过ID去全局配置中心拉取另一个是引入轻量级的代理节点真正的实现共享数据但每个引用的父节点持有不同的包装对象。这两种方式都能在一定程度上扩展组合模式前提是同组人员对这种共享语义有明确的约定。5.3 类型判断的泛滥安全式组合模式容易遇到的一个问题是刚开始只是add和remove需要类型判断后来业务壮大了越来越多的场景都需要区分叶子节点和容器节点客户端代码里就会出现大量的instanceof判断和向下转型。这是我见过最常见的组合模式退化形态。应对办法是反向思考判断类型到底要做什么比如要统计总大小那就在抽象接口里定义getSize比如要判断可不可以添加子节点那就在接口里定义一个isContainer或者isLeaf方法。判断语意收敛到接口能力上而不是直接暴露具体类型这样即使底层换了实现客户端代码也不用大面积改动。5.4 过度设计两层结构真不需要组合模式还有一个很容易犯的错误是过度设计。文件系统、UI组件树这类场景确实适合组合模式但一个简单的两级菜单、一个固定深度为2的分组列表用两层for循环就能解决的问题非要抽象出Component接口再写Leaf和Composite两个实现类完全没必要。我的习惯是刚开始先用简单的两层结构等真的出现了第三层嵌套需求或者发现客户端经常出现重复的层级判断逻辑了再重构为组合模式。像这种模式的重构成本并不高接口设计对了的话客户端代码甚至都不用改。过早引入模式和不用模式一样都是设计上的失误。5.5 递归调用的性能陷阱组合模式经常和递归一起出现递归用多了自然会遇到性能问题。比如一个很深的目录结构递归统计每个目录大小的时候如果每次都重新遍历一遍子节点性能就会很差。我在实际中常用两个手段来缓解。一个是在容器节点里增加懒加载缓存子节点列表第一次读取后缓存起来数据变更时再主动刷新。另一个是控制遍历的深度比如在某些场景下只展示两级菜单就传一个depth参数递归到指定深度就停。还有就是要留意递归遍历是否会重复进入同一个节点配合防环检查一起控制逻辑。6. 实际重构案例报表配置系统的一段历程6.1 重构前的代码一层套一层的类型分支我之前参与的一个报表配置系统刚开始的页面控件结构比较浅就是一个页面下面挂几个区块。后来需求演进了区块里面出现了分组分组里面又出现子分组和多个指标页面结构一下子就复杂了。这时候旧代码的问题就暴露出来了页面解析逻辑里满是instanceof判断每个控件的渲染和取值都要先判断是页面、区块还是分组。举个例子原来的取值代码大概长这样if (element instanceof Page) { ListBlock blocked ((Page) element).getBlocks(); for (Block block : blocks) { if (block instanceof Group) { for (Metric metric : ((Group) block).getMetrics()) { // 处理指标 } } else { // 处理单个指标 } } }加了分组的维度之后这个代码只能硬改而且后续每加一种控件类型这里都要加一个判断分支。代码的维护成本明显升高了团队里几个新人接手的时候都要花不少时间梳理。6.2 用组合模式重构后的形态我们决定用组合模式重构这块逻辑。抽象出一个报表元素接口页面、区块、分组、指标都实现这个接口。页面、区块、分组都是容器节点支持添加和管理子元素指标是叶子节点不可再细分。重构完之后取值的核心逻辑简化成一段递归调用。客户端代码只需要调用顶层元素的统一方法内部自然会把请求分发到子元素上去。后面再增加一种新控件类型只要实现接口并在归属的容器节点里注册主流程代码完全不用动。最让我满意的是报表的权限控制也在这一层一起解决了。每个元素实现统一的权限校验方法叶子节点检查自身权限容器节点先检查自身权限再递归检查子节点的权限逻辑非常清晰。6.3 重构过程中的几个关键决策这次重构里有几个决策是关键。第一个是采用安全式而非透明式因为报表元素之间的类型能力差别很大指标节点和分组节点暴露同一套管理子节点方法会误导使用者。第二个是在容器节点内部统一管理子节点列表避免外层业务逻辑直接操作内部的List结构破坏封装。第三个是页面上的所有元素包含偏移量、大小等布局属性这些属性也统一放在抽象元素里而不是放在各实现类里保证渲染引擎能一致遍历。还有一个重要的决定是我们没有把元素ID的生成逻辑放进组合模式内部而是由外部工厂统一负责。组合模式管树的结构和遍历工厂管对象的创建和ID分配两个关注点分离测试和维护起来都舒服很多。7. 常见问题速查表问题现象可能原因解决思路递归遍历栈溢出树结构出现循环引用add方法中加入祖先链检测导入数据后做全树校验叶子节点被调用add使用了透明式设计切换到安全式将子节点管理方法收窄到容器节点客户端到处是类型判断容器能力暴露不足抽象方法或扩展能力接口把判断收敛到统一入口处修改共享节点导致多处失效节点被多个父节点直接引用改为引用ID或引入轻量代理包装深层树渲染卡顿递归无缓存、重复遍历容器节点增加懒加载缓存控制递归深度新增节点类型需要改主流程主流程存在大量类型分支强化抽象接口能力将分发逻辑下沉到各节点这张表可以说是我用组合模式这几年遇到的高频问题集合了。写代码的时候经常就是这些问题反复出现。每次我都要提醒自己模式不是一份文档背完就能用好的是要在具体业务场景里反复打磨才能驾驭。8. 组合模式与相关模式的边界8.1 组合模式与装饰器模式这两个模式的代码结构非常像都是持有一个或一组同类对象调用的时候把请求转发过去。区别在于目的组合模式是为了表示树形层级结构让客户端一致地访问单个对象和组合对象装饰器模式则是为了给对象动态添加职责而不会影响树的结构。实际使用中装饰器经常被用来包装组合树中的节点。比如文件系统里可以在节点外面加一个带权限校验的装饰器或者加一个带访问日志的装饰器。这些装饰器本身也实现了文件系统节点的接口所以在组合树里可以无缝替换普通节点。8.2 组合模式与迭代器模式组合模式的遍历逻辑可以和迭代器模式结合使用。组合模式定义了节点的结构迭代器负责提供统一的遍历方式。文件系统里用深度优先迭代器可以遍历整棵目录树用广度优先迭代器可以实现按层级逐个处理。我在做报表导出的时候就用过一次这个组合——为控件树实现了一个自定义迭代器让外部代码可以像遍历数组一样遍历整棵控件树不用感知树结构的具体细节。这样导出的代码写起来非常顺手树的嵌套细节都被迭代器屏蔽了。8.3 组合模式与访问者模式访问者模式经常和组合模式搭配用于在不修改节点类的情况下增加新的操作。比如文件系统里要增加一个文件统计功能不用在每个节点类里都加方法而是定义一个新的访问者类统一在处理节点的地方完成操作。不过要小心别过度使用这个组合。访问者模式虽然解耦了操作和数据但也增加了类数量而且破坏了封装——节点需要把自己的内部结构暴露给访问者。只有在操作经常变化、节点结构稳定的场景下访问者模式才有明显的优势。9. 使用组合模式时的设计纪律关于组合模式我总结了几条自己的设计纪律分享给读者朋友第一抽象接口里的方法一定要是所有节点共通的能力。像求大小、展示、渲染这类能力放接口里没问题。像添加子节点、移除子节点这类能力就要在容器节点里单独定义。违反这条纪律就会出现无意义的空实现或者异常实现。第二容器节点的子节点列表一定要封装好。不要让客户端直接拿到底层的List再去操作而是提供受限的add、remove方法。这样才能在方法内部做防环校验、类型校验、以及后续可能的懒加载逻辑。封装一旦破开组合模式的核心完整性就很难保证了。第三递归遍历时要考虑性能。深度大、节点多的时候建议用缓存或者限制深度。尤其注意避免对同一个节点重复发起磁盘IO或网络请求否则性能问题会非常明显。第四尽量保持客户端代码只依赖抽象接口。哪怕底层从ArrayList换成了TreeSet甚至从内存存储换成数据库存储客户端代码不应该有感知。这既是组合模式的设计意图也是任何好设计的通用要求。第五别树懒思路。如果只有两层结构直接循环处理通常更简单。组合模式的抽象成本是要靠复杂树形和统一访问来回收的。最后想说的个人体会学习设计模式最容易犯的错误就是背住定义和类图就以为会了。真正把组合模式用得游刃有余一定要在具体的、有递归层级的数据结构中去练。文件系统、UI组件树、配置树、组织架构树都是非常好的练手场景。如果对组合模式还不太熟建议先自己手写一个菜单树或者文件夹树的例子然后尝试统计总大小、渲染树形结构、再给树加缓存或权限等扩展。跑通之后再去看身边的项目里有哪些树形结构可以拿来重构体会会跟纯看书完全不一样。