ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

组合模式实战:从文件系统到树形结构的统一抽象设计

组合模式实战:从文件系统到树形结构的统一抽象设计 提起组合模式Composite做过文件系统、组织架构、权限目录、商品类目这类功能的后端同学一定不陌生。它是设计模式里的结构型模式核心思路非常朴素把“单个对象”和“由对象聚合而成的组合对象”放进同一个抽象接口里让客户端调用时不需要区分当前操作的是叶子节点还是树状容器节点。换句话说就是用同一种方式对待“个体”和“整体”。正常开发里最容易遇到组合模式原型的场景就是文件目录。一个文件夹下面有文件也有子文件夹子文件夹下面又有文件又有子文件夹。如果把文件夹和文件分开处理每写一个功能就要做一个if (node instanceof Directory)的判断功能一多就全是分支。组合模式的价值就是让文件夹和文件都实现同一个接口文件夹负责把操作递归转给子节点文件负责真正执行操作。这样客户端面对一颗多深多复杂的树都只需要对根节点说一句话。这篇文章适合正在做树形数据结构、递归聚合统计、动态菜单、类目层级这类需求的人也适合想把设计模式从“背概念”变成“会落地”的人。我会先用一个完整的手写文件系统示例把组合模式的实现拆开再把循环引用、栈溢出、遍历顺序这些实战里最容易踩的坑全部讲清楚最后聊聊组合模式跟装饰器、责任链、访问者这几个容易混淆的模式的边界。1. 组合模式解决什么问题树形结构下让“个体”和“整体”同一套接口1.1 一个无处不在的树形需求树形结构在业务系统里到处都是公司组织架构、网站菜单、商品分类、地域层级、权限树、工单分类……只要数据表里有一个parent_id你基本就是在跟树打交道。没有组合模式的时候处理树最常见的方式是写递归函数但递归函数很容易写成“看到一种类型写一个分支”。比如统计一个目录总大小你可能写出这样的伪代码long getTotalSize(Object node) { if (node instanceof File) { return ((File) node).getSize(); } else if (node instanceof Directory) { long total 0; for (Object child : ((Directory) node).getChildren()) { total getTotalSize(child); // 递归计算 } return total; } return 0; }这段代码单独看没毛病但问题在于调用方必须知道节点的具体类型。一旦以后多了一种节点类型比如“快捷方式”“压缩包虚拟目录”你就得回过头来改这段逻辑。而且这种东西往往不止一处统计要写一遍打印要写一遍搜索要写一遍每个功能都变成一棵if-else圣诞树。组合模式换了个思路既然“文件”和“文件夹”本质上都可以回答“你叫什么、你多大”这类问题那就抽一个接口出来让文件夹对子节点的调用统一走接口方法剩下的递归由节点自己完成。我经常用公司通知做类比。总经办要发一个全员通知如果不统一抽象就得先找各部门负责人再让负责人逐个找员工不同层级的人处理方式完全不同。而组合模式相当于总经办只需要把通知发给“公司”这个容器节点容器会自动把通知转发给下面所有部门和员工下级容器接力转发直到叶子员工收到为止。对外表现就是“只要你发给根节点整棵树都会响应”。1.2 透明式还是安全式两种实现风格的取舍组合模式在实现上有一个绕不开的设计分歧增删子节点的方法到底放在哪。透明式Transparent在抽象节点接口里直接声明add()、remove()、getChild()这些结构方法叶子节点也要实现这些方法。叶子没有子节点所以通常的做法是空实现或者直接抛异常。安全式Safe抽象接口只定义公共业务方法比如getName()、getSize()而add()、remove()、getChild()只放在容器节点类里。叶子节点根本没有这些方法结构上更安全。两种方式我一直用下面这张表去权衡对比维度透明式安全式客户端代码统一性高叶子容器都当同一类型用低想操作容器特性还得instanceof编译期安全性低叶子也有add()误调用到运行期才报错高叶子压根没有add()编译期就拦住接口语义接口偏重叶子被迫实现无关方法接口精简符合接口隔离原则适用场景框架型、通用树结构追求调用方统一业务内部模块重视健壮性和可维护性我的实际建议是自己在业务代码里写优先选安全式。因为透明式虽然看起来客户端很爽但团队成员水平参差不齐很容易有人在遍历的时候对叶子调用add()然后等到线上运行才抛异常。安全式把问题提前到了编译期省心。不过下面这个实战示例我决定用透明式来演示。主要原因是透明式的代码结构更能直观展示“统一抽象”这件事FileSystemNode接口里同时包含业务方法和结构方法读者一眼就能看清组合模式的全貌。实际项目里选哪种上面那张表已经给结论了。1.3 递归是组合模式的灵魂组合模式之所以能成立本质上是因为树本身就是递归定义的一个文件夹由一个名字和一组“文件或文件夹”组成一个文件就是一个文件不可再分。所以getSize()可以递归定义为如果当前节点是叶子返回自己的大小。如果当前节点是容器返回所有子节点的getSize()之和。这个定义放在客户端代码里就是一种策略放在组合模式里就成了每个节点自己的方法。容器节点做“我 我的孩子”的汇总叶子节点做“直接返回自己”。调用方不需要知道这一切递归的入口就是根节点的一次方法调用。理解了这个组合模式的代码就顺理成章了。2. 从零手写文件目录树组合模式实战实现这一节我直接带大家写一个完整可跑的简化版文件系统。需求很清晰给定一个目录树能够打印出整棵目录结构并且计算出任意目录总大小。我们先用透明式实现然后再深入讲每个类为什么要这么写。2.1 先定义统一的节点接口所有节点不管是文件还是目录都实现同一个接口。接口里包含两类方法一类是信息方法一类是结构方法。public interface FileSystemNode { String getName(); // 返回节点名称 long getSize(); // 返回节点大小文件是自己的大小目录是递归汇总大小 void display(String prefix); // 以指定缩进打印节点信息 void add(FileSystemNode child); // 添加子节点文件节点不支持 void remove(FileSystemNode child); // 删除子节点文件节点不支持 }接口设计有个小细节值得注意display方法带上prefix参数。这个参数在递归时用来控制缩进每往下一层调用方就在原前缀基础上多加两个空格。这样打印出来的目录树才可读否则全挤在第一列根本看不出层级关系。add()和remove()是透明式的标志。前面说了叶子节点也会“被迫”实现这两个方法具体怎么做下一个类会演示。2.2 叶子节点实现只关心自己的值文件节点是树的终点它只有名字和大小没有孩子。代码非常简单public class FileLeaf implements FileSystemNode { private final String name; private final long size; public FileLeaf(String name, long size) { this.name name; this.size size; } Override public String getName() { return name; } Override public long getSize() { return size; } Override public void display(String prefix) { System.out.println(prefix name ( size bytes)); } Override public void add(FileSystemNode child) { throw new UnsupportedOperationException(文件节点不支持添加子节点); } Override public void remove(FileSystemNode child) { throw new UnsupportedOperationException(文件节点不支持删除子节点); } }文件节点就是纯数据载体。getSize()直接返回自身字段这是整棵递归树的“出口”。如果全是一层套一层的容器递归永远没有结束条件那就成死循环了。所以叶子节点的实现必须保持简单——它的任务就是让递归在这里终止。add()和remove()直接抛UnsupportedOperationException这是透明式的标准做法。因为接口规定了这两个方法叶子又不符合这个语义所以用异常明确表达“我不会支持这个操作”。注意异常消息一定写清楚原因否则线上排查的时候看到一句干巴巴的UnsupportedOperationException还得翻代码才能定位是哪个节点抛的。2.3 容器节点实现把计算交给子节点目录节点是组合模式的核心它内部维护一个子节点列表对外把操作递归分发给所有子节点。public class Directory implements FileSystemNode { private final String name; private final ListFileSystemNode children new ArrayList(); public Directory(String name) { this.name name; } Override public String getName() { return name; } Override public long getSize() { long total 0; for (FileSystemNode child : children) { total child.getSize(); } return total; } Override public void display(String prefix) { System.out.println(prefix name /); for (FileSystemNode child : children) { child.display(prefix ); } } Override public void add(FileSystemNode child) { children.add(child); } Override public void remove(FileSystemNode child) { children.remove(child); } }这个类的灵魂只有一行total child.getSize();这一行调用的时候child可能是文件也可能是另一个目录。文件会立刻返回自身大小目录则会继续遍历自己的孩子一层一层往下算。对外部调用者来说它不需要关心child到底是什么组合模式把所有差异消化掉了。display()同理先打印自己然后对每个孩子调用display(prefix )。孩子如果是文件打印一行文件信息孩子如果是目录就会继续打印它的下一层。这是典型的先序遍历符合“先展示目录本身再展示内部内容”的阅读习惯。2.4 组装与调用客户端只认识根节点上面类和接口都写好了下面演示怎么使用。public class CompositeDemo { public static void main(String[] args) { Directory user new Directory(user); Directory docs new Directory(docs); docs.add(new FileLeaf(readme.md, 12)); docs.add(new FileLeaf(design.md, 88)); Directory src new Directory(src); Directory main new Directory(main); Directory javaDir new Directory(java); javaDir.add(new FileLeaf(App.java, 1024)); javaDir.add(new FileLeaf(Util.java, 2048)); main.add(javaDir); src.add(main); Directory test new Directory(test); test.add(new FileLeaf(AppTest.java, 512)); user.add(docs); user.add(src); user.add(test); // 客户端只调用根节点 System.out.println(总大小: user.getSize() bytes); user.display(); } }运行这段代码getSize()会从user开始递归统计docs、src、test三个子目录其中src下面又会递归进入main、java最终把所有文件大小汇总起来。输出目录树大概长这样user/ docs/ readme.md (12 bytes) design.md (88 bytes) src/ main/ java/ App.java (1024 bytes) Util.java (2048 bytes) test/ AppTest.java (512 bytes)这个示例里最值得体会的是main方法整个客户端代码从头到尾没有出现一次instanceof没有出现if (node instanceof FileLeaf) ... else if (node instanceof Directory) ...。所有操作都建立在FileSystemNode接口之上这就是组合模式最大的价值——调用方不需要理解树结构根节点一次调用整棵树自动协作完成工作。如果不用组合模式去写这个统计逻辑代码会变成什么样子呢最直接的办法就是写一个工具类里面方法接收Object然后用instanceof判断类型再挨个遍历这和前面展示的伪代码一样。问题在于这种代码的“知识”全集中在调用方而不是均匀分布在树结构里一旦树的类型扩展、层级变深维护成本会指数上涨。组合模式把“我该怎么处理我下面这些孩子”这个知识封装进了每个容器节点调用方永远只需要知道根节点。3. 组合模式踩坑实录循环引用、栈溢出与遍历顺序组合模式代码写起来很爽但实战中隐藏的坑一点不少。这里把我踩过的几个问题完整复盘一下。3.1 递归太深栈溢出怎么办组合模式的天然实现就是递归递归就一定会遇到栈深度问题。如果目录树层级很深或者某个场景下树特别“瘦长”一次性递归个几千层Java 默认的线程栈就会抛出StackOverflowError。我遇到过最夸张的一次是有人把商品类目树从接口层到数据库反复组装一棵树组装出了上万层嵌套。这种深度不可能靠“优化递归”解决唯一的办法就是换一种遍历方式把隐式的调用栈改成显式的栈。显式迭代版getSize()可以直接放在目录类里作为替代实现public long getSizeIterative() { long total 0; DequeFileSystemNode stack new ArrayDeque(); stack.push(this); while (!stack.isEmpty()) { FileSystemNode node stack.pop(); if (node instanceof Directory) { Directory dir (Directory) node; for (int i dir.children.size() - 1; i 0; i--) { stack.push(dir.children.get(i)); } } else { total node.getSize(); } } return total; }这段代码的instanceof是有意为之的因为只有容器节点才需要“展开”。这里也显示了安全式设计的一点代价如果你拿安全式做递归遍历想区分叶子还是容器往往要借助一个isLeaf()标志或直接判断类型。不是说你用了安全式之后instanceof就永别了而是把类型判断限制在了遍历框架内部而不是散落在每一处业务逻辑里。用显式栈之后遍历深度只受堆内存约束不再受线程栈大小限制。实际项目中如果树特别深我建议直接用这种迭代式的写法或者干脆把树扁平化成列表再处理从根源上避开栈溢出。3.2 循环引用让程序死循环这个坑比栈溢出更隐蔽也更危险。目录树模型里如果存在循环引用比如A目录包含了B目录B目录又包含了A目录那么调用A.getSize()时流程会不停地在 A 和 B 之间循环永远算不完。更极端的还有节点把自己加进自己的 children。组合模式本身没有规定“树必须无环”它默认你构建的是一棵真正的树。但业务数据可没有这么自觉比如数据从数据库查出来之后由于脏数据或者人为配置错误完全可能产生环。我自己的处理方案有两个第一个是在add()里做环检测。新加入的子节点如果是当前节点的祖先直接拒绝。实现思路是顺着当前节点的父指针向上找或者用一个独立的isAncestor方法递归判断。第二个方案是遍历的时候带上“访问过”标记适用于数据已经存在、不方便改添加逻辑的场景。比如在getSize()里传一个SetFileSystemNode visited碰到已经访问过的节点就跳过或直接报错。真正优雅的解法是“源头控制”在设计add()时就约定好父节点校验逻辑。宁可添加方法多写两行判断也不要在运行到统计时才发现死循环。3.3 空实现导致运行期异常透明式组合模式里叶子节点必须实现add()和remove()。最常见但最怕的就是写着写着偷懒把叶子节点的这两个方法写成空方法体什么都不做。这会导致什么后果客户端代码遍历整棵树时如果对每个节点统一调用add()叶子节点的add()会“假成功”但什么都没发生。这种静默失败非常难排查因为没有任何异常数据也没变但程序行为就是不对。我强烈建议叶子节点的无效结构方法一律抛异常而且异常消息要写清楚。比如throw new UnsupportedOperationException(文件节点不支持添加子节点请检查是否为目录节点);这样一旦调用方误操作程序会立刻失败日志里还能直接看到原因。快速失败永远优于静默失败这是我在生产环境里用血泪教训换来的经验。3.4 问题排查速查表现象可能原因解决方向程序栈溢出树层级过深递归调用层级过多换显式栈迭代限制树深考虑扁平化程序卡死、CPU 飙高存在循环引用递归无终止条件add()时做祖先检测遍历时加访问集合叶子节点 add 无效空心方法静默失败叶子节点抛UnsupportedOperationException目录树打印层级混乱遍历顺序和缩进逻辑有问题先序打印父节点再递归子节点缩进用 prefix 控制统计结果少算或重复算节点被多个父节点引用检查数据来源保证每个节点只有一个父节点容器节点类型判断过多安全式调用方用 instanceof 处理业务考虑把操作封装回节点方法或引入访问者模式4. 组合模式与装饰器/责任链/访问者如何区分与选型容易和组合模式混淆的模式主要是装饰器、责任链和访问者。这几个模式结构上都有点“链式/嵌套”的味道但设计意图完全不同。4.1 组合模式 vs 装饰器模式聚合与包装装饰器模式也是层层嵌套的经典例子是给咖啡加奶、加糖。MilkDecorator内部包了一个Coffee对象调用cost()时在原有价格上加上牛奶的价格。从 UML 类图上看装饰器和组合模式都是“自己包含同类型对象”很容易看混。但意图差别巨大装饰器的目的是给单个对象动态增强功能它包装的是“同一个对象”不是聚合一堆对象。组合模式的目的是管理一组对象并统一访问它包含的是一个对象集合。换成人话装饰器是“我帮你把咖啡变得更贵、更好喝”组合模式是“我是文件夹我里面装了一堆文件和子文件夹”。实际选型很简单如果是对单个对象做功能叠加用装饰器如果是对一组对象做统一管理、批量操作用组合模式。两者也可以配合使用比如在文件树上加一个“加密文件夹装饰器”既属于文件夹组合又给子节点读写加了加密功能。这属于模式组合的进阶玩法日常能用到但不用强求。4.2 组合模式 vs 责任链树形与链式责任链模式里也有“链条”比如审批流程中一级主管审批不了就传给二级主管二级不行再传给三级。从表面看这跟树的递归遍历也有点像但责任链的核心是每个节点只处理自己关心的请求处理不了时把请求转发给下一个节点而且通常只有一个节点最终处理请求。组合模式的递归则是每个层级的节点都参与操作容器节点把操作广播给所有孩子孩子们各自完成各自的动作。一个是“请找下一个能处理的人”一个是“转发给所有下属一起去处理”方向就是“独占”和“广播”的区别。所以如果你要解决的问题是“谁有能力谁去处理”优先考虑责任链如果是“所有层级都给我动起来”那就是组合模式。4.3 组合模式 访问者模式把操作从结构中抽离组合模式最让我头疼的地方其实不是结构设计本身而是操作越来越多。今天要getSize()明天要display()后天要“导出 zip”大后天要“清理临时文件”。如果全都写成每个节点的方法每次加一个操作就要改动所有节点类没完没了。这时候访问者模式就是组合模式的最佳搭档。可以让树结构保持稳定把“操作”本身抽成一个Visitor对象public interface FileVisitor { void visitFile(FileLeaf file); void visitDirectory(Directory dir); }每个节点实现一个accept(FileVisitor visitor)方法文件节点调用visitor.visitFile(this)目录节点先调用visitor.visitDirectory(this)再遍历孩子。以后新增一个操作比如“统计文件总数”只需要再写一个FileCountVisitor类树结构代码完全不用动。这个组合在开源框架里很常见比如 AST 解析、文件树扫描、编译器等。我自己在做菜单权限导出功能时也用过这个套路把“菜单树结构”和“导出格式”解耦。4.4 什么时候不要用组合模式组合模式确实通用但不是所有层级结构都适合无脑上模式。有三种情况我建议放弃组合模式第一树的层级固定且很浅比如只有“省-市”两层。这时候直接写两个类就完了强行抽象一个接口反而增加理解成本。第二叶子节点和容器节点的业务行为差异巨大几乎没有公共操作可以抽象。比如“文件”就是二进制内容“目录”就是一堆元数据两者唯一的共同点是名字。硬凑接口只会设计出又空又大的上帝接口。第三业务场景根本不需要“统一调用”。如果你从头到尾都只遍历容器节点叶子节点完全是另一种处理流程那组合模式带来的接口统一收益就很有限。我见过最夸张的反模式案例是有人把数据库里一张平铺的标签列表强行构造成一棵树就为了用组合模式。结果原本一张表select *就能解决的问题变成了先建树、再递归、再遍历最后还得处理环。这种“为了模式而模式”的做法比不用模式还糟糕。最后分享一点我的个人体会组合模式是我在实际开发中用得最多的结构型模式之一菜单权限、文件扫描、组织架构、类目筛选几乎天天见。但我发现很多人学它的时候只记住了“抽象节点接口 叶子 容器”这个骨架真正到了项目里却不知道该在哪里用。我的经验是判断要不要用组合模式就看一句话你的数据是不是天生递归定义的你处理数据的方式是不是希望一层一层往下广播。如果是组合模式会帮你把复杂度收进节点内部如果不是别硬凑。另外如果你第一次接触这个模式建议一定自己动手把文件系统这个例子敲一遍然后想办法加上“删除某个目录”“搜索某个文件名”“统计里面有多少个文件”这些功能。跑通一次递归再对照着看透明式和安全式的差异理解就会扎实很多。模式这东西看懂了不算会写出能跑的代码才算入门。
返回列表