CategoryResourceRepost/极客时间专栏/设计模式之美/设计模式与范式:行为型/61 | 策略模式(下):如何实现一个支持给不同大小文件排序的小程序?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

258 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="61 | 策略模式(下):如何实现一个支持给不同大小文件排序的小程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/25/27267a7dc10c14c20e0076e8a0952325.mp3"></audio>
上一节课我们主要介绍了策略模式的原理和实现以及如何利用策略模式来移除if-else或者switch-case分支判断逻辑。今天我们结合“给文件排序”这样一个具体的例子来详细讲一讲策略模式的设计意图和应用场景。
除此之外,在今天的讲解中,我还会通过一步一步地分析、重构,给你展示一个设计模式是如何“创造”出来的。通过今天的学习,你会发现,**设计原则和思想其实比设计模式更加普适和重要,掌握了代码的设计原则和思想,我们甚至可以自己创造出来新的设计模式**。
话不多说,让我们正式开始今天的学习吧!
## 问题与解决思路
假设有这样一个需求,希望写一个小程序,实现对一个文件进行排序的功能。文件中只包含整型数,并且,相邻的数字通过逗号来区隔。如果由你来编写这样一个小程序,你会如何来实现呢?你可以把它当作面试题,先自己思考一下,再来看我下面的讲解。
你可能会说,这不是很简单嘛,只需要将文件中的内容读取出来,并且通过逗号分割成一个一个的数字,放到内存数组中,然后编写某种排序算法(比如快排),或者直接使用编程语言提供的排序函数,对数组进行排序,最后再将数组中的数据写入文件就可以了。
但是如果文件很大呢比如有10GB大小因为内存有限比如只有8GB大小我们没办法一次性加载文件中的所有数据到内存中这个时候我们就要利用外部排序算法具体怎么做可以参看我的另一个专栏《数据结构与算法之美》中的“排序”相关章节了。
如果文件更大比如有100GB大小我们为了利用CPU多核的优势可以在外部排序的基础之上进行优化加入多线程并发排序的功能这就有点类似“单机版”的MapReduce。
如果文件非常大比如有1TB大小即便是单机多线程排序这也算很慢了。这个时候我们可以使用真正的MapReduce框架利用多机的处理能力提高排序的效率。
## 代码实现与分析
解决思路讲完了,不难理解。接下来,我们看一下,如何将解决思路翻译成代码实现。
我先用最简单直接的方式将它实现出来。具体代码我贴在下面了,你可以先看一下。因为我们是在讲设计模式,不是讲算法,所以,在下面的代码实现中,我只给出了跟设计模式相关的骨架代码,并没有给出每种排序算法的具体代码实现。感兴趣的话,你可以自行实现一下。
```
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
if (fileSize &lt; 6 * GB) { // [0, 6GB)
quickSort(filePath);
} else if (fileSize &lt; 10 * GB) { // [6GB, 10GB)
externalSort(filePath);
} else if (fileSize &lt; 100 * GB) { // [10GB, 100GB)
concurrentExternalSort(filePath);
} else { // [100GB, ~)
mapreduceSort(filePath);
}
}
private void quickSort(String filePath) {
// 快速排序
}
private void externalSort(String filePath) {
// 外部排序
}
private void concurrentExternalSort(String filePath) {
// 多线程外部排序
}
private void mapreduceSort(String filePath) {
// 利用MapReduce多机排序
}
}
public class SortingTool {
public static void main(String[] args) {
Sorter sorter = new Sorter();
sorter.sortFile(args[0]);
}
}
```
在“编码规范”那一部分我们讲过函数的行数不能过多最好不要超过一屏的大小。所以为了避免sortFile()函数过长我们把每种排序算法从sortFile()函数中抽离出来拆分成4个独立的排序函数。
如果只是开发一个简单的工具,那上面的代码实现就足够了。毕竟,代码不多,后续修改、扩展的需求也不多,怎么写都不会导致代码不可维护。但是,如果我们是在开发一个大型项目,排序文件只是其中的一个功能模块,那我们就要在代码设计、代码质量上下点儿功夫了。只有每个小的功能模块都写好,整个项目的代码才能不差。
在刚刚的代码中我们并没有给出每种排序算法的代码实现。实际上如果自己实现一下的话你会发现每种排序算法的实现逻辑都比较复杂代码行数都比较多。所有排序算法的代码实现都堆在Sorter一个类中这就会导致这个类的代码很多。而在“编码规范”那一部分中我们也讲到一个类的代码太多也会影响到可读性、可维护性。除此之外所有的排序算法都设计成Sorter的私有函数也会影响代码的可复用性。
## 代码优化与重构
只要掌握了我们之前讲过的设计原则和思想针对上面的问题即便我们想不到该用什么设计模式来重构也应该能知道该如何解决那就是将Sorter类中的某些代码拆分出来独立成职责更加单一的小类。实际上拆分是应对类或者函数代码过多、应对代码复杂性的一个常用手段。按照这个解决思路我们对代码进行重构。重构之后的代码如下所示
```
public interface ISortAlg {
void sort(String filePath);
}
public class QuickSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class ExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class ConcurrentExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class MapReduceSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize &lt; 6 * GB) { // [0, 6GB)
sortAlg = new QuickSort();
} else if (fileSize &lt; 10 * GB) { // [6GB, 10GB)
sortAlg = new ExternalSort();
} else if (fileSize &lt; 100 * GB) { // [10GB, 100GB)
sortAlg = new ConcurrentExternalSort();
} else { // [100GB, ~)
sortAlg = new MapReduceSort();
}
sortAlg.sort(filePath);
}
}
```
经过拆分之后每个类的代码都不会太多每个类的逻辑都不会太复杂代码的可读性、可维护性提高了。除此之外我们将排序算法设计成独立的类跟具体的业务逻辑代码中的if-else那部分逻辑解耦也让排序算法能够复用。这一步实际上就是策略模式的第一步也就是将策略的定义分离出来。
实际上,上面的代码还可以继续优化。每种排序类都是无状态的,我们没必要在每次使用的时候,都重新创建一个新的对象。所以,我们可以使用工厂模式对对象的创建进行封装。按照这个思路,我们对代码进行重构。重构之后的代码如下所示:
```
public class SortAlgFactory {
private static final Map&lt;String, ISortAlg&gt; algs = new HashMap&lt;&gt;();
static {
algs.put(&quot;QuickSort&quot;, new QuickSort());
algs.put(&quot;ExternalSort&quot;, new ExternalSort());
algs.put(&quot;ConcurrentExternalSort&quot;, new ConcurrentExternalSort());
algs.put(&quot;MapReduceSort&quot;, new MapReduceSort());
}
public static ISortAlg getSortAlg(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException(&quot;type should not be empty.&quot;);
}
return algs.get(type);
}
}
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize &lt; 6 * GB) { // [0, 6GB)
sortAlg = SortAlgFactory.getSortAlg(&quot;QuickSort&quot;);
} else if (fileSize &lt; 10 * GB) { // [6GB, 10GB)
sortAlg = SortAlgFactory.getSortAlg(&quot;ExternalSort&quot;);
} else if (fileSize &lt; 100 * GB) { // [10GB, 100GB)
sortAlg = SortAlgFactory.getSortAlg(&quot;ConcurrentExternalSort&quot;);
} else { // [100GB, ~)
sortAlg = SortAlgFactory.getSortAlg(&quot;MapReduceSort&quot;);
}
sortAlg.sort(filePath);
}
}
```
经过上面两次重构之后现在的代码实际上已经符合策略模式的代码结构了。我们通过策略模式将策略的定义、创建、使用解耦让每一部分都不至于太复杂。不过Sorter类中的sortFile()函数还是有一堆if-else逻辑。这里的if-else逻辑分支不多、也不复杂这样写完全没问题。但如果你特别想将if-else分支判断移除掉那也是有办法的。我直接给出代码你一看就能明白。实际上这也是基于查表法来解决的其中的“algs”就是“表”。
```
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
private static final List&lt;AlgRange&gt; algs = new ArrayList&lt;&gt;();
static {
algs.add(new AlgRange(0, 6*GB, SortAlgFactory.getSortAlg(&quot;QuickSort&quot;)));
algs.add(new AlgRange(6*GB, 10*GB, SortAlgFactory.getSortAlg(&quot;ExternalSort&quot;)));
algs.add(new AlgRange(10*GB, 100*GB, SortAlgFactory.getSortAlg(&quot;ConcurrentExternalSort&quot;)));
algs.add(new AlgRange(100*GB, Long.MAX_VALUE, SortAlgFactory.getSortAlg(&quot;MapReduceSort&quot;)));
}
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg = null;
for (AlgRange algRange : algs) {
if (algRange.inRange(fileSize)) {
sortAlg = algRange.getAlg();
break;
}
}
sortAlg.sort(filePath);
}
private static class AlgRange {
private long start;
private long end;
private ISortAlg alg;
public AlgRange(long start, long end, ISortAlg alg) {
this.start = start;
this.end = end;
this.alg = alg;
}
public ISortAlg getAlg() {
return alg;
}
public boolean inRange(long size) {
return size &gt;= start &amp;&amp; size &lt; end;
}
}
}
```
现在的代码实现就更加优美了。我们把可变的部分隔离到了策略工厂类和Sorter类中的静态代码段中。当要添加一个新的排序算法时我们只需要修改策略工厂类和Sort类中的静态代码段其他代码都不需要修改这样就将代码改动最小化、集中化了。
你可能会说,即便这样,当我们添加新的排序算法的时候,还是需要修改代码,并不完全符合开闭原则。有什么办法让我们完全满足开闭原则呢?
对于Java语言来说我们可以通过反射来避免对策略工厂类的修改。具体是这么做的我们通过一个配置文件或者自定义的annotation来标注都有哪些策略类策略工厂类读取配置文件或者搜索被annotation标注的策略类然后通过反射动态地加载这些策略类、创建策略对象当我们新添加一个策略的时候只需要将这个新添加的策略类添加到配置文件或者用annotation标注即可。还记得上一节课的课堂讨论题吗我们也可以用这种方法来解决。
对于Sorter来说我们可以通过同样的方法来避免修改。我们通过将文件大小区间和算法之间的对应关系放到配置文件中。当添加新的排序算法时我们只需要改动配置文件即可不需要改动代码。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
一提到if-else分支判断有人就觉得它是烂代码。如果if-else分支判断不复杂、代码不多这并没有任何问题毕竟if-else分支判断几乎是所有编程语言都会提供的语法存在即有理由。遵循KISS原则怎么简单怎么来就是最好的设计。非得用策略模式搞出n多类反倒是一种过度设计。
一提到策略模式有人就觉得它的作用是避免if-else分支判断逻辑。实际上这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用控制代码的复杂度让每个部分都不至于过于复杂、代码量过多。除此之外对于复杂代码来说策略模式还能让其满足开闭原则添加新策略的时候最小化、集中化代码改动减少引入bug的风险。
实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。
## 课堂讨论
1. 在过去的项目开发中,你有没有用过策略模式,都是为了解决什么问题才使用的?
1. 你可以说一说在什么情况下我们才有必要去掉代码中的if-else或者switch-case分支逻辑呢
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。