1. 程式人生 > >淺談依賴注入

淺談依賴注入

轉載自部落格園一位前輩寫的很不錯的文章
作者: yangecnu(yangecnu’s Blog on 部落格園)
出處:http://www.cnblogs.com/yangecnu/

淺談依賴注入

最近幾天在看一本名為Dependency Injection in .NET 的書,主要講了什麼是依賴注入,使用依賴注入的優點,以及.NET平臺上依賴注入的各種框架和用法。在這本書的開頭,講述了軟體工程中的一個重要的理念就是關注分離(Separation of concern, SoC)。依賴注入不是目的,它是一系列工具和手段,最終的目的是幫助我們開發出鬆散耦合(loose coupled)、可維護、可測試的程式碼和程式。這條原則的做法是大家熟知的面向介面,或者說是面向抽象程式設計。

關於什麼是依賴注入,在Stack Overflow上面有一個問題,如何向一個5歲的小孩解釋依賴注入,其中得分最高的一個答案是:

*“When you go and get things out of the refrigerator for yourself, you can cause problems. You might leave the door open, you might get something Mommy or Daddy doesn’t want you to have. You might even be looking for something we don’t even have or which has expired.
What you should be doing is stating a need, “I need something to drink with lunch,” and then we will make sure you have something when you sit down to eat.”*

對映到面向物件程式開發中就是:高層類(5歲小孩)應該依賴底層基礎設施(家長)來提供必要的服務。

編寫鬆耦合的程式碼說起來很簡單,但是實際上寫著寫著就變成了緊耦合。

使用例子來說明可能更簡潔明瞭,首先來看看什麼樣的程式碼是緊耦合。

1 不好的實現

編寫鬆耦合程式碼的第一步,可能大家都熟悉,那就是對系統分層。比如下面的經典的三層架構。
這裡寫圖片描述

分完層和實現好是兩件事情,並不是說分好層之後就能夠鬆耦合了。

1.1 緊耦合的程式碼

有很多種方式來設計一個靈活的,可維護的複雜應用,但是n層架構是一種大家比較熟悉的方式,這裡面的挑戰在於如何正確的實現n層架構。

假設要實現一個很簡單的電子商務網站,要列出商品列表,如下:

這裡寫圖片描述

下面就具體來演示通常的做法,是如何一步一步把程式碼寫出緊耦合的。

1.1.1 資料訪問層

要實現商品列表這一功能,首先要編寫資料訪問層,需要設計資料庫及表,在SQLServer中設計的資料庫表Product結構如下:

這裡寫圖片描述

表設計好之後,就可以開始寫程式碼了。在Visual Studio 中,新建一個名為DataAccessLayer的工程,新增一個ADO.NET Entity Data Model,此時Visual Studio的嚮導會自動幫我們生成Product實體和ObjectContext DB操作上下文。這樣我們的 Data Access Layer就寫好了。

Product Entity Model

1.1.2 業務邏輯層

表現層實際上可以直接訪問資料訪問層,通過ObjectContext 獲取Product 列表。但是大多數情況下,我們不是直接把DB裡面的資料展現出來,而是需要對資料進行處理,比如對會員,需要對某些商品的價格打折。這樣我們就需要業務邏輯層,來處理這些與具體業務邏輯相關的事情。

新建一個類庫,命名為DomainLogic,然後新增一個名為ProductService的類:

public class ProductService {
    private readonly CommerceObjectContext objectContext;

    public ProductService()
    {
        this.objectContext = new CommerceObjectContext();
    }

    public IEnumerable<Product> GetFeaturedProducts(
        bool isCustomerPreferred)
    {
        var discount = isCustomerPreferred ? .95m : 1;
        var products = (from p in this.objectContext
                            .Products
                        where p.IsFeatured
                        select p).AsEnumerable();
        return from p in products
                select new Product
                {
                    ProductId = p.ProductId,
                    Name = p.Name,
                    Description = p.Description,
                    IsFeatured = p.IsFeatured,
                    UnitPrice = p.UnitPrice * discount
                };
    }
}

現在我們的業務邏輯層已經實現了。

1.1.3 表現層

現在實現表現層邏輯,這裡使用ASP.NET MVC,在Index 頁面的Controller中,獲取商品列表然後將資料返回給View。

public ViewResult Index()
{
    bool isPreferredCustomer = 
        this.User.IsInRole("PreferredCustomer");

    var service = new ProductService();
    var products = 
        service.GetFeaturedProducts(isPreferredCustomer);
    this.ViewData["Products"] = products;

    return this.View();
}

然後在View中將Controller中返回的資料展現出來:

<h2>Featured Products</h2>
<div>
<% var products =
        (IEnumerable<Product>)this.ViewData["Products"];
    foreach (var product in products)
    { %>
    <div>
    <%= this.Html.Encode(product.Name) %>
    (<%= this.Html.Encode(product.UnitPrice.ToString("C")) %>)
    </div>
<% } %>
</div>

1.2 分析

現在,按照三層“架構”我們的程式碼寫好了,並且也達到了要求。整個專案的結構如下圖:

這裡寫圖片描述

這應該是我們通常經常寫的所謂的三層架構。在Visual Studio中,三層之間的依賴可以通過專案引用表現出來。

1.2.1 依賴關係圖

現在我們來分析一下,這三層之間的依賴關係,很明顯,上面的實現中,DomianLogic需要依賴SqlDataAccess,因為DomainLogic中用到了Product這一實體,而這個實體是定義在DataAccess這一層的。WebUI這一層需要依賴DomainLogic,因為ProductService在這一層,同時,還需要依賴DataAccess,因為在UI中也用到了Product實體,現在整個系統的依賴關係是這樣的:

這裡寫圖片描述

1.2.2 耦合性分析

使用三層結構的主要目的是分離關注點,當然還有一個原因是可測試性。我們應該將領域模型從資料訪問層和表現層中分離出來,這樣這兩個層的變化才不會汙染領域模型。在大的系統中,這點很重要,這樣才能將系統中的不同部分隔離開來。

現在來看之前的實現中,有沒有模組性,有沒有那個模組可以隔離出來呢。現在新增幾個新的case來看,系統是否能夠響應這些需求:

新增新的使用者介面

除了WebForm使用者之外,可能還需要一個WinForm的介面,現在我們能否複用領域層和資料訪問層呢?從依賴圖中可以看到,沒有任何一個模組會依賴表現層,因此很容易實現這一點變化。我們只需要建立一個WPF的富客戶端就可以。現在整個系統的依賴圖如下:

這裡寫圖片描述

更換新的資料來源

可能過了一段時間,需要把整個系統部署到雲上,要使用其他的資料儲存技術,比如Azure Table Storage Service。現在,整個訪問資料的協議發生了變化,訪問Azure Table Storage Service的方式是Http協議,而之前的大多數.NET 訪問資料的方式都是基於ADO.NET 的方式。並且資料來源的儲存方式也發生了改變,之前是關係型資料庫,現在變成了key-value型資料庫。

這裡寫圖片描述

由上面的依賴關係圖可以看出,所有的層都依賴了資料訪問層,如果修改資料訪問層,則領域邏輯層,和表現層都需要進行相應的修改。

1.2.3 問題

除了上面的各層之間耦合下過強之外,程式碼中還有其他問題。

領域模型似乎都寫到了資料訪問層中。所以領域模型看起來依賴了資料訪問層。在資料訪問層中定義了名為Product的類,這種類應該是屬於領域模型層的。
表現層中摻入了決定某個使用者是否是會員的邏輯。這種業務邏輯應該是 業務邏輯層中應該處理的,所以也應該放到領域模型層
ProductService因為依賴了資料訪問層,所以也會依賴在web.config 中配置的資料庫連線字串等資訊。這使得,整個業務邏輯層也需要依賴這些配置才能正常執行。
在View中,包含了太多了函式性功能。他執行了強制型別轉換,字串格式化等操作,這些功能應該是在介面顯示得模型中完成。

上面可能是我們大多數寫程式碼時候的實現, UI介面層去依賴了資料訪問層,有時候偷懶就直接引用了這一層,因為實體定義在裡面了。業務邏輯層也是依賴資料訪問層,直接在業務邏輯裡面使用了資料訪問層裡面的實體。這樣使得整個系統緊耦合,並且可測試性差。那現在我們看看,如何修改這樣一個系統,使之達到鬆散耦合,從而提高可測試性呢?

2 較好的實現

依賴注入能夠較好的解決上面出現的問題,現在可以使用這一思想來重新實現前面的系統。之所以重新實現是因為,前面的實現在一開始的似乎就沒有考慮到擴充套件性和鬆耦合,使用重構的方式很難達到理想的效果。對於小的系統來說可能還可以,但是對於一個大型的系統,應該是比較困難的。

在寫程式碼的時候,要管理好依賴性,在前面的實現這種,程式碼直接控制了依賴性:當ProductService需要一個ObjectContext類的似乎,直接new了一個,當HomeController需要一個ProductService的時候,直接new了一個,這樣看起來很酷很方便,實際上使得整個系統具有很大的侷限性,變得緊耦合。new 操作實際上就引入了依賴, 控制反轉這種思想就是要使的我們比較好的管理依賴。

2.1 鬆耦合的程式碼
2.1.1 表現層

首先從表現層來分析,表現層主要是用來對資料進行展現,不應該包含過多的邏輯。在Index的View頁面中,程式碼希望可以寫成這樣

<h2>
    Featured Products</h2>
<div>
    <% foreach (var product in this.Model.Products)
        { %>
    <div>
        <%= this.Html.Encode(product.SummaryText) %></div>
    <% } %>
</div>

可以看出,跟之前的表現層程式碼相比,要整潔很多。很明顯是不需要進行型別轉換,要實現這樣的目的,只需要讓Index.aspx這個檢視繼承自 System.Web.Mvc.ViewPage 即可,當我們在從Controller建立View的時候,可以進行選擇,然後會自動生成。整個用於展示的資訊放在了SummaryText欄位中。

這裡就引入了一個檢視模型(View-Specific Models),他封裝了檢視的行為,這些模型只是簡單的POCOs物件(Plain Old CLR Objects)。FeatureProductsViewModel中包含了一個List列表,每個元素是一個ProductViewModel類,其中定義了一些簡單的用於資料展示的欄位。
這裡寫圖片描述

現在在Controller中,我們只需要給View返回FeatureProductsViewModel物件即可。比如:

public ViewResult Index()
{
    var vm = new FeaturedProductsViewModel();
    return View(vm);
}

現在返回的是空列表,具體的填充方式在領域模型中,我們接著看領域模型層。

2.1.2 領域邏輯層

新建一個類庫,這裡麵包含POCOs和一些抽象型別。POCOs用來對領域建模,抽象型別提供抽象作為到達領域模型的入口。依賴注入的原則是面向介面而不是具體的類程式設計,使得我們可以替換具體實現。

現在我們需要為表現層提供資料。因此使用者介面層需要引用領域模型層。對資料訪問層的簡單抽象可以採用Patterns of Enterprise Application Architecture一書中講到的Repository模式。因此定義一個ProductRepository抽象類,注意是抽象類,在領域模型庫中。它定義了一個獲取所有特價商品的抽象方法:

public abstract class ProductRepository
{
    public abstract IEnumerable<Product> GetFeaturedProducts();
}

這個方法的Product類中只定義了商品的基本資訊比如名稱和單價。整個關係圖如下:

這裡寫圖片描述

現在來看錶現層,HomeController中的Index方法應該要使用ProductService例項類來獲取商品列表,執行價格打折,並且把Product類似轉化為ProductViewModel例項,並將該例項加入到FeaturesProductsViewModel中。因為ProductService有一個帶有型別為ProductReposity抽象類的建構函式,所以這裡可以通過建構函式注入實現了ProductReposity抽象類的例項。這裡和之前的最大區別是,我們沒有使用new關鍵字來立即new一個物件,而是通過建構函式的方式傳入具體的實現。

現在來看錶現層程式碼:

public partial class HomeController : Controller
{
    private readonly ProductRepository repository;

    public HomeController(ProductRepository repository)
    {
        if (repository == null)
        {
            throw new ArgumentNullException("repository");
        }

        this.repository = repository;
    }

    public ViewResult Index()
    {
        var productService = new ProductService(this.repository);

        var vm = new FeaturedProductsViewModel();

        var products = productService.GetFeaturedProducts(this.User);
        foreach (var product in products)
        {
            var productVM = new ProductViewModel(product);
            vm.Products.Add(productVM);
        }

        return View(vm);
    }

}

在HomeController的建構函式中,傳入了實現了ProductRepository抽象類的一個例項,然後將該例項儲存在定義的私有的只讀的ProductRepository型別的repository物件中,這就是典型的通過建構函式注入。在Index方法中,獲取資料的ProductService類中的主要功能,實際上是通過傳入的repository類來代理完成的。

ProductService類是一個純粹的領域物件,實現如下:

public class ProductService
{
    private readonly ProductRepository repository;

    public ProductService(ProductRepository repository)
    {
        if (repository == null)
        {
            throw new ArgumentNullException("repository");
        }

        this.repository = repository;
    }

    public IEnumerable<DiscountedProduct> GetFeaturedProducts(IPrincipal user)
    {
        if (user == null)
        {
            throw new ArgumentNullException("user");
        }

        return from p in
                        this.repository.GetFeaturedProducts()
                select p.ApplyDiscountFor(user);
    }
}

可以看到ProductService也是通過建構函式注入的方式,儲存了實現了ProductReposity抽象類的例項,然後藉助該例項中的GetFeatureProducts方法,獲取原始列表資料,然後進行打折處理,進而實現了自己的GetFeaturedProducts方法。在該GetFeaturedProducts方法中,跟之前不同的地方在於,現在的引數是IPrincipal,而不是之前的bool型,因為判斷使用者的狀況,這是一個業務邏輯,不應該在表現層處理。IPrincipal是BCL中的型別,所以不存在額外的依賴。我們應該基於介面程式設計IPrincipal是應用程式使用者的一種標準方式。

這裡將IPrincipal作為引數傳遞給某個方法,然後再裡面呼叫實現的方式是依賴注入中的方法注入的手段。和建構函式注入一樣,同樣是將內部實現代理給了傳入的依賴物件。

現在我們只剩下兩塊地方沒有處理了:

沒有ProductRepository的具體實現,這個很容易實現,後面放到資料訪問層裡面去處理,我們只需要建立一個具體的實現了ProductRepository的資料訪問類即可。
預設上,ASP.NET MVC 希望Controller物件有自己的預設建構函式,因為我們在HomeController中添加了新的建構函式來注入依賴,所以MVC框架不知道如何解決建立例項,因為有依賴。這個問題可以通過開發一個IControllerFactory來解決,該物件可以建立一個具體的ProductRepositry例項,然後傳給HomeController這裡不多講。

現在我們的領域邏輯層已經寫好了。在該層,我們只操作領域模型物件,以及.NET BCL 中的基本物件。模型使用POCOs來表示,命名為Product。領域模型層必須能夠和外界進行交流(database),所以需要一個抽象類(Repository)來時完成這一功能,並且在必要的時候,可以替換具體實現。

2.1.3 資料訪問層

現在我們可以使用LINQ to Entity來實現具體的資料訪問層邏輯了。因為要實現領域模型的ProductRepository抽象類,所以需要引入領域模型層。注意,這裡的依賴變成了資料訪問層依賴領域模型層。跟之前的恰好相反,程式碼實現如下:

public class SqlProductRepository : Domain.ProductRepository
{
    private readonly CommerceObjectContext context;

    public SqlProductRepository(string connString)
    {
        this.context =
            new CommerceObjectContext(connString);
    }

    public override IEnumerable<Domain.Product> GetFeaturedProducts()
    {
        var products = (from p in this.context.Products
                        where p.IsFeatured
                        select p).AsEnumerable();
        return from p in products
                select p.ToDomainProduct();
    }
}

在這裡需要注意的是,在領域模型層中,我們定義了一個名為Product的領域模型,然後再資料訪問層中Entity Framework幫我們也生成了一個名為Product的資料訪問層實體,他是和db中的Product表一一對應的。所以我們在方法返回的時候,需要把型別從db中的Product轉換為領域模型中的POCOs Product物件。

這裡寫圖片描述

Domain Model中的Product是一個POCOs型別的物件,他僅僅包含領域模型中需要用到的一些基本欄位,DataAccess中的Product物件是對映到DB中的實體,它包含資料庫中Product表定義的所有欄位,在資料表現層中我們 定義了一個ProductViewModel資料展現的Model。

這兩個物件之間的轉換很簡單:

public class Product
{
    public Domain.Product ToDomainProduct()
    {
        Domain.Product p = new Domain.Product();
        p.Name = this.Name;
        p.UnitPrice = this.UnitPrice;
        return p;
    }
}

2.2 分析
2.2.1 依賴關係圖

現在,整個系統的依賴關係圖如下:
這裡寫圖片描述
表現層和資料訪問層都依賴領域模型層,這樣,在前面的case中,如果我們新新增一個UI介面;更換一種資料來源的儲存和獲取方式,只需要修改對應層的程式碼即可,領域模型層保持了穩定。

2.2.2 時序圖

整個系統的時序圖如下:
這裡寫圖片描述

系統啟動的時候,在Global.asax中建立了一個自定義了Controller工廠類,應用程式將其儲存在本地便兩種,當頁面請求進來的時候,程式出發該工廠類的CreateController方法,並查詢web.config中的資料庫連線字串,將其傳遞給新的SqlProductRepository例項,然後將SqlProductRepository例項注入到HomeControll中,並返回。

然後應用呼叫HomeController的例項方法Index來建立新的ProductService類,並通過建構函式傳入SqlProductRepository。ProductService的GetFeaturedProducts 方法代理給SqlProductRepository例項去實現。

最後,返回填充好了FeaturedProductViewModel的ViewResult物件給頁面,然後MVC進行合適的展現。

2.2.3 新的結構

在1.1的實現中,採用了三層架構,在改進後的實現中,在UI層和領域模型層中加入了一個表現模型(presentation model)層。如下圖:
這裡寫圖片描述

將Controllers和ViewModel從表現層移到了表現模型層,僅僅將檢視(.aspx和.ascx檔案)和聚合根物件(Composition Root)保留在了表現層中。之所以這樣處理,是可以使得儘可能的使得表現層能夠可配置而其他部分儘可能的可以保持不變。

3. 結語

一不小心我們就編寫出了緊耦合的程式碼,有時候以為分層了就可以解決這一問題,但是大多數的時候,都沒有正確的實現分層。之所以容易寫出緊耦合的程式碼有一個原因是因為程式語言或者開發環境允許我們只要需要一個新的例項物件,就可以使用new關鍵字來例項化一個。如果我們需要新增依賴,Visual Studio有些時候可以自動幫我們新增引用。這使得我們很容易就犯錯,使用new關鍵字,就可能會引入以來;新增引用就會產生依賴。

減少new引入的依賴及緊耦合最好的方式是使用建構函式注入依賴這種設計模式:即如果我們需要一個依賴的例項,通過建構函式注入。在第二個部分的實現演示瞭如何針對抽象而不是具體程式設計。

建構函式注入是反轉控制的一個例子,因為我們反轉了對依賴的控制。不是使用new關鍵字建立一個例項,而是將這種行為委託給了第三方實現。

希望本文能夠給大家瞭解如何真正實現三層架構,編寫鬆散耦合,可維護,可測試性的程式碼提供一些幫助。