文章目录
- 一、概述
-
- [1.1 结构与角色](#1.1 结构与角色)
- [1.2 适用场景](#1.2 适用场景)
- 二、实现方式
-
- [2.1 透明组合](#2.1 透明组合)
- [2.2 安全组合](#2.2 安全组合)
- [2.3 透明组合 vs 安全组合](#2.3 透明组合 vs 安全组合)
- [三、JDK 源码中的组合模式](#三、JDK 源码中的组合模式)
-
- [3.1 AWT/Swing 组件体系](#3.1 AWT/Swing 组件体系)
- [3.2 Java NIO 文件系统](#3.2 Java NIO 文件系统)
- [3.3 MyBatis SqlNode 体系](#3.3 MyBatis SqlNode 体系)
- 四、总结
一、概述
在软件开发中,经常会遇到这样的场景:需要处理一种树形结构 的数据,其中包含"容器"节点和"叶子"节点两种类型。例如,文件系统中的文件夹(容器)和文件(叶子)、组织架构中的部门(容器)和员工(叶子)、GUI 中的容器组件(容器)和基本组件(叶子)。如果将容器和叶子区别对待,客户端就需要使用 if-else 或 instanceof 来判断节点类型,代码既复杂又难扩展。
组合模式(Composite Pattern)正是为了解决这个问题而诞生的------它将对象组合成树形结构 以表示"部分-整体"的层次关系,使得客户端对单个对象和组合对象的使用具有一致性。组合模式让客户端可以统一地对待单个对象和组合对象,而不需要关心它们的具体类型。
生活中的组合模式例子比比皆是:
- 文件系统:文件夹可以包含子文件夹和文件,文件是最小单位。用户对文件夹和文件执行"复制""删除"操作时,不需要区分两者
- 组织架构:公司由部门组成,部门又可以包含子部门和员工。计算部门人数时,叶子节点(员工)和组合节点(子部门)的统计方式可以统一处理
- 菜单系统:菜单可以包含子菜单和菜单项,用户点击菜单时的交互方式是一致的
核心:将对象组合成树形结构以表示"部分-整体"的层次关系,使得客户端对单个对象和组合对象的使用具有一致性
1.1 结构与角色
组合模式包含以下角色:
实现
实现
持有
包含
包含
Client 客户端
Component 抽象构件
Leaf 叶子构件
Composite 组合构件
Composite 子组合
- Component(抽象构件):为叶子构件和组合构件声明公共接口,可以提供默认行为
- Leaf(叶子构件):表示叶子节点,没有子节点,定义组合中最基本的行为
- Composite(组合构件):表示容器节点,可以包含子节点(叶子或其他组合),提供添加、删除子节点的功能,并委托子节点执行操作
- Client(客户端):通过抽象构件接口统一地操作叶子构件和组合构件
1.2 适用场景
- 需要表示对象的部分-整体层次结构(树形结构)
- 希望客户端统一地对待单个对象和组合对象,而不需要区分它们的类型
- 需要动态地组合对象,且组合的方式可以递归嵌套
二、实现方式
组合模式有两种实现方式:透明组合和安全组合。两者的核心区别在于:管理子节点的方法(add、remove、getChild)放在哪里------是放在抽象构件中,还是只放在组合构件中。
2.1 透明组合
透明组合将管理子节点的方法(add、remove、getChild)定义在抽象构件中,这样叶子构件和组合构件对于客户端来说是"透明"的------客户端不需要区分两者,可以通过统一的接口操作所有节点。
add / remove / operation
add / remove / operation
持有
客户端
Component 抽象构件
Leaf 叶子构件
Composite 组合构件
叶子构件的add和remove方法抛出UnsupportedOperationException
(1)抽象构件------统一接口
java
import java.util.List;
/**
* 抽象构件:统一接口
* 透明组合:管理子节点的方法也定义在抽象构件中
*/
public abstract class Component {
/**
* 添加子节点
*
* @param component 子构件
*/
public void add(Component component) {
throw new UnsupportedOperationException("不支持添加操作");
}
/**
* 移除子节点
*
* @param component 子构件
*/
public void remove(Component component) {
throw new UnsupportedOperationException("不支持移除操作");
}
/**
* 获取子节点
*
* @param index 索引
* @return 子构件
*/
public Component getChild(int index) {
throw new UnsupportedOperationException("不支持获取子节点操作");
}
/**
* 业务操作
*/
public abstract void operation();
}
(2)叶子构件------文件
java
/**
* 叶子构件:文件
* 没有子节点,add / remove / getChild 方法继承默认实现(抛出异常)
*/
public class File extends Component {
private final String name;
public File(String name) {
this.name = name;
}
@Override
public void operation() {
System.out.println("文件:" + name);
}
}
(3)组合构件------文件夹
java
import java.util.ArrayList;
import java.util.List;
/**
* 组合构件:文件夹
* 可以包含子节点(文件或子文件夹),重写 add / remove / getChild 方法
*/
public class Folder extends Component {
private final String name;
private final List<Component> children = new ArrayList<>();
public Folder(String name) {
this.name = name;
}
@Override
public void add(Component component) {
children.add(component);
}
@Override
public void remove(Component component) {
children.remove(component);
}
@Override
public Component getChild(int index) {
return children.get(index);
}
@Override
public void operation() {
System.out.println("文件夹:" + name);
for (Component child : children) {
child.operation();
}
}
}
(4)客户端调用
java
public class TransparentCompositeDemo {
public static void main(String[] args) {
// 构建文件系统树
Component root = new Folder("根目录");
Component srcFolder = new Folder("src");
srcFolder.add(new File("Main.java"));
srcFolder.add(new File("Utils.java"));
Component resFolder = new Folder("resources");
resFolder.add(new File("config.properties"));
root.add(srcFolder);
root.add(resFolder);
root.add(new File("pom.xml"));
// 统一调用------不需要区分 File 和 Folder
root.operation();
// 文件夹:根目录
// 文件夹:src
// 文件:Main.java
// 文件:Utils.java
// 文件夹:resources
// 文件:config.properties
// 文件:pom.xml
// 透明性体现------客户端可以用 Component 类型操作所有节点
Component file = new File("test.txt");
file.add(new File("不可能")); // UnsupportedOperationException
}
}
透明组合的特点 :客户端不需要区分叶子构件和组合构件,统一通过
Component接口操作。但叶子构件继承了它不需要的方法(add、remove、getChild),调用时会抛出异常,不够安全。
2.2 安全组合
安全组合将管理子节点的方法(add、remove、getChild)只定义在组合构件中,抽象构件只声明公共的业务方法。这样叶子构件不会拥有不需要的方法,更加安全。但客户端需要区分叶子构件和组合构件,丧失了一定的透明性。
只有operation
只有operation
add / remove / getChild
客户端
Component 抽象构件
Leaf 叶子构件
Composite 组合构件
Component 子构件
管理子节点的方法只在Composite中定义
(1)抽象构件------只声明业务方法
java
/**
* 抽象构件:只声明业务方法
* 安全组合:管理子节点的方法不放在抽象构件中
*/
public abstract class Component {
/**
* 业务操作
*/
public abstract void operation();
}
(2)叶子构件------文件
java
/**
* 叶子构件:文件
* 只需要实现业务方法,不包含管理子节点的方法
*/
public class File extends Component {
private final String name;
public File(String name) {
this.name = name;
}
@Override
public void operation() {
System.out.println("文件:" + name);
}
}
(3)组合构件------文件夹
java
import java.util.ArrayList;
import java.util.List;
/**
* 组合构件:文件夹
* 自己定义管理子节点的方法,不继承自抽象构件
*/
public class Folder extends Component {
private final String name;
private final List<Component> children = new ArrayList<>();
public Folder(String name) {
this.name = name;
}
/**
* 添加子节点------只在组合构件中定义
*
* @param component 子构件
*/
public void add(Component component) {
children.add(component);
}
/**
* 移除子节点------只在组合构件中定义
*
* @param component 子构件
*/
public void remove(Component component) {
children.remove(component);
}
/**
* 获取子节点------只在组合构件中定义
*
* @param index 索引
* @return 子构件
*/
public Component getChild(int index) {
return children.get(index);
}
@Override
public void operation() {
System.out.println("文件夹:" + name);
for (Component child : children) {
child.operation();
}
}
}
(4)客户端调用
java
public class SafeCompositeDemo {
public static void main(String[] args) {
// 构建文件系统树------需要使用 Folder 类型来调用 add 方法
Folder root = new Folder("根目录");
Folder srcFolder = new Folder("src");
srcFolder.add(new File("Main.java"));
srcFolder.add(new File("Utils.java"));
Folder resFolder = new Folder("resources");
resFolder.add(new File("config.properties"));
root.add(srcFolder);
root.add(resFolder);
root.add(new File("pom.xml"));
// 统一调用------operation 方法仍然可以通过 Component 类型调用
Component component = root;
component.operation();
// 文件夹:根目录
// 文件夹:src
// 文件:Main.java
// 文件:Utils.java
// 文件夹:resources
// 文件:config.properties
// 文件:pom.xml
// 安全性体现------File 没有 add 方法,不会误调用
// File file = new File("test.txt");
// file.add(new File("不可能")); // 编译错误,File 没有 add 方法
}
}
安全组合的特点 :叶子构件不会拥有不需要的方法,编译期就能发现问题,更加安全。但客户端在构建树形结构时需要区分叶子构件和组合构件(需要使用
Folder类型来调用add等方法),丧失了一定的透明性。
2.3 透明组合 vs 安全组合
| 对比维度 | 透明组合 | 安全组合 |
|---|---|---|
| 子节点管理方法位置 | 抽象构件中(所有节点都有) | 只在组合构件中 |
| 叶子构件安全性 | 低(调用 add 等方法会抛异常) | 高(编译期就能发现问题) |
| 客户端透明性 | 高(统一通过 Component 操作) | 低(构建时需要区分类型) |
| 符合设计原则 | 不符合接口隔离原则(叶子被迫实现不需要的方法) | 符合接口隔离原则 |
| 典型应用 | AWT/Swing 组件体系 | 文件系统、组织架构 |
选型建议:
- 如果希望客户端完全透明 地操作所有节点,不需要区分叶子和组合------选择透明组合
- 如果希望类型安全 ,在编译期就能发现问题------选择安全组合
- 实际开发中,安全组合更为常用,因为编译期的错误比运行时的异常更容易定位
三、JDK 源码中的组合模式
组合模式在 JDK 中有着广泛的应用,最典型的就是 AWT/Swing 的组件体系和 Java NIO 的文件系统。
3.1 AWT/Swing 组件体系
java.awt.Component 和 java.awt.Container 是组合模式在 JDK 中最经典的应用。Component 是抽象构件,Container 是组合构件,Button、Label 等是叶子构件。
持有
Component 抽象构件
Container 组合构件
Panel 面板
Window 窗口
Button 叶子构件
Label 叶子构件
TextField 叶子构件
java
/**
* Component 是抽象构件
* 它声明了所有组件的公共方法
*/
public abstract class Component implements ImageObserver, MenuContainer, Serializable {
public abstract void paint(Graphics g);
public void repaint() {
// ...
}
public void setVisible(boolean b) {
// ...
}
}
/**
* Container 是组合构件
* 它继承 Component,并增加了管理子组件的方法
*/
public class Container extends Component {
/**
* 存放子组件的列表
*/
java.util.List<Component> component = new java.util.ArrayList<>();
/**
* 添加子组件
*/
public Component add(Component comp) {
// ...
component.add(comp);
return comp;
}
/**
* 移除子组件
*/
public void remove(int index) {
// ...
component.remove(index);
}
/**
* 获取子组件
*/
public Component getComponent(int n) {
return component.get(n);
}
/**
* 获取所有子组件
*/
public Component[] getComponents() {
return component.toArray(new Component[0]);
}
}
在这个例子中:
- Component(抽象构件) :
java.awt.Component - Composite(组合构件) :
java.awt.Container(继承 Component,拥有 add/remove/getComponent 等方法) - Leaf(叶子构件) :
Button、Label、TextField等(继承 Component,没有子组件)
使用方式:
java
// 组合构件------窗口
Frame frame = new Frame("我的窗口");
// 组合构件------面板
Panel panel = new Panel();
// 叶子构件------按钮和标签
Button button = new Button("点击");
Label label = new Label("标签");
// 组合------面板添加叶子组件
panel.add(button);
panel.add(label);
// 组合------窗口添加面板
frame.add(panel);
// 统一操作------对所有组件执行 setVisible
frame.setVisible(true);
说明 :这是安全组合 的实现------
add、remove、getComponent等方法只定义在Container中,Button、Label等叶子构件没有这些方法。客户端在构建界面时需要区分容器和组件,但类型更加安全。
3.2 Java NIO 文件系统
java.nio.file 包中的文件系统 API 也使用了组合模式。Path 接口表示文件路径,既可以表示文件(叶子),也可以表示目录(组合)。
java
/**
* Path 接口------抽象构件
* 既可以表示文件,也可以表示目录
*/
public interface Path extends Comparable<Path>, Iterable<Path>, Watchable {
/**
* 获取文件名
*/
Path getFileName();
/**
* 获取父路径
*/
Path getParent();
/**
* 解析子路径(类似添加子节点)
*/
Path resolve(Path other);
/**
* 解析子路径(字符串形式)
*/
Path resolve(String other);
}
使用方式:
java
import java.nio.file.Path;
import java.nio.file.Paths;
// 创建路径------组合模式让文件和目录的路径操作一致
Path dir = Paths.get("/home/user/project");
Path file = dir.resolve("src/Main.java");
System.out.println(dir.getFileName()); // project
System.out.println(file.getFileName()); // Main.java
System.out.println(file.getParent()); // /home/user/project/src
说明 :
Path采用的是透明组合 的设计------文件和目录使用同一个Path接口,resolve方法既可以用于目录(拼接子路径),也可以用于文件(虽然语义上不太合理,但不会报错)。
3.3 MyBatis SqlNode 体系
MyBatis 的动态 SQL 解析使用了组合模式。各种 SQL 标签(<if>、<where>、<choose>、<foreach> 等)被解析为不同的 SqlNode 实现,而 MixedSqlNode 作为组合构件可以包含多个子 SqlNode。
java
/**
* 抽象构件:SqlNode
*/
public interface SqlNode {
/**
* 应用动态 SQL 逻辑
*/
boolean apply(DynamicContext context);
}
/**
* 组合构件:MixedSqlNode
* 包含多个子 SqlNode,委托调用每个子节点
*/
public class MixedSqlNode implements SqlNode {
private final List<SqlNode> contents;
public MixedSqlNode(List<SqlNode> contents) {
this.contents = contents;
}
@Override
public boolean apply(DynamicContext context) {
for (SqlNode sqlNode : contents) {
sqlNode.apply(context);
}
return true;
}
}
/**
* 叶子构件:StaticTextSqlNode(静态文本)
*/
public class StaticTextSqlNode implements SqlNode {
private final String text;
public StaticTextSqlNode(String text) {
this.text = text;
}
@Override
public boolean apply(DynamicContext context) {
context.appendSql(text);
return true;
}
}
/**
* 叶子构件:IfSqlNode(if 标签)
*/
public class IfSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String test;
private final SqlNode contents;
public IfSqlNode(SqlNode contents, String test) {
this.test = test;
this.contents = contents;
this.evaluator = new ExpressionEvaluator();
}
@Override
public boolean apply(DynamicContext context) {
if (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context);
return true;
}
return false;
}
}
在这个例子中:
- Component(抽象构件) :
SqlNode接口 - Composite(组合构件) :
MixedSqlNode(包含子SqlNode列表,递归调用) - Leaf(叶子构件) :
StaticTextSqlNode、IfSqlNode等
说明 :MyBatis 的
SqlNode体系采用透明组合 ------所有节点都实现SqlNode接口,客户端统一调用apply方法。MixedSqlNode可以包含任意类型的SqlNode(包括其他MixedSqlNode),形成递归嵌套的树形结构。
四、总结
组合模式的核心思想是将对象组合成树形结构以表示"部分-整体"的层次关系,使得客户端对单个对象和组合对象的使用具有一致性。
优点:
- 统一对待叶子和组合:客户端不需要区分单个对象和组合对象,简化了客户端代码
- 易于扩展:新增叶子构件或组合构件非常容易,符合开闭原则
- 树形结构天然支持:递归嵌套的组合结构非常适合表示树形层次关系
- 简化客户端代码:客户端不需要使用
if-else或instanceof来判断节点类型
缺点:
- 难以限制节点类型:在透明组合中,无法在编译期限制叶子构件不调用 add/remove 等方法
- 增加新构件时需要修改现有代码:如果需要新增统一的行为,需要修改抽象构件
- 透明组合不够安全:叶子构件继承了不需要的方法,运行时调用会抛出异常
- 安全组合不够透明:客户端需要区分叶子和组合,丧失了一致性
适用场景:
- 需要表示对象的部分-整体层次结构(树形结构)
- 希望客户端统一地对待单个对象和组合对象
- 需要动态地组合对象,且组合可以递归嵌套
- 需要对树形结构中的所有节点执行统一的操作
透明 vs 安全:透明组合将子节点管理方法放在抽象构件中,客户端完全透明但叶子构件不够安全;安全组合将子节点管理方法只放在组合构件中,类型安全但客户端需要区分节点类型。实际开发中,安全组合更为常用。
参考博客:
组合模式 | 菜鸟教程:https://www.runoob.com/design-pattern/composite-pattern.html