每天认识一个设计模式-组合模式:树形结构的统一接口艺术

一、前言

搞软件开发就像走一段很长的路,设计模式就像天上闪亮的星星,总能给我们指引方向。讲完桥接模式和过滤器模式之后,这个系列接着带大家探索结构型设计模式这个神奇的领域。今天,咱们就专门来聊聊组合模式,看看它到底是怎么回事。​

二、组合模式的基础介绍

组合模式(Composite Pattern),又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次。这种类型的设计模式属于结构型模式,它创建了对象组的树形结构。

这种模式创建了一个包含自己对象组的类。该类提供了修改相同对象组的方式。其中递归算法是其核心灵魂,它如同递归的精灵,穿梭于树形结构的每一个节点,让复杂的层次结构变得简单可控。​

生活里,组合模式那可是到处都有。就说企业的组织架构吧,公司是由好多部门组成的,每个部门又有不少员工,这完全就是个典型的树形结构。用组合模式的话,咱可以把公司、部门还有员工都看成是节点,用一样的方法去操作和管理,轻轻松松就能实现对组织架构的遍历、查询和调整。

而在文件系统中,文件夹和文件的那种层次化管理,也少不了组合模式帮忙。文件夹能装好多文件和子文件夹,文件就是最底层的叶子节点,有了组合模式,我们操作文件系统就很方便啦,像遍历目录、查找文件这些操作都不在话下。

三、组合模式原型设计:统一接口与递归结构

组合模式的核心目标是通过统一接口处理树形结构中的叶子节点(Leaf)和容器节点(Composite),无论是叶子节点还是容器节点,都能以一致的方式进行操作。这种设计思想使得代码更加简洁、灵活,易于扩展。让我们通过 UML 类图来深入了解组合模式的结构。

在这个类图中,我们可以清晰地看到三个核心角色:​

组件(Component)::定义了组合中所有对象的通用接口,可以是抽象类或接口。它声明了用于访问和管理子组件的方法,包括添加、删除、获取子组件等。

其中,operation()方法是核心操作,所有节点都必须实现,用于执行具体的业务逻辑。而add(Component)和remove(Component)方法则用于管理子节点,不过在抽象接口中,这两个方法被声明为抽象的,具体的实现由子类完成。这是因为叶子节点没有子节点,不需要实现这些方法,而容器节点则需要根据自身的逻辑来实现它们。

在实际应用中,operation()方法可能会根据不同的业务场景进行定制,比如在文件系统中,它可以表示文件的读取、写入等操作;

叶子节点(Leaf):表示组合中的叶子节点对象,叶子节点没有子节点。它实现了组件接口的方法,但通常不包含子组件。

叶子节点是树形结构的末端,因此只需要实现operation()方法,完成自身的基础操作。

叶子节点代表了最基本的元素,它们是整个树形结构的基础。在文件系统中,文件就是典型的叶子节点,它们没有子节点,只有自身的内容和属性。

复合节点(Composite):表示组合中的复合对象,复合节点可以包含子节点,可以是叶子节点,也可以是其他复合节点。它实现了组件接口的方法,包括管理子组件的方法。

Composite类中维护了一个List<Component>类型的children列表,用于存储子节点。它实现了operation()方法,在执行该方法时,会递归地调用所有子节点的operation()方法,从而实现对整个树形结构的统一操作。同时,它还实现了add(Component)和remove(Component)方法,用于添加和删除子节点。

在文件系统中,文件夹就是容器节点,它可以包含多个文件和子文件夹。

四、组合模式的设计策略介绍:透明模式 vs 安全模式详解

组合模式在实现方式上存在两种设计策略:透明模式和安全模式。

透明模式以接口统一性为核心,在抽象构件(Component)中直接声明所有子节点管理方法(如add()remove()),然后通过如下方式进行实现:

  • Leaf(叶子节点)需空实现或抛出异常这些方法(例如调用add()时抛出UnsupportedOperationException)。

  • Composite(容器节点)正常实现子节点管理逻辑。

// 透明模式下的Component接口
public abstract class Component {
    public abstract void operation();
    
    // 子节点管理方法定义在抽象层
    public void add(Component component) {
        throw new UnsupportedOperationException();
    }
    public void remove(Component component) {
        throw new UnsupportedOperationException();
    }
}

// Leaf实现(需空实现/异常处理)
public class Leaf extends Component {
    @Override
    public void operation() {
        System.out.println("执行叶子节点操作");
    }
    
    @Override
    public void add(Component component) {
        throw new UnsupportedOperationException("叶子节点不支持添加子节点");
    }
}

// Composite实现
public class Composite extends Component {
    private List<Component> children = new ArrayList<>();
    
    @Override
    public void operation() {
        System.out.println("执行容器节点操作");
        children.forEach(Component::operation);
    }
    
    @Override
    public void add(Component component) {
        children.add(component);
    }
}

这样做的好处是,客户端无需区分操作的是叶子节点还是容器节点,使用起来极为方便,具有极高的透明性,并且呢符合开闭原则:新增节点类型时,客户端逻辑无需修改。

但是叶子节点实际上并不具备管理子对象的能力,却要实现这些方法,可能导致代码冗余和逻辑混乱。也存在运行时风险,比如客户端可能误对Leaf调用add(),需通过异常处理规避。最关键是他违反接口隔离原则Leaf被迫实现无意义的接口方法(如add())。

而安全模式则不同,它将管理子对象的方法(add()remove())只在容器节点(Composite)中实现,叶子节点(Leaf)不会包含这些管理方法,也无需处理子节点相关逻辑,保持接口纯净。这样保证了系统的安全性,不会出现叶子节点调用不适合方法的错误情况;

// 安全模式下的Component接口
public interface Component {
    void operation();
}

// Leaf实现(仅需关注自身操作)
public class Leaf implements Component {
    @Override
    public void operation() {
        System.out.println("执行叶子节点操作");
    }
}

// Composite实现(独立管理子节点)
public class Composite implements Component {
    private List<Component> children = new ArrayList<>();
    
    @Override
    public void operation() {
        System.out.println("执行容器节点操作");
        children.forEach(Component::operation);
    }
    
    // 子节点管理方法仅在此定义
    public void add(Component component) {
        children.add(component);
    }
}

这样Leaf仅实现必要接口,无冗余方法,符合接口隔离原则并且客户端无法对Leaf调用add(),避免运行时错误,使得编译时更安全相对应的,其缺点就是客户端需区分Leaf与Composite操作子节点时必须先判断对象类型。同时客户端可能频繁使用instanceof检查,降低可维护性而造成代码冗余

这两种模式的本质区别在于对接口职责的划分客户端使用成本的权衡。

透明模式倾向于简化客户端的使用,将接口职责宽泛化,以牺牲部分代码合理性为代价

安全模式则更注重系统内部的安全性和逻辑严谨性,将接口职责明确划分,却增加了客户端使用的复杂性。

我们在实际应用中,需要可以根据我们自己具体的业务场景和需求来选择合适的设计策略 :

维度透明模式安全模式
接口设计统一但冗余精简且职责清晰
客户端复杂度无需类型判断,代码简洁需类型判断,逻辑复杂
设计原则违反接口隔离原则符合单一职责、接口隔离原则
适用场景客户端无需感知节点差异的简单树形结构需严格区分节点类型的复杂系统

五、组合模式的适用场景与开源实践 

5.1. 典型场景

  • 树形数据递归操作:权限菜单、组织架构、XML/JSON解析。

在树形数据递归操作上,权限菜单是典型场景。大型企业级应用的权限菜单是多层次树形结构,通过组合模式将菜单和权限项统一视为节点,用相同接口操作,进行权限验证、菜单展示等操作时,可递归调用统一接口遍历菜单树,无需关心节点具体类型和层次结构。处理组织架构同理,把公司、部门和员工看作树形节点,通过统一接口管理操作,如计算员工总数、统计部门薪资支出等。​

XML/JSON 解析也适用组合模式。XML 和 JSON 数据以树形结构表示,将其中元素和属性抽象为树形节点,用统一接口解析处理,无论数据简单还是复杂嵌套,都能通过递归操作统一接口完成,简化解析过程。

  • UI组件嵌套:如Swing的JPanelJComponent继承体系。

在 UI 组件嵌套场景,Swing 的 JPanel 与 JComponent 继承体系是组合模式经典应用。JPanel 是容器组件可包含多个 JComponent,JComponent 又可以是其他容器或具体 UI 组件。通过组合模式,JPanel 和 JComponent 实现统一接口,构建复杂用户界面时,可递归添加管理 JComponent 组件,创建多层次 UI 结构,通过统一接口设置属性、布局和事件处理 。

5.2. 开源框架应用

在众多开源框架中,组合模式也得到了广泛的应用,它为框架的设计和实现提供了强大的支持,使得框架能够更加灵活、高效地处理复杂的业务逻辑。

JavaFX 作为 Java 平台上的一个富客户端应用开发框架,其 Parent 与 Node 的容器 - 组件设计就巧妙地运用了组合模式。

在 JavaFX 中,Node 是所有可视化对象的基类,它定义了一些通用的属性和方法,如位置、大小、可见性等。而 Parent 类则继承自 Node,它是一种特殊的节点,能够包含其他节点,类似于组合模式中的容器节点。通过这种设计,JavaFX 构建了一个树形的场景图,其中每个 Parent 节点可以包含多个子节点,这些子节点可以是普通的 Node 节点,也可以是其他 Parent 节点,从而形成了一个复杂的层次结构。在实际应用中,我们可以通过统一的接口来操作这些节点,无论是添加、删除节点,还是设置节点的属性,都可以通过调用 Node 接口中定义的方法来实现。

import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;

public class JavaFXExample extends Application {

    @Override
    public void start(Stage primaryStage) {
        // 创建一个Group,它是Parent的子类,作为容器节点
        Group root = new Group();

        // 创建两个Circle,它们是Node节点
        Circle circle1 = new Circle(50, 50, 25);
        Circle circle2 = new Circle(150, 50, 25);

        // 将Circle添加到Group中
        root.getChildren().add(circle1);
        root.getChildren().add(circle2);

        // 创建场景并设置给舞台
        Scene scene = new Scene(root, 300, 250);
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

这里Group 类继承自 Parent,它作为一个容器节点,通过 getChildren().add() 方法添加 Circle 类型的子节点,这正是组合模式在 JavaFX 中的具体体现,通过递归地调用子节点的方法,就能实现对整个场景图的遍历和操作 。这种设计使得 JavaFX 的界面构建更加灵活和高效,能够满足各种复杂的用户界面需求。除此以外常见的还有SpEL。

Spring Expression Language (SpEL) 是 Spring 框架中的一个强大的表达式语言,它在复合表达式解析中也有运用了组合模式。

SpEL 允许我们在运行时动态地解析和执行表达式,这些表达式可以包含各种运算符、函数和对象引用。在解析复合表达式时,SpEL 将表达式视为一个树形结构,其中每个运算符、函数和对象引用都作为一个节点。例如,对于表达式 “a and b or c”,SpEL 会将 “and”、“or” 视为组合节点,将 “a”、“b”、“c” 视为叶子节点,通过组合模式来构建和解析这个表达式树。在解析过程中,SpEL 会递归地处理每个节点,根据节点的类型和运算符的优先级来计算表达式的值。

import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;

public class SpELExample {
    public static void main(String[] args) {
        // 创建表达式解析器
        ExpressionParser parser = new SpelExpressionParser();

        // 解析表达式
        Expression expression = parser.parseExpression("2 + 3 * 4");

        // 计算表达式的值
        Integer result = expression.getValue(Integer.class);
        System.out.println(result);
    }
}

 SpelExpressionParser 负责解析表达式,表达式中的运算符 + 和 * 以及数字 2、3、4 会被构建成一个树形结构,按照组合模式的规则进行解析计算。

在 Spring 的配置文件中,我们可以使用 SpEL 表达式来动态地设置 Bean 的属性值,通过组合模式的解析机制,SpEL 能够准确地计算表达式的值,并将其应用到相应的 Bean 属性上 ,为 Spring 框架的配置、数据绑定、AOP 等功能提供了强大的支持。

六、简易的组合模式应用实战分析:动态菜单树构建

在后台管理系统中,菜单是用户与系统交互的重要入口,它不仅要清晰地展示系统的功能结构,还要满足不同用户的权限需求和操作习惯。因此,支持多级菜单的动态配置成为了后台管理系统的一项关键需求。​

以 “系统管理> 权限管理 > 角色管理” 这样的菜单结构为例,用户在系统管理模块下,可以进一步访问权限管理子模块,而在权限管理子模块中,又可以找到角色管理这一具体功能。这种多级菜单的设计,使得系统的功能层次更加分明,用户能够更方便地找到自己需要的功能。同时,为了适应不同的业务场景和用户需求,菜单的配置需要具备动态性,即可以根据用户的权限、系统的配置等因素进行实时调整。比如,对于普通用户,可能只展示基本的功能菜单,而对于管理员用户,则可以展示所有的高级管理功能菜单。

为了简易实现动态菜单树的构建(非纯开发场景,后续有空带来实际应用),我们使用 Spring Boot 框架,结合组合模式的思想,通过 Java 来完成这一功能。​

首先,定义抽象构件MenuComponent,它是所有菜单组件的抽象接口,定义了一个display(int level)方法,用于展示菜单,其中level参数表示菜单的层级,通过缩进的方式来体现菜单的层次结构。

// 抽象构件(安全模式)  
public interface MenuComponent {
    void display(int level);
}

接着,实现叶子节点MenuItem,它代表具体的菜单项,实现了MenuComponent接口。在display(int level)方法中,根据传入的层级参数level,使用空格进行缩进,并在菜单项前添加一个 “📎” 图标(正常应从数据库存储标签值后通过前端渲染),以直观地表示这是一个具体的菜单项。

// 叶子节点  
@Component  
public class MenuItem implements MenuComponent {
    private String name;

    public MenuItem(String name) {
        this.name = name;
    }

    @Override
    public void display(int level) {
        System.out.println("  ".repeat(level) + "📎 " + name);
    }
}

 然后,实现容器节点MenuGroup,它可以包含多个子菜单组件,无论是叶子节点还是其他容器节点。在MenuGroup类中,维护了一个List<MenuComponent>类型的children列表,用于存储子菜单组件。

通过add(MenuComponent component)方法,可以向菜单组中添加子菜单组件。在display(int level)方法中,首先根据层级参数level进行缩进,并在菜单组前添加一个 “📁 菜单组” 的标识,然后递归地调用children列表中每个子菜单组件的display(int level + 1)方法,以展示整个菜单树的结构。

// 容器节点  
@Component  
public class MenuGroup implements MenuComponent {
    private List<MenuComponent> children = new ArrayList<>();

    public void add(MenuComponent component) {
        children.add(component);
    }

    @Override
    public void display(int level) {
        System.out.println("  ".repeat(level) + "📁 菜单组");
        children.forEach(child -> child.display(level + 1));
    }
}

最后,通过 Spring 的配置类MenuConfig,使用 Spring IoC 容器来自动装配动态菜单树。在rootMenu()方法中,创建了一个根菜单组root,并向其中添加了 “首页” 菜单项和一个系统管理菜单组systemMenu。

在系统管理菜单组中,又添加了 “用户管理” 和 “角色管理” 两个菜单项。通过这种方式,动态地构建了一个多级菜单树。

// Spring装配(动态构建树)  
@Configuration  
public class MenuConfig {
    @Bean
    public MenuComponent rootMenu() {
        MenuGroup root = new MenuGroup();
        root.add(menuItem("首页"));
        MenuGroup systemMenu = new MenuGroup();
        systemMenu.add(menuItem("用户管理"));
        systemMenu.add(menuItem("角色管理"));
        root.add(systemMenu);
        return root;
    }

    @Bean
    public MenuItem menuItem(String name) {
        return new MenuItem(name);
    }
}

整体上我们通过MenuComponent统一接口,客户端在调用display()方法时,无需关心菜单组件是叶子节点还是容器节点,也无需区分菜单层级,只需要调用统一的接口,即可实现对整个菜单树的展示。

同时,利用 Spring IoC 容器的自动装配功能,使得菜单树的构建更加灵活和可扩展。当需要添加新的菜单项或菜单组时,只需要在配置类中进行相应的修改,而无需修改核心的菜单展示逻辑,大大提高了系统的可维护性和扩展性。

七、总结

可以看到,组合模式作为一种强大的设计模式,在软件开发中还是展现出了极高的价值的。整体来看,它的核心价值主要体现在一致性递归扩展性两个方面。​

  1. 一致性是组合模式的一大显著优势。通过为叶子节点和容器节点提供统一的接口,组合模式使得客户端能够以一致的方式处理单对象与组合对象。
  2. 递归扩展性是组合模式的另一核心价值。组合模式允许将对象组合成树形结构,并且在容器节点中可以递归地包含其他容器节点或叶子节点。这使得在新增节点类型时,只需扩展相应的叶子节点或容器节点类,而无需修改核心逻辑,符合开闭原则。

当然了在我们实际的应用过程中还需要注意如下几点:

⚠️当系统需要隐藏层次差异,并且客户端需要一致对待单对象与组合对象时,组合模式是一个非常合适的选择

比如在一个大型的电子商务系统中,商品目录通常是一个多层次的树形结构,包括一级分类、二级分类、三级分类以及具体的商品。使用组合模式,我们可以将商品目录和商品统一视为节点,通过统一的接口进行操作。这样,在展示商品目录、查询商品信息等操作时,客户端无需关心节点是商品目录还是具体商品,只需调用统一的接口即可。​

⚠️在使用组合模式时,需要特别注意避免循环引用的问题

循环引用可能会导致系统在遍历树形结构时陷入死循环,从而消耗大量的资源,甚至导致系统崩溃。为了避免这种情况,我们可以在添加子节点时进行检查,确保不会出现循环引用。一种常见的方法是在添加子节点之前,先检查该子节点是否已经是当前节点的祖先节点,如果是,则禁止添加。此外,我们还可以在系统运行时,定期进行树形遍历检测,及时发现和处理可能存在的循环引用问题。​

⚠️在选择组合模式的实现方式时,优先选择安全模式可以有效地降低接口冗余

如前面章节所述,安全模式将子节点管理方法只定义在 Composite 类中,避免了 Leaf 类实现不必要的方法,从而减少了接口的复杂性。而具体的业务选择还需要大家自行斟酌~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

深情不及里子

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值