接缝(seam)是Michael C. Feathers提出的概念。Feathers在Working Effectively with Legacy Code一书中对接缝的定义如下:接缝,顾名思义,就是指程序中的一些特殊的点,在这些点上你无需作任何修改就可以达到改动程序行为的目的。
“接缝”这个词语不太好理解,根据我的理解,大约还是依赖点的含义。通过事先找到依赖点,并采取一定方式解除依赖,就能够改善代码质量,尤其是针对遗留代码而言。准确而言,我们寻找接缝以及解依赖,就是为了代码能够具有好的可重用性与可扩展性,尤其是当我们能解除对其他外部服务的依赖时,可以带来程序的可测试性。
最近项目组的同事和我讨论了这样一个满足可测试性的问题。项目中需要对返回的响应信息PlatformResponse进行处理,这些信息会根据不同的StatusCode,得到不同的提示或出错信息。为了避免分支语句的判断,同事利用hash table将StatusCode与提示(出错)信息进行了映射,然后根据当前的StatusCode就可以返回对应的结果。返回结果后,还需要调用外部服务对消息进行处理,例如消息的输出。由于之前相关的类PlatformResponse并没有提供这一逻辑,相关服务要返回消息时,直接返回了PlatformResponse对象,然后再由客户端根据当前的StatusCode来判断,输出相关的提示信息,所以同事将这些逻辑写到了扩展方法中,例如定义PlatformResponseHelper静态类:
|
通过引入扩展方法,Controller得到的PlatformResponse对象就可以通过调用扩展方法Output()输出获得的提示(出错)信息。注意,在上面的代码中,ServiceLocator是一个单例的服务定位器对象,通过它可以获得注册的服务。在Controller中,同样调用了ServiceLocator来获得它所需的业务服务。
现在,我们需要进行单元测试。项目之前已经为ServiceLocator提供了Mock对象,并且该对象在Controller中也是通过依赖注入的方式获得的。所以,在测试Controller时,可以通过注入模拟的ServiceLocator对象进行测试,从而解除与外部服务之间的依赖关系。现在,在增加了PlatformResponse的扩展方法时,遇到了难题,即如何解除扩展方法与ServiceLocator之间的依赖关系?
显然,这里的ServiceLocator.Lookup
在面向对象设计中,最常见的解除依赖的方法是职责分离以及抽象,或者利用反射或IOC容器来解除具体依赖。由于要解除与ServiceLocator的耦合关系,再加上调用PlatformResponse相关方法的Controller也是通过依赖注入ServiceLocator对象的,所以我首先想到将ServiceLocator转移到扩展方法的外部,通过传入参数的方式注入依赖。由于这个对象是单例的,因此Controller获得的ServiceLocator也就是PlatformResponse需要的对象。当我们调用PlatformResponse的Output()方法时,可以将Controller获得的ServiceLocator对象作为方法参数传给PlatformResponse。在Controller层,我们利用依赖注入注入Mock对象,就可以达到较好的可测试性了。
然而,倘若要这样做,就需要将调用代码改为:
|
同事觉得在调用Output()方法时,还需要传入ServiceLocator对象,实在不够优雅而简洁。
由于C#的扩展方法有很多限制,例如它要求必须是静态类和静态方法,很难利用OO的一些特性,所以我想到的第二个方案是不采用扩展方法,而是将之前的逻辑直接封装到PlatformResponse中。我们可以将Output()方法定义为虚方法,然后再为测试定义PlatformResponse的子类,它将作为测试使用的Mock类,重写Output()方法。遗憾的是,系统基本上都是在调用外部服务的时候才获得的PlatformResponse对象。我们不可能去修改服务对象,使其在单元测试时返回该类的子类对象。
第三条路是转移职责,将扩展方法Output()转移到一个专门的类,例如OutputMessage,由它来负责管理StatusCode与提示(出错)消息之间的映射关系,以及消息的输出,然后由子类重写消息处理的逻辑,完成模拟。例如代码:
|
实际上这一方案是第二种方案的一种变化。因为我们无法修改一个已经被广泛使用的类,所以只能在引入新职责的时候,通过引入新生类来完成职责的增加,并利用子类重写的方式达到可测试的目的。
可是这一方案实际上更无法达到同事的目标,因为改动后的调用变得比第一种方案更复杂:
|
我们必须考虑OutputMessage对象的创建,同事还需要将PlatformResponse对象传入,再调用它的Output()方法。虽然不需要传入方法参数,但对象的创建以及构造函数参数的传入,反而让事情变得更复杂。
那么,应该怎么办?同事的理想目标是调用简单。就目前而言,在C#中,只有扩展方法才能让我们对PlatformResponse对象的message处理显得如此的自然而简洁。再加上PlatformMessage对象已经被广泛使用,因此从PlatformMessage类的角度进行处理,就变得不再可能。
让我们再来仔细思考“接缝”的问题。是谁引入了依赖点?接缝是调用ServiceLocator这条语句,而它的目的实际上是需要获得IMessageWriter。是这个外部服务成为测试的障碍。所以解决的重点应该是解除与IMessageWriter之间的依赖。要这样做,就需要修改Output()扩展方法,使其能够传入IMessageWriter对象。这种改进事实上与第一种方案没有什么区别,唯一的不同是它依赖于更小的接口,而不是全局的ServiceLocator对象。我认为,这已经是一个最好的方案了。但是同事依旧执著于调用的简单性。他认为,不能为了单元测试,而改变客户端调用的方式。
现在,我们已经明白扩展方法是最简单的实现方式,纠结仅仅在于IMessageWriter服务的获取方式而已。在产品代码中,我们可以通过ServiceLocator来获得IMessageWriter对象,而在测试的时候,我们又需要模拟该服务对象。若要两全齐美,只有区分测试与生产环境。事实上,这是我最初想到的做法,就是引入预定义来区分测试与真正的生产环境。但同事无法接受这种C++所主要采取的预定义做法。因此,我唯一能想到的是修改PlatformMessage类的定义,提供设置IMessageWriter的属性(因为C#并不支持扩展属性),并在Output()方法中判断IMessageWriter对象是否为null。如果为null,则说明它没有在测试环境下注入,这就需要通过ServiceLocator获得。 Liquid error: invalid byte sequence in US-ASCII
虽然我修改了PlatformResponse类的定义,但由于需要调用或创建PlatformResponse对象的外部服务并不需要新增加的MessageWriter属性,因此这样的修改实际上是扩展,并不会影响到以前的代码。这就是我唯一能够想到的满足同事要求的方案。在测试时,我们可以通过为PlatformResponse注入模拟的IMessageWriter对象,而在真正的产品代码中,则无需为它设置MessageWriter属性,而是直接调用它的扩展方法Output()。美中不足之处在于它为调用者提供了一定的开放性,使得调用者能够自由设置MessageWriter属性,破坏了对象的封装。为使这种破坏带来的影响降到最低,在单元测试放在同一个项目的前提下,可以考虑将MessageWriter属性定义为internal。
我们常常需要在灵活性和简单性之间进行设计权衡。大多数情况下,都可能产生非此即彼的选择。要做到两全齐美真的很难。从面向对象的角度来看,我不认为最后的方案是最佳方案。其实,通过方法参数注入IMessageWriter的做法已经足够好了,它并没有加大结构与调用的复杂性。无论怎样,设计总是见仁见智的问题,就看大家的选择了。唯一需要遵循的原则,就是设计必须结合具体的场景来做出正确的决定。