原文链接 作者:Kirk Knoernschild
模块间的紧耦合是一种很糟糕的设计,而耦合的最坏表现就是模块间的循环依赖。幸运的是,有几种方法可以用来消除循环依赖,分别是回调函数,代码上移,代码下移。 接下来,我会为大家展示一个小例子。示例中,我会分别使用上述几种技术来消除循环依赖。
在消除循环依赖之后,我们会探索另外两项技术,来达到依赖反转和消除模块之间依赖的目的。本示例的所有代码都可以在Google Code下载,每个解决方法对应的代码都包含有一个编译脚本和一个简单测试用例。一般你只需要输入 ant compile就可以执行编译脚本,不过如果你想使用JarAnalyzer,那么必须要有GraphViz。注意每个解决方法对应的代码版本都具有相同的行为!
接下来的例子非常简单。假设有这样一个系统,包含有Customer和Bill两个类,分别在两个模块中—cust.jar和bill.jar。同时有一个测试类PaymentTest测试两者交互的行为,集成在billtest.jar模块。最初的类图显示在下面,需要引起注意图中两个类的双向关联。
随着逐步的深入,我们会往系统中添加更多的类和抽象来提高模块化程度。另外,我们会使用JarAnalyzer来描述模块间的关系,同时也用来衡量设计的好坏。用JarAnalyzer生成的模块结构如下图所示,可以通过查看编译脚本来了解JarAnalyzer的用法。回到正题,这篇文章中我们的目的是用上述三种方法来消除循环依赖,再之后会探索不同的途径来构建非循环依赖的模块。
上移
首先使用的方法叫上移。我们通过将导致依赖的因子(这里是折扣的计算)上移到一个更高级别的模块,来达到消除循环依赖的目的。然而在此之前,我们要先弄清楚这个例子中为什么会存在循环依赖。具体原因如下:
一个Customer拥有多个bill实例,当bill对象的pay方法被调用的时候,需要先去判断是否有折扣。但是,计算折扣的方法是在Customer类中,而不是Bill。因此,Bill类需要调用Customer的方法来计算合适的折扣。可以这样来思考这个问题...Customer代表一个付款人,而我们和每个付款人协商折扣。所以折扣的计算是封装在Customer中的。
为了打破这个依赖,我们将导致依赖的因素上移到一个更高级别的类-CustomerMediator。Mediator类将计算折扣的细节封装起来,并传给Bill类。了解这次修改的最好方法就是看修改后的PaymentTest类。我已经修改好了编译脚本,并且将Mediator打包成jar。但如果在深入了解这个类结构后,你可能会质疑为什么不直接把折扣数从Customer类传给Bill类。不用担心,这个例子只是初步的设计,并不是解决此类问题的最好方法。我们要知道的就是,这个方法的核心是将依赖上移到mediator模块,来达到消除循环依赖的目的。
下移
一个解决此类特定循环依赖问题的更好方法是下移(这里Customer和Bill间有组合关系)。通过下移,我们将导致依赖的因子下移到一个更低级别的模块,这正好和上移 相反。我们引入一个DiscountCalculator类,用来传递给Bill类,修改后的PaymentTest类会生成DiscountCalculator对象并将其传进来。因为Customer类知道怎么计算折扣,所以由Customer类提供生成DiscountCalculator的工厂方法。新的类结构图如下所示。
接下来我们会修改编译脚本将DiscountCalcultor打包生成calc.jar,最终的模块结构图如下所示。
通过分析你会发现下移比上移在处理这种循环依赖的问题上显得更为合理,但是关键的区别是什么呢?使用上移我可以单独部署cust.jar和bill.jar。而虽然下移更为合理,但是如果要部署cust.jar或者bill.jar,也必须依赖calc.jar。可行的的解决方案总是会和具体问题关联,而理想的解决方案是在整个开发周期中具有灵活扩展性。
回调
回调类似于观察者模式,我们将DiscountCalculator类重构为接口,并让Customer实现这个接口。新的类结构图如下所示。
在这个例子中,回调类似于下移和最初版本的组合,Customer作为DiscountCalculator类型被传给Bill。与下移中DiscountCalculator类被封装在一个单独的模块不同的是,现在我们把它放在bill模块中。需要注意的是,它不能放在cust模块,这会引入循环依赖。新的类结构图如下所示,有点像消除循环依赖的最初版本。
依赖反转
接下来我们讨论一下模块关系。虽然回调看上去是最合理的解决方法,但如果我们想单独部署cust模块而不依赖bill模块呢?回调并不能做到这一点,不过通过一些小技巧,cust和bill模块的依赖关系能够被反转。
首先,将Bill类重构为接口,接下来为了避免分离包(同一个包中的不同类被打包到不同的模块),我将Bill类和Customer类放在同一个包。新的类图如下所示。
反转后的模块结构图如下所示 。
消除依赖
依赖反转满足了我们这样的需求,独立部署cust模块而不用依赖bill模块。不过现在,我想探索基于独立测试模块需求的解决方法。在依赖反转之后,我能够独立的测试cust模块,但是如果我想同时独立测试(或者部署)两个模块呢?为了达到这个目的,需要彻底消除两者之间的关联。
事实证明,在使用了依赖反转(大多是抽象耦合)后,类结构变得非常灵活。我只需要简单地把两个接口-Bill和DiscountCalculator-分别封装起来,不需要额外的修改。
我将它们移到一个新的包base,一样地,修改编译脚本将这两个接口打包到base模块。至此,我们成功地消除了bill模块和cust模块的关联,模块结构如下所示。
总结
从最开始的两个存在循环依赖的模块,到最后模块之间没有任何依赖的模块结构,我们取得了很大的进步。 模块之间没有依赖,就意味着模块可以独立测试和部署。如果你关注我的博客,你应该知道我已经写了大量的文章,关于权衡灵活性和复杂性,可用和重用,以及其他架构和设计方面的。我也希望这个小例子能说清楚这些里面的部分概念。
最后提示,为了更深入地了解这样设计的目的,以及从对象层次上去理解为什么要这样做,我希望你能亲自运行每个工程的编译脚本,并查看在stats目录下的dependencies.html文件。当然,你需要确保JarAnalyzer正确运行,而JarAnalyzer需要依赖GraphViz。如你所见,相比原始的版本,最终的版本在设计质量上有着显著的提升。
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/119764.html