Duper 简介
Duper 是一个用于查找和删除重复文件的工具。它通过比较文件内容(而非文件名或路径)来识别重复项,确保只保留一份副本,从而节省磁盘空间。Duper 支持多种文件类型,并可自定义扫描选项,如忽略特定文件夹、设置哈希算法等。它适用于个人用户和企业环境,帮助高效管理文件系统中的冗余数据。
初学者在使用 Elixir 时,可能会对如何组织应用程序感到困惑。他们可能会问:什么时候该使用服务器?监督者(supervisors)应如何融入系统?应该编写多少个应用程序?
对于 Elixir 来说,以下方法或许能帮助我们理清上述问题。
我们可以自问五个问题:
- 环境是什么?有哪些约束条件?
- 明显的核心关注点有哪些?
- 运行时特性是什么?
- 我们要保护什么免受错误影响?
- 如何让这个系统运行起来?
本章展示的只是一种临时性的思路,并非一套正式的方法论。但当我们设计新的 Elixir 系统时感到不知所措时,这些步骤或许能提供一些帮助。
让我们通过一个简单的应用来说明我们的思路。
Duper
我们的电脑上充斥着大量重复的文件。为了应对这个问题,我们来编写一个查找重复文件的应用程序,命名为 Duper,以便将来推出付费版本 SuperDuper。它的原理是扫描目录树中的所有文件,为每个文件计算哈希值。如果两个文件具有相同的哈希值,就将其报告为重复文件。
现在,让我们开始逐个回答这些问题。
Q1:环境是什么?有哪些约束条件?
这个程序将运行在一台典型的计算机上。它的磁盘存储容量大约比主内存大两个数量级。文件大小从 到 字节不等,总数约为 个。
这意味着:
我们需要考虑到,虽然必须将文件加载到内存中才能计算其哈希值,但可能没有足够的内存一次性加载最大的文件。我们肯定无法同时处理所有文件。
这也意味着,我们的设计必须兼顾大文件和小文件。大文件读入内存所需时间更长,哈希计算也更耗时。
Q2:核心关注点是什么?
核心关注点代表了应用程序的责任划分。提前考虑这些关注点,有助于降低整体系统的耦合度。每个关注点内部可以高度内聚,但与其他关注点之间应保持松散耦合。这种耦合既包括结构上的(例如数据表示方式),也包括时序上的(例如事件发生的顺序)。
在 Duper 中,我们可以明确识别出几个关键的关注点:
-
我们需要一个地方来收集结果。由于要为每个文件计算哈希值,因此结果存储需要保存所有哈希值。由于我们要寻找重复的哈希值,内部采用键值对结构是合理的,其中键是哈希值,值是具有相同哈希的所有文件列表。但这只是实现细节,不应暴露在 API 中。
-
我们需要一个能遍历文件系统、每次返回一个路径的组件。
-
我们需要一个能接收路径并计算对应文件哈希值的模块。由于单个文件可能太大而无法完全加载到内存中,我们必须分块读取,并逐步计算哈希。
-
由于我们知道需要并发处理多个文件以充分利用 CPU 和 I/O 带宽,因此还需要一个协调整个流程的组件。
随着编码的进行,这个列表可能会发生变化,但目前这个清单已足够推进下一步。
这意味着:
至少每个关注点都应是一个 Elixir 模块。通常情况下,大多数甚至全部都会成为服务器。其中许多甚至应作为独立的 Elixir 应用程序存在,但这里暂不深入探讨。我们的代码将被组织成四个服务器。虽然可以用更少的服务器实现,但使用四个可以为每个组件赋予特定的行为特征。这四个分别是:
-
Results服务器是最关键的,因为它负责在内存中保存扫描结果。我们需要它可靠,因此尽量减少其内部逻辑。 -
PathFinder服务器负责逐个返回目录树中每个文件的路径。 -
WorkerSupervisor服务器从PathFinder获取路径,计算文件内容的哈希,并将结果传递给收集器。 -
Gatherer服务器负责启动整个流程,并判断任务何时完成。当任务完成后,它会获取结果并输出报告。
Q3:运行时特性是什么?
我们的应用程序大部分时间将消耗在工作进程中,因为这是读取文件和计算哈希值的地方。目标是尽可能让处理器和 I/O 总线保持忙碌,以最大化性能。
如果只有一个工作进程,它将依次读取一个文件、计算哈希、再读下一个、再计算……这样会在 I/O 密集和 CPU 密集之间交替切换,远未达到性能最优。
另一方面,如果为每个文件都分配一个工作进程,虽然可以并行读写和计算,但会导致内存耗尽,相当于试图把整个文件系统加载进内存。
最佳方案介于两者之间。
一种常见做法是创建 n 个工作进程,然后平均分配任务。这是一种典型的“推送”模型:预先规划好工作量,然后执行。但这种方法假设所有文件大小相近。如果文件大小差异较大,可能导致某些工作进程处理大量小文件而快速完成,而另一些则因处理大文件而长时间占用资源,造成空闲浪费。
我们在此场景下更倾向于采用“饥饿消费者”模式。这是一种“拉取”模型:每个工作进程主动请求下一个任务,处理完后继续请求新任务。在这种机制下,处理小文件的工作进程会迅速完成并立即请求新任务;处理大文件的进程则耗时较长。直到最后几份文件,才可能出现空闲状态。
下图展示了系统中消息的流动方式。注意其中混合了同步消息(双向箭头)和异步消息。
Q4:我们要保护什么免受错误影响?
这时我们需要务实一点!理想情况下,一切都不会失败,所有部分都应具备容错能力。
但现实并非如此。我们所能做的容错有限。保护程度取决于具体场景。如果是在开发心脏起搏器软件,那么绝大多数开发精力应投入错误防护;但如果是编写一个重复文件查找工具,则无需过度保护。
我们可以假设,扫描 500 GB 的文件需要几分钟而非几秒钟。这意味着,如果某个文件读取失败,我们不希望整个程序崩溃并丢失已有的工作成果。只需跳过该文件,继续运行即可。
这意味着:
我们应保护累积的结果,但对单个工作进程的容错要求不高。工作进程可以在失败后重启,直接处理下一个文件。
Q5:如何让这个系统运行起来?
顺序程序很容易启动:直接运行即可。但包含多个组件的应用程序则复杂得多。必须确保各个服务器按正确顺序启动,比如服务器 A 调用服务器 B 时,B 必须已经运行。
那么,这四个服务器应该如何启动?序列图已经给出了大部分答案。
WorkerSupervisor依赖于PathFinder和Gatherer。Gatherer依赖于Results。PathFinder和Results不依赖任何其他组件。
这意味着:
应首先启动 PathFinder 和 Results,然后启动 Gatherer,最后启动 WorkerSupervisor。
在实现上,这非常简单:只需将这些服务器按此顺序列为监督者的子进程,监督者会确保每个子进程在启动下一个之前已正常运行。
但我们还知道,系统中会有多个工作进程。与其在顶层手动创建它们,不如引入一个新的概念:子监督者。这个子监督者专门负责管理工作进程池。
引入这一监督者带来了一个有趣的可能:如果一个监督者的子进程类型完全相同,那么它可以动态地创建这些子进程。因此,Gatherer 服务器可以在启动应用时动态创建一组工作进程。这使得我们可以实验不同工作进程数量对总耗时的影响。
此时,新的依赖关系如下:
WorkerSupervisor依赖于PathFinder和Gatherer。Gatherer依赖于Results和WorkerSupervisor。PathFinder和Results不依赖任何其他组件。
这形成了如下的监督结构: