解依賴與接縫

來源: 捷道  發布時間: 2011-11-24 13:05  閱讀: 2058 次  推薦: 0   原文鏈接   [收藏]  

  接縫(seam)是Michael C. Feathers提出的概念。Feathers在Working Effectively with Legacy Code一書中對接縫的定義如下:

接縫,顧名思義,就是指程序中的一些特殊的點,在這些點上你無需作任何修改就可以達到改動程序行為的目的。

  “接縫”這個詞語不太好理解,根據我的理解,大約還是依賴點的含義。通過事先找到依賴點,并采取一定方式解除依賴,就能夠改善代碼質量,尤其是針對遺留代碼而言。準確而言,我們尋找接縫以及解依賴,就是為了代碼能夠具有好的可重用性與可擴展性,尤其是當我們能解除對其他外部服務的依賴時,可以帶來程序的可測試性。

  最近項目組的同事和我討論了這樣一個滿足可測試性的問題。項目中需要對返回的響應信息PlatformResponse進行處理,這些信息會根據不同的StatusCode,得到不同的提示或出錯信息。為了避免分支語句的判斷,同事利用hash table將StatusCode與提示(出錯)信息進行了映射,然后根據當前的StatusCode就可以返回對應的結果。返回結果后,還需要調用外部服務對消息進行處理,例如消息的輸出。由于之前相關的類PlatformResponse并沒有提供這一邏輯,相關服務要返回消息時,直接返回了PlatformResponse對象,然后再由客戶端根據當前的StatusCode來判斷,輸出相關的提示信息,所以同事將這些邏輯寫到了擴展方法中,例如定義PlatformResponseHelper靜態類:

public static class PlatformResponseHelper {
   
private static HashTable<String,String> messageMapping = //此處略
    public static void Output(this PlatformResponse response) {
        ServiceLocator.Lookup
<IMessageWriter>.Write(messageMapping[response.StatusCode]);
    }
}

  通過引入擴展方法,Controller得到的PlatformResponse對象就可以通過調用擴展方法Output()輸出獲得的提示(出錯)信息。注意,在上面的代碼中,ServiceLocator是一個單例的服務定位器對象,通過它可以獲得注冊的服務。在Controller中,同樣調用了ServiceLocator來獲得它所需的業務服務。

  現在,我們需要進行單元測試。項目之前已經為ServiceLocator提供了Mock對象,并且該對象在Controller中也是通過依賴注入的方式獲得的。所以,在測試Controller時,可以通過注入模擬的ServiceLocator對象進行測試,從而解除與外部服務之間的依賴關系。現在,在增加了PlatformResponse的擴展方法時,遇到了難題,即如何解除擴展方法與ServiceLocator之間的依賴關系?

  顯然,這里的ServiceLocator.Lookup<IMessageWriter>.Write()方法調用就是前面所說的“接縫”。我們希望在單元測試中不依賴于ServiceLocator,這就需要解除PlatformResponse與ServiceLocator之間的耦合關系。同事希望既能達到可測試性的目的,又要保障調用的簡單。

  在面向對象設計中,最常見的解除依賴的方法是職責分離以及抽象,或者利用反射或IOC容器來解除具體依賴。由于要解除與ServiceLocator的耦合關系,再加上調用PlatformResponse相關方法的Controller也是通過依賴注入ServiceLocator對象的,所以我首先想到將ServiceLocator轉移到擴展方法的外部,通過傳入參數的方式注入依賴。由于這個對象是單例的,因此Controller獲得的ServiceLocator也就是PlatformResponse需要的對象。當我們調用PlatformResponse的Output()方法時,可以將Controller獲得的ServiceLocator對象作為方法參數傳給PlatformResponse。在Controller層,我們利用依賴注入注入Mock對象,就可以達到較好的可測試性了。

  然而,倘若要這樣做,就需要將調用代碼改為:

businessService.Response.Output(serviceLocator);

  同事覺得在調用Output()方法時,還需要傳入ServiceLocator對象,實在不夠優雅而簡潔。

  由于C#的擴展方法有很多限制,例如它要求必須是靜態類和靜態方法,很難利用OO的一些特性,所以我想到的第二個方案是不采用擴展方法,而是將之前的邏輯直接封裝到PlatformResponse中。我們可以將Output()方法定義為虛方法,然后再為測試定義PlatformResponse的子類,它將作為測試使用的Mock類,重寫Output()方法。遺憾的是,系統基本上都是在調用外部服務的時候才獲得的PlatformResponse對象。我們不可能去修改服務對象,使其在單元測試時返回該類的子類對象。

  第三條路是轉移職責,將擴展方法Output()轉移到一個專門的類,例如OutputMessage,由它來負責管理StatusCode與提示(出錯)消息之間的映射關系,以及消息的輸出,然后由子類重寫消息處理的邏輯,完成模擬。例如代碼:

public class OutputMessage {
   
private PlatformResponse response;
   
private HashTable<String, String> messageMapping = //此處略
    public OutputMessage(PlatformResponse response) {
       
this.response = response;
    }
   
public void Output() {
        OutputInternal();
    }
   
protected virtual void OutputInternal() {
        ServiceLocator.Lookup
<IMessageWriter>.Write(messageMapping[response.StatusCode]);
    }
}

public class MockOutputMessage {
   
public MockOutputMessage(PlatformResponse response):base(response) {}
   
protected override void OutputInternal() {
   
//模擬國際化服務對消息進行處理;
   }
}

  實際上這一方案是第二種方案的一種變化。因為我們無法修改一個已經被廣泛使用的類,所以只能在引入新職責的時候,通過引入新生類來完成職責的增加,并利用子類重寫的方式達到可測試的目的。

  可是這一方案實際上更無法達到同事的目標,因為改動后的調用變得比第一種方案更復雜:

   new OutputMessage(businessService.Response).Output();

  我們必須考慮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獲得。

public class PlatformResponse {
   
private IMessageWriter writer = null;
   
public IMessageWriter MessageWriter {
       
get; set;
    }
}

public static class PlatformResponseHelper {
   
private static HashTable<String,String> messageMapping = //略去

    public static void Output(this PlatformResponse response) {
        String message
= messageMapping[response.StatusCode];
       
//如果不為null,說明是測試注入了該對象;
       if (response.MessageWriter !=null) {
           response.MessageWriter.Write(message);
       }
else {
           ServiceLocator.Lookup
<IMessageWriter>.Write(message);
       }
    }
}

  雖然我修改了PlatformResponse類的定義,但由于需要調用或創建PlatformResponse對象的外部服務并不需要新增加的MessageWriter屬性,因此這樣的修改實際上是擴展,并不會影響到以前的代碼。這就是我唯一能夠想到的滿足同事要求的方案。在測試時,我們可以通過為PlatformResponse注入模擬的IMessageWriter對象,而在真正的產品代碼中,則無需為它設置MessageWriter屬性,而是直接調用它的擴展方法Output()。美中不足之處在于它為調用者提供了一定的開放性,使得調用者能夠自由設置MessageWriter屬性,破壞了對象的封裝。為使這種破壞帶來的影響降到最低,在單元測試放在同一個項目的前提下,可以考慮將MessageWriter屬性定義為internal。

  我們常常需要在靈活性和簡單性之間進行設計權衡。大多數情況下,都可能產生非此即彼的選擇。要做到兩全齊美真的很難。從面向對象的角度來看,我不認為最后的方案是最佳方案。其實,通過方法參數注入IMessageWriter的做法已經足夠好了,它并沒有加大結構與調用的復雜性。無論怎樣,設計總是見仁見智的問題,就看大家的選擇了。唯一需要遵循的原則,就是設計必須結合具體的場景來做出正確的決定。

0
0
 
 
 

文章列表

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()