爱看书的阿东

赐他一块白色石头,石头上写着新名

浅谈设计模式 - 组合模式(十二)

浅谈设计模式 - 组合模式(十二)

前言

​ 组合模式是一种非常重要的设计模式,使用场景几乎随处可见,各类菜单和目录等地方都能看到组合模式的影子,组合模式通常情况下是和树形结构相辅相成的,而树是软件设计里面非常重要的数据结构,这篇文章将介绍什么是组合模式。

什么是组合模式

​ 允许你将对象组合到树形结构表现“整体部分”的结构,组合能让客户以一致的方式处理个别对象和对象组合,组合其实更像是对于对于各种独立组建的“统一性”,可以将一类相似的事物看为一个整体但是拥有完全不同的工作机制。

介绍

​ 可以说将相似的物品形成一个集合的模式就是组合模式,他能看两个相似的物品在一处进行完美的融合以及操作。当我们需要 整体/部分的操作时候,就可以使用这种形式。

特点

  • 组合模式讲究的是整体和部分之间的关系,整体可以包含部分,部分可以回溯到整体,互相包含
  • 组合模式可以让对象结构以“树”的形式包含关系。多数情况可以忽略整体和个体之前的差别

优缺点

优点:

  • 组合模式可以帮助对象和组合的对象一视同仁的对待

缺点:

  • 继承结构,修改抽象类违反开放关闭原则
  • 如果层次结构非常深,递归结构影响效率
  • 使用迭代器有可能造成并发遍历菜单的问题

组合模式以单一职责的原则换取透明性?

组合模式破坏了的单一职责原则,组合了多个对象的方法,同时在方法里面做了多种操作,但是这样做却是可以让整个对象可以更加直观的了解整体和部分的特性,这是设计模式里面非常常见的操作。

组合模式的结构图

​ 组合模式的结构图如下:

  • Component 组件:定义组件的接口,这里可以设计为抽象类,可以设计为接口,可以视为组件的“可能的公共行为”。
  • Leaf 叶子节点:用于表示原始对象,叶子节点只需要实现自己的特殊功能即可,比如菜单的菜单子项。
  • Composite 组件节点:定义组件行为,可以具备子节点。同时实现叶子节点的相关操作(继承同一个接口),可以视为一个分类的大类

实际应用场景

​ 由于现实场景当中这样的设计模式结构是有树状结构转换而来的,所以组合模式的使用场景就是出现树形结构的地方。比如:文件目录显示,多及目录呈现等树形结构数据的操作。下面我们就使用一个菜单的结构来了解一下组合模式的“模板”代码。

实战

模拟场景

​ 组合模式是为树形结构设计的一种设计模式,案例参照一个菜单的管理功能作为模拟,我们需要拿到不同的菜单分类,在菜单的分类里面,我们有需要拿到不同的菜单项,我们可以由任意的菜单项进入到不同的菜单分类,同时可以进入不同的叶子节点。

​ 这次的代码案例是从网上找的例子:

抽象组件

抽象组件定义了组件的通知接口,并实现了增删子组件及获取所有子组件的方法。同时重写了hashCodeequales方法(至于原因,请读者自行思考。如有疑问,请在评论区留言)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.jasongj.organization;

import java.util.ArrayList;
import java.util.List;

public abstract class Organization {

private List<Organization> childOrgs = new ArrayList<Organization>();

private String name;

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

public String getName() {
return name;
}

public void addOrg(Organization org) {
childOrgs.add(org);
}

public void removeOrg(Organization org) {
childOrgs.remove(org);
}

public List<Organization> getAllOrgs() {
return childOrgs;
}

public abstract void inform(String info);

@Override
public int hashCode(){
return this.name.hashCode();
}

@Override
public boolean equals(Object org){
if(!(org instanceof Organization)) {
return false;
}
return this.name.equals(((Organization) org).name);
}

}

简单组件(部门)

简单组件在通知方法中只负责对接收到消息作出响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.jasongj.organization;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Department extends Organization{

public Department(String name) {
super(name);
}

private static Logger LOGGER = LoggerFactory.getLogger(Department.class);

public void inform(String info){
LOGGER.info("{}-{}", info, getName());
}

}

复合组件(公司)

复合组件在自身对消息作出响应后,还须通知其下所有子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.jasongj.organization;

import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Company extends Organization{

private static Logger LOGGER = LoggerFactory.getLogger(Company.class);

public Company(String name) {
super(name);
}

public void inform(String info){
LOGGER.info("{}-{}", info, getName());
List<Organization> allOrgs = getAllOrgs();
allOrgs.forEach(org -> org.inform(info+"-"));
}

}

awt的组合模式

​ 组合模式因为使用了同样的接口,会让叶子节点实现一些不必要的功能,此时一般可以使用一个空对象或者使用更为激进的使用抛出异常的形式。

​ awt这种老掉牙的东西就不多介绍,java的gui其实就是使用了组合模式,下面是一部分的案例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//创建组件
public MethodsTank() {
//创建组件等
jm = new JMenu("我的菜单(G)");
jmb = new JMenuBar();
jl1 = new JMenuItem("开始新游戏(F)");
jl2 = new JMenuItem("结束游戏");
jl3 = new JMenuItem("重新开始(R)");
jl4 = new JMenuItem("存盘退出");
jl5 = new JMenuItem("回到上次游戏");

draw = new DrawTank();
ses = new selectIsSallup();

//设置快捷键方式
jm.setMnemonic('G');
jl1.setMnemonic('f');
jl3.setMnemonic('r');
jl4.setMnemonic('q');
jl5.setMnemonic('w');

//开启闪烁线程
new Thread(ses).start();
//先运行开始画面
this.addTank();

}


public void addTank() {
//添加菜单栏目
jm.add(jl1);
jm.add(jl2);
jm.add(jl3);
jm.add(jl4);
jm.add(jl5);
jmb.add(jm);

//运行选关界面
this.add(ses);


//对于子菜单添加事件
jl1.addActionListener(this);
jl1.setActionCommand("newgame");
jl2.addActionListener(this);
jl2.setActionCommand("gameexit");
jl3.addActionListener(this);
jl3.setActionCommand("restart");


//设置窗体的一些基本属性
this.setTitle("我的坦克大战");
this.setBounds(600, 350, width, height);
//添加菜单栏的方式
this.setJMenuBar(jmb);
this.setDefaultCloseOperation(this.EXIT_ON_CLOSE);


this.setVisible(true);

}

总结

​ 组合模式精髓在于“破而后立”,他虽然违反了设计原则,但是通过更加优雅的形式,实现了将单一的对象由部分变为一个整体。

​ 而组合模式也经常和适配器模式搭配使用,本文的案例只是一个简单的套板,对于组合模式的实际运用场景其实更常见的情况是关于菜单和菜单子项的内容。

结语

​ 组合模式很多情况下可能并不是十分用的上,更多的时候是和其他的设计模式搭配,组合模式我们需要关注的是“整体-部分”的融合统一即可。

参考资料:

​ 这里有一篇讲的更好的资料,在组合模式的基础上给了一个质量稍高的案例代码:

实战组合模式「营销差异化人群发券,决策树引擎搭建场景」

源码分析组合模式的典型应用

java.awt中的组合模式

Java GUI分两种:

  • AWT(Abstract Window Toolkit):抽象窗口工具集,是第一代的Java GUI组件。绘制依赖于底层的操作系统。基本的AWT库处理用户界面元素的方法是把这些元素的创建和行为委托给每个目标平台上(Windows、 Unix、 Macintosh等)的本地GUI工具进行处理。
  • Swing,不依赖于底层细节,是轻量级的组件。现在多是基于Swing来开发。

我们来看一个AWT的简单示例:

注意:为了正常显示中文,需要在IDEA中的 Edit Configurations -> VM Options 中设置参数 -Dfile.encoding=GB18030

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

public class MyFrame extends Frame {

public MyFrame(String title) {
super(title);
}

public static void main(String[] args) {
MyFrame frame = new MyFrame("这是一个 Frame");

// 定义三个构件,添加到Frame中去
Button button = new Button("按钮 A");
Label label = new Label("这是一个 AWT Label!");
TextField textField = new TextField("这是一个 AWT TextField!");

frame.add(button, BorderLayout.EAST);
frame.add(label, BorderLayout.SOUTH);
frame.add(textField, BorderLayout.NORTH);

// 定义一个 Panel,在Panel中添加三个构件,然后再把Panel添加到Frame中去
Panel panel = new Panel();
panel.setBackground(Color.pink);

Label lable1 = new Label("用户名");
TextField textField1 = new TextField("请输入用户名:", 20);
Button button1 = new Button("确定");
panel.add(lable1);
panel.add(textField1);
panel.add(button1);

frame.add(panel, BorderLayout.CENTER);

// 设置Frame的属性
frame.setSize(500, 300);
frame.setBackground(Color.orange);
// 设置点击关闭事件
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
System.exit(0);
}
});
frame.setVisible(true);
}
}
复制代码

运行后窗体显示如下

我们在Frame容器中添加了三个不同的构件 ButtonLabelTextField,还添加了一个 Panel 容器,Panel 容器中又添加了 ButtonLabelTextField 三个构件,为什么容器 FramePanel 可以添加类型不同的构件和容器呢?

我们先来看下AWT Component的类图

AWT Component类图

GUI组件根据作用可以分为两种:基本组件和容器组件。

  • 基本组件又称构件,诸如按钮、文本框之类的图形界面元素。
  • 容器是一种比较特殊的组件,可以容纳其他组件,容器如窗口、对话框等。所有的容器类都是 java.awt.Container 的直接或间接子类

容器父类 Container 的部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Container extends Component {
/**
* The components in this container.
* @see #add
* @see #getComponents
*/
private java.util.List<Component> component = new ArrayList<>();

public Component add(Component comp) {
addImpl(comp, null, -1);
return comp;
}
// 省略...
}
复制代码

容器父类 Container 内部定义了一个集合用于存储 Component 对象,而容器组件 Container 和 基本组件如 ButtonLabelTextField 等都是 Component 的子类,所以可以很清楚的看到这里应用了组合模式

Component 类中封装了组件通用的方法和属性,如图形的组件对象、大小、显示位置、前景色和背景色、边界、可见性等,因此许多组件类也就继承了 Component 类的成员方法和成员变量,相应的成员方法包括:

1
2
3
4
5
6
7
8
9
10
11
12
&emsp;&emsp;&emsp;getComponentAt(int x, int y)
&emsp;&emsp;&emsp;getFont()
&emsp;&emsp;&emsp;getForeground()
&emsp;&emsp;&emsp;getName()
&emsp;&emsp;&emsp;getSize()
&emsp;&emsp;&emsp;paint(Graphics g)
&emsp;&emsp;&emsp;repaint()
&emsp;&emsp;&emsp;update()
&emsp;&emsp;&emsp;setVisible(boolean b)
&emsp;&emsp;&emsp;setSize(Dimension d)
&emsp;&emsp;&emsp;setName(String name)
复制代码

Java集合中的组合模式

HashMap 提供 putAll 的方法,可以将另一个 Map 对象放入自己的存储空间中,如果有相同的 key 值则会覆盖之前的 key 值所对应的 value 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Test {
public static void main(String[] args) {
Map<String, Integer> map1 = new HashMap<String, Integer>();
map1.put("aa", 1);
map1.put("bb", 2);
map1.put("cc", 3);
System.out.println("map1: " + map1);

Map<String, Integer> map2 = new LinkedMap();
map2.put("cc", 4);
map2.put("dd", 5);
System.out.println("map2: " + map2);

map1.putAll(map2);
System.out.println("map1.putAll(map2): " + map1);
}
}
复制代码

输出结果

1
2
3
4
map1: {aa=1, bb=2, cc=3}
map2: {cc=4, dd=5}
map1.putAll(map2): {aa=1, bb=2, cc=4, dd=5}
复制代码

查看 putAll 源码

1
2
3
4
    public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}
复制代码

putAll 接收的参数为父类 Map 类型,所以 HashMap 是一个容器类,Map 的子类为叶子类,当然如果 Map 的其他子类也实现了 putAll 方法,那么它们都既是容器类,又都是叶子类

同理,ArrayList 中的 addAll(Collection<? extends E> c) 方法也是一个组合模式的应用,在此不做探讨

Mybatis SqlNode中的组合模式

MyBatis 的强大特性之一便是它的动态SQL,其通过 if, choose, when, otherwise, trim, where, set, foreach 标签,可组合成非常灵活的SQL语句,从而提高开发人员的效率。

来几个官方示例:

动态SQL – IF

1
2
3
4
5
6
7
8
9
10
<select id="findActiveBlogLike"  resultType="Blog">
SELECT * FROM BLOG WHERE state = ‘ACTIVE’
<if test="title != null">
AND title like #{title}
</if>
<if test="author != null and author.name != null">
AND author_name like #{author.name}
</if>
</select>
复制代码

动态SQL – choose, when, otherwise

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="findActiveBlogLike"  resultType="Blog">
SELECT * FROM BLOG WHERE state = ‘ACTIVE’
<choose>
<when test="title != null">
AND title like #{title}
</when>
<when test="author != null and author.name != null">
AND author_name like #{author.name}
</when>
<otherwise>
AND featured = 1
</otherwise>
</choose>
</select>
复制代码

动态SQL – where

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="findActiveBlogLike"  resultType="Blog">
SELECT * FROM BLOG
<where>
<if test="state != null">
state = #{state}
</if>
<if test="title != null">
AND title like #{title}
</if>
<if test="author != null and author.name != null">
AND author_name like #{author.name}
</if>
</where>
</select>
复制代码

动态SQL – foreach

1
2
3
4
5
6
7
8
<select id="selectPostIn" resultType="domain.blog.Post">
SELECT * FROM POST P WHERE ID in
<foreach item="item" index="index" collection="list"
open="(" separator="," close=")">
#{item}
</foreach>
</select>
复制代码

Mybatis在处理动态SQL节点时,应用到了组合设计模式,Mybatis会将映射配置文件中定义的动态SQL节点、文本节点等解析成对应的 SqlNode 实现,并形成树形结构。

SQLNode 的类图如下所示

Mybatis SqlNode 类图

需要先了解 DynamicContext 类的作用:主要用于记录解析动态SQL语句之后产生的SQL语句片段,可以认为它是一个用于记录动态SQL语句解析结果的容器

抽象构件为 SqlNode 接口,源码如下

1
2
3
4
public interface SqlNode {
boolean apply(DynamicContext context);
}
复制代码

applySQLNode 接口中定义的唯一方法,该方法会根据用户传入的实参,参数解析该SQLNode所记录的动态SQL节点,并调用 DynamicContext.appendSql() 方法将解析后的SQL片段追加到 DynamicContext.sqlBuilder 中保存,当SQL节点下所有的 SqlNode 完成解析后,我们就可以从 DynamicContext 中获取一条动态生产的、完整的SQL语句

然后来看 MixedSqlNode 类的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MixedSqlNode implements SqlNode {
private 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;
}
}
复制代码

MixedSqlNode 维护了一个 List<SqlNode> 类型的列表,用于存储 SqlNode 对象,apply 方法通过 for循环 遍历 contents 并调用其中对象的 apply 方法,这里跟我们的示例中的 Folder 类中的 print 方法非常类似,很明显 MixedSqlNode 扮演了容器构件角色

对于其他SqlNode子类的功能,稍微概括如下:

  • TextSqlNode:表示包含 ${} 占位符的动态SQL节点,其 apply 方法会使用 GenericTokenParser 解析 ${} 占位符,并直接替换成用户给定的实际参数值
  • IfSqlNode:对应的是动态SQL节点 <If> 节点,其 apply 方法首先通过 ExpressionEvaluator.evaluateBoolean() 方法检测其 test 表达式是否为 true,然后根据 test 表达式的结果,决定是否执行其子节点的 apply() 方法
  • TrimSqlNode :会根据子节点的解析结果,添加或删除相应的前缀或后缀。
  • WhereSqlNodeSetSqlNode 都继承了 TrimSqlNode
  • ForeachSqlNode:对应 <foreach> 标签,对集合进行迭代
  • 动态SQL中的 <choose><when><otherwise> 分别解析成 ChooseSqlNodeIfSqlNodeMixedSqlNode

综上,SqlNode 接口有多个实现类,每个实现类对应一个动态SQL节点,其中 SqlNode 扮演抽象构件角色,MixedSqlNode 扮演容器构件角色,其它一般是叶子构件角色