複合模式 (Composite pattern)|在實戰案例下的 OOAD 詳盡設計推導教學
大家好,我是水球潘。今天想和大家聊一下在四人帮设计模式(Gang of Four 23 Design Patterns)中的结构型模式之最——复合模式(Composite Pattern)。
复合模式最棒的性质是什么?
想想公司服务的客户⋯⋯
客户这种毫不在乎你公司的组织结构,对任何层级的人都能发号施令的特性,就是复合模式最棒的性质。
四人帮的每一个模式其实都不是什么相当困难的模式,常人都能通过自己的创意思考得出相关解法,或是在不自觉的情况下套用复合模式。
但是呀,其实复合模式的运用却在一个框架中举足轻重。我在阅读一个框架的源代码时,第一时间一定都是先分析它复合模式的形式长什么样子,就能差不多了解它框架的「核心竞争力」摆在哪里。
所以可以说呀,只要是大型框架多少都会套爆复合模式,已经可以说是套到那种「杞人忧天」的程度了。
既然复合模式有着举足轻重的应用地位,那么就让我用一篇文章的时间,借由一个实战案例来展示复合模式的基本运用和面向对象分析/设计上的推导。
这篇文章有一定的长度,是来自我制作的在线课程:「软件设计模式精通之旅」中的其中一篇章,由于学员们很喜欢这个篇章的内容,因此我将它公开分享出来。
我所教授的「设计模式」是结合了Christopher Alexander - The Timeless way of Building(设计模式起源著作)思想,和我多年面向对象大型系统项目下的实战梳理心得的结果。
所以各位此文章相较一般的文来得长上许多,请读者将重点放在「思路的推导」,我会在其中带领各位走一遍更可靠的思路:「需求 → Context (Forces → Problem) → Form (Solution) → Resulting Context」的完整探讨,如此一来才能让大家感受到设计理论与实务上之间的紧密配合。
具体来说,我会遵照以下步骤来介绍复合模式的落地运用:
- 提出「初版需求」,交代情境
- 对需求进行「面向对象分析 (OOA)」,绘制出初版类图
- 参考初版类图快速实现出初版代码
- 新增令人困扰的「第二版需求」
- 分析新的需求带来哪些困扰,察觉 Forces
- 对 Forces 进行分析、分类,釐清 Context 并定义我们要解决的 Problem
- 寻找对应的软件设计模式
- 应用设计模式来解决 Problem (Forces),绘制第二版类图
- 参考第二版类图重构出第二版代码
- 软件设计模式六大要素总结(使用Christopher Alexander的描述方式)
所以请各位在阅读我的模式系列文时,可将文章视为是「水球的设计模式思路的展示」- 对新手而言是一个非常完整的出发点,而对老手而言,你仍能从中刷新认知。
你收到了一份需求!!!!!
- 你要开发一个用来模拟文件系统的命令行程序(CLI),用户向CLI输入指令来存取文件系统。
- 文件系统由目录(Directory)和文件(File)以层次结构组成:
- 目录中可以存放多个子目录及文件。
- 目录和文件都以名称辨识,名称中的合法字符为大小写字母、数字以及. - 和_符号。
- 每一个文件中都会携带着二进制内容(如文字、图片等)。
文件系统会有一个根目录的存在(Root Directory),代表着文件系统的最外层目录,是文件系统的访问入口。 - 除了根目录,所有目录和文件都必须存在于一个父目录之中。
- CLI在执行时会指定一个当前目录(current directory),用户和CLI互动来对当前目录下达指令。CLI在启动时的默认当前目录为根目录。
- 初版的CLI只支持三道指令:
- cd :cd指令其实是change directory的意思。指定一个name参数,将当前目录底下名称为的子目录设置为CLI的当前目录。
输入cd …将当前目录设置为「当前目录的父目录」。 - size :找到当前目录底下名称为子目录或文件,并打印出它所占用的内存大小size,计算方式如下:
- 如果是子目录,其大小为底下所有子目录和文件大小的总和。
- 如果是文件,其大小为其携带二进制内容的字节数量。
- search :计算当前目录底下「所有子子孙孙中(即不只往下看一层,看的是当前目录底下的每一层)」名字包含的子目录或子文件的数量。
- cd :cd指令其实是change directory的意思。指定一个name参数,将当前目录底下名称为的子目录设置为CLI的当前目录。
这就是初版的需求了,一般我们接收到的需求也是通过这种自然语言文字组成。而在收到需求的当下,我们是不可能立刻想到最适当的设计的,因此从需求作为出发点来讨论软件设计模式才会接近实战的感受。
面向对象分析 (OOA)
收到一份需求之后,我会先大致走一趟OOA来将需求中涉及的「概念」,以面向对象的元素捕捉起来,所以才能慢慢浮现出概念的「结构面」。
从需求中我们可以意识到CLI主要存取两类对象:「目录(Directory)」和「文件(File)」,捕捉好目录和文件身上的属性和彼此之间的关系之后,可以绘制出以下类图:
面向对象分析 (OOA) 其实是有一套流程的,在此不赘述。
在类图中我们可以见到,这份需求涉及的概念大致上都已经被捕捉到图中。比如:从需求中我们可以读到「目录中可以存放子目录和子文件」,因此萃取出了「Directory」和「File」两个类别,并在Directory类别身上拉出两道关联(实线),一道关联着自身(代表包含子目录),另一道关联着File(代表包含子文件)。
而由于需求提出了要支持cd指令功能,因此我们从中捕捉了「当前目录」的概念,以CLI和Directory之间的实线关联来表现。
最后需求提出了size指令功能,我们将size指令大体上的行为用粉红色的便签纸描述在CLI类别上。
OOA做到这个程度之后,其实就算是建立好了60%的设计基础,我们可以紧接着去实作初版的代码!
初版程式
首先看File 類別的代碼:
public class File {
private Directory parent;
private final String name;
private final byte[] content;
public File(String name, String content) {
this.name = shouldMatch("[A-Za-z0-9.\\\\-_]+", name);
this.content = content.getBytes();
}
public String getName() {
return name;
}
public long bytes() {
return content.length;
}
public Directory getParent() {
return parent;
}
public void setParent(Directory parent) {
this.parent = parent;
}
}
这是一个非常简单的类别,没有太多行为,但会验证File属性的规范。
接着来看看Directory类别的代码:
public class Directory {
private Directory parent;
private final String name;
// 子檔案們
private final List<File> fileChildren = new ArrayList<>();
// 子目錄們
private final List<Directory> dirChildren = new ArrayList<>();
public static Directory root() {
return new Directory("root");
}
public Directory(String name) {
this.name = shouldMatch("[A-Za-z0-9.\\\\-_]+", name);
}
public void addFile(File file) {
fileChildren.add(file);
}
public void addDirectory(Directory directory) {
dirChildren.add(directory);
directory.setParent(this);
}
public File getFile(String name) {
for (File file : fileChildren) {
if (file.getName().equals(name)) {
return file;
}
}
return null;
}
public Directory getDirectory(String name) {
for (Directory dir : dirChildren) {
if (dir.name.equals(name)) {
return dir;
}
}
return null;
}
public long totalBytes() {
long total = 0;
for (Directory dir : dirChildren) {
total += dir.totalBytes();
}
for (File file : fileChildren) {
total += file.bytes();
}
return total;
}
public List<File> searchFiles(String keyword) {
List<File> files =