导读
由于软件系统在发布之后还可以继续演化和改进,这就意味着不断有新的需求需要被实现。如果我们的软件架构设计的对新需求不友好,那么实现新的需求可能会面临一系列问题。
本文总结了一种将变化封装的设计模式,以增加软件的可扩展性,减少软件的变更成本。
变化的基因
由于软件系统固有的多变性,新的需求总会不断提出来,因此变化是根植在软件系统的基因中的。这就需要我们不得不考虑软件的可扩展性,当有新的需求出现时,系统不需要或者仅需要少量修改就可以支持,无须整个系统重构或者重建。
在软件开发领域,面向对象思想的提出,就是为了解决可扩展性带来的问题;后来的设计模式,更是将可扩展性做到了极致。得益于设计模式的巨大影响力,几乎所有的技术人员对于可扩展性都特别重视。
而设计具备良好可扩展性的系统,需要有两个基本条件:
- 预测变化
- 完美封装变化
预测变化
“预测”这个词,本身就暗示了不可能每次预测都是准确的,如果预测的事情出错,我们期望中的需求迟迟不来,甚至被明确否定,那么基于预测做的架构设计就没什么作用,投入的工作量也就白费了。
综合分析,预测变化的复杂性在于:
- 不能每个设计点都考虑可扩展性。
- 不能完全不考虑可扩展性。
- 所有的预测都存在出错的可能性。
如何把握预测的程度和提升预测结果的准确性,是一件很复杂的事情,而且没有通用的标准可以简单套上去,更多是靠自己的经验、直觉。
应对变化
假设开发人员经验非常丰富,目光非常敏锐,看问题非常准,所有的变化都能准确预测,是否意味着可扩展性就很容易实现了呢?也没那么理想!因为预测变化是一回事,采取什么方案来应对变化,又是另外一个复杂的事情。即使预测很准确,如果方案不合适,则系统扩展一样很麻烦。
因此下面来介绍一种将变化封装的基本设计思想。将“变化”封装在一个“变化层”(抽象层),将不变的部分封装在一个独立的“稳定层”(实现层)。
无论是变化层依赖稳定层,还是稳定层依赖变化层都是可以的,需要根据具体业务情况来设计。例如,如果系统需要支持XML、JSON、ProtocolBuffer三种接入方式,那么最终的架构就是上面图中的“形式1”架构,也就是下面这样。
如果系统需要支持MySQL、Oracle、DB2数据库存储,那么最终的架构就变成了“形式2”的架构了,你可以看下面这张图。
无论采取哪种形式,通过剥离变化层和稳定层的方式应对变化,都会带来两个主要的复杂性相关的问题:
- 系统需要拆分出变化层和稳定层对于哪些属于变化层,哪些属于稳定层,很多时候并不是像前面的示例(不同接口协议或者不同数据库)那样明确,不同的人有不同的理解,导致设计评审的时候可能吵翻天。
- 需要设计变化层和稳定层之间的接口接口设计同样至关重要,对于稳定层来说,接口肯定是越稳定越好;但对于变化层来说,在有差异的多个实现方式中找出共同点,并且还要保证当加入新的功能时原有的接口设计不需要太大修改,这是一件很复杂的事情。例如,MySQL的REPLACE INTO和Oracle的MERGE INTO语法和功能有一些差异,那存储层如何向稳定层提供数据访问接口呢?是采取MySQL的方式,还是采取Oracle的方式,还是自适应判断?如果再考虑DB2的情况呢?相信你看到这里就已经能够大致体会到接口设计的复杂性了。
应用举例
需求
我们接下来要实现一个监控目录和文件变化的功能。当前需要支持Linux、Solaris和FreeBSD系统,未来可能需要支持Windows、Mac OS或者AIX系统。
分析
我们知道,各个操作系统内核基本都提供了文件监控功能,并在应用层对外提供API接口。所以,为了避免重复造轮子,我们决定使用操作系统提供的监控功能。
经过调研,Linux系统提供了inotify文件系统监控机制,Solaris提供了一套File Events Notification API,FreeBSD则提供了kqueue机制。
好的,各个系统本身的监控机制我们已经了解了,可以看到,这三个系统提供的API还是差异很大的,那我们接下来如何设计我们自己的软件系统呢。
一种符合直觉的简单做法是在每个系统的API上都封装一套自己的接口以实现定制化的需求。比如在Linux的inotify API上封装接口以实现最大文件监控数,监控超时等功能,Solaris系统也按照Linux上的实现重复搞一套,FreeBSD也依法炮制。
这种做法虽然看上去比较直观,而且还兼顾了各个操作系统的差异性,但是实际上带来的问题要比解决的问题更多。
首先,增加了开发成本和后期的维护成本。每个系统都要维护一套接口,对开发人员和功能使用者而言增加了额外负担。同样,后期维护上,如果有新的需求变更,哪怕是很小的变化,开发人员都要同时修改三套代码。
其次,可扩展性较差。假如后面需要增加Windows的监控功能,按照现有的实现方式,我们只能再重新实现一套windows上的封装接口,这几乎相当于系统重写。
可见,在复杂的软件开发流域,依赖直觉并不靠谱,需要不断的思考和实践,才能设计出优秀的软件系统。
接下来,我们按照本文提出的变化封装的思想来重新设计。
首先,我们要剥离变化层和稳定层。稳定层是我们需要暴露给使用者的接口,而变化层则是每个系统的具体实现。可见,这里是稳定层在上,变化层在下,稳定层依赖变化层,如下图所示:
其次,为了定义稳定层的接口,我们需要细化需求。假设有以下需求:
- 需要监控如下文件变动事件:
- 创建;
- 更新;
- 删除;
- 重命名;
- 改变所有者;
- 允许设置监控超时,在超时时间内没有事件发生则自动产生一个超时事件;
- 允许设置事件过滤;
- 允许设置路径过滤;
- 允许设置用户自定义事件处理函数;
我们可以根据以上需求,设计一个Montior抽象基类,在抽象基类中区分与平台相关的和与平台无关的方法。其中,平台相关方法需要每个操作系统override,而平台无关的方法采用抽象基类中的实现。
平台相关方法:
- run:需要每个系统下override的实际检测逻辑,我们可以定义检测逻辑结构,每个系统参考该结构做定制化实现,run的结构可以设计如下:
void run() {
initialize_api();
for (;;) {
unique_lock run_guard(run_mutex);
if (should_stop) break;
run_guard.unlock();
scan_paths();
wait_for_events();
vector evts = get_changes();
vector events;
for (auto & evt : evts) {
if (accept(evt.get_path)) {
events.push_back({event from evt});
}
}
if (events.size()) notify_events(events);
}
terminate_api();
平台无关方法:
- start:启动检测,内部调用run方法,并在检测退出时调用stop方法;
- stop:检测退出清理;
- accept_path:路径过滤;
- notify_events:过滤事件并调用自定义处理函数;
- …
系统拆分出稳定层和变化层后,每个变化层中的操作系统只需要实现自己的run方法即可,稳定层对外的接口全部由稳定层的抽象基类提供,大大减少了开发代码量和后续的维护成本。
转载:https://blog.csdn.net/qq_31032141/article/details/103410953