[ASP.NET MVC 小牛之路]04 - 依賴注入(DI)和Ninject

Liam Wang發表於2013-08-07

特別提醒:本文編寫時間是 2013 年,請根據目前 .NET 發展接收你所需的知識點。

為什麼需要依賴注入

在[ASP.NET MVC 小牛之路]系列的理解MVC模式文章中,我們提到MVC的一個重要特徵是關注點分離(separation of concerns)。我們希望應用程式的各部分元件儘可能多的相互獨立、儘可能少的相互依賴。

我們的理想情況是:一個元件可以不知道也可以不關心其他的元件,但通過提供的公開介面卻可以實現其他元件的功能呼叫。這種情況就是所謂的鬆耦合

舉個簡單的例子。我們要為商品定製一個“高階”的價錢計算器LinqValueCalculator,這個計算器需要實現IValueCalculator介面。如下程式碼所示:

public interface IValueCalculator {
    decimal ValueProducts(params Product[] products);
}

public class LinqValueCalculator : IValueCalculator {
    public decimal ValueProducts(params Product[] products) {
        return products.Sum(p => p.Price);
    }
}

Product類和前兩篇博文中用到的是一樣的。現在有個購物車ShoppingCart類,它需要有一個能計算購物車內商品總價錢的功能。但購物車本身沒有計算的功能,因此,購物車要嵌入一個計算器元件,這個計算器元件可以是LinqValueCalculator元件,但不一定是LinqValueCalculator元件(以後購物車升級,可能會嵌入別的更高階的計算器)。那麼我們可以這樣定義購物車ShoppingCart類:

 1 public class ShoppingCart {
 2     //計算購物車內商品總價錢
 3     public decimal CalculateStockValue() {
 4         Product[] products = { 
 5             new Product {Name = "西瓜", Category = "水果", Price = 2.3M}, 
 6             new Product {Name = "蘋果", Category = "水果", Price = 4.9M}, 
 7             new Product {Name = "空心菜", Category = "蔬菜", Price = 2.2M}, 
 8             new Product {Name = "地瓜", Category = "蔬菜", Price = 1.9M} 
 9         };
10         IValueCalculator calculator = new LinqValueCalculator();
11 
12         //計算商品總價錢 
13         decimal totalValue = calculator.ValueProducts(products);
14 
15         return totalValue;
16     }
17 }

ShoppingCart類是通過IValueCalculator介面(而不是通過LinqValueCalculator)來計算商品總價錢的。如果以後購物車升級需要使用更高階的計算器,那麼只需要改變第10行程式碼中new後面的物件(即把LinqValueCalculator換掉),其他的程式碼都不用變動。這樣就實現了一定的鬆耦合。這時三者的關係如下圖所示:

這個圖說明,ShoppingCart類既依賴IValueCalculator介面又依賴LinqValueCalculator類。這樣就有個問題,用現實世界的話來講就是,如果嵌入在購物車內的計算器元件壞了,會導致整個購物車不能正常工作,豈不是要把整個購物車要換掉!最好的辦法是將計算器元件和購物車完全獨立開來,這樣不管哪個元件壞了,只要換對應的元件即可。即我們要解決的問題是,要讓ShoppingCart元件和LinqValueCalculator元件完全斷開關係,而依賴注入這種設計模式就是為了解決這種問題。

什麼是依賴注入

上面實現的部分鬆耦合顯然並不是我們所需要的。我們所需要的是,在一個類內部,不通過建立物件的例項而能夠獲得某個實現了公開介面的物件的引用。這種“需要”,就稱為DI(依賴注入,Dependency Injection),和所謂的IoC(控制反轉,Inversion of Control )是一個意思。

DI是一種通過介面實現鬆耦合的設計模式。初學者可能會好奇網上為什麼有那麼多技術文章對DI這個東西大興其筆,是因為DI對於基於幾乎所有框架下,要高效開發應用程式,它都是開發者必須要有的一個重要的理念,包括MVC開發。它是解耦的一個重要手段。

DI模式可分為兩個部分。一是移除對元件(上面示例中的LinqValueCalculator)的依賴,二是通過類的建構函式(或類的Setter訪問器)來傳遞實現了公開介面的元件的引用。如下面程式碼所示:

public class ShoppingCart {
    IValueCalculator calculator;
    
    //建構函式,引數為實現了IValueCalculator介面的類的例項
    public ShoppingCart(IValueCalculator calcParam) {
        calculator = calcParam;
    }

    //計算購物車內商品總價錢
    public decimal CalculateStockValue() {
        Product[] products = { 
            new Product {Name = "西瓜", Category = "水果", Price = 2.3M}, 
            new Product {Name = "蘋果", Category = "水果", Price = 4.9M}, 
            new Product {Name = "空心菜", Category = "蔬菜", Price = 2.2M}, 
            new Product {Name = "地瓜", Category = "蔬菜", Price = 1.9M} 
        };

        //計算商品總價錢 
        decimal totalValue = calculator.ValueProducts(products);

        return totalValue;
    }
}

這樣我們就徹底斷開了ShoppingCart和LinqValueCalculator之間的依賴關係。某個實現了IValueCalculator介面的類(示例中的LinqValueCalculator)的例項引用作為引數,傳遞給ShoppingCart類的建構函式。但是ShoppingCart類不知道也不關心這個實現了IValueCalculator介面的類是什麼,更沒有責任去操作這個類。 這時我們可以用下圖來描述ShoppingCart、LinqValueCalculator和IValueCalculator之間的關係:

在程式執行的時候,依賴被注入到ShoppingCart,這個依賴就是,通過ShoppingCart建構函式傳遞實現了IValueCalculator介面的類的例項引用。在程式執行之前(或編譯時),ShoppingCart和任何實現IValueCalculator介面的類沒有任何依賴關係。(注意,程式執行時是有具體依賴關係的。)

注意,上面示例使用的注入方式稱為“構造注入”,我們也可以通過屬性來實現注入,這種注入被稱為“setter 注入”,就不舉例了,朋友們可以看看T2噬菌體的文章依賴注入那些事兒來對DI進行更多的瞭解。

由於經常會在程式設計時使用到DI,所以出現了一些DI的輔助工具(或叫DI容器),如Unity和Ninject等。由於Ninject的輕量和使用簡單,加上本人只用過Ninject,所以本系列文章選擇用它來開發MVC應用程式。下面開始介紹Ninject,但在這之前,先來介紹一個安裝Ninject需要用到的外掛-NuGet。

使用NuGet安裝庫

NuGet 是一種 Visual Studio 擴充套件,它能夠簡化在 Visual Studio 專案中新增、更新和刪除庫(部署為程式包)的操作。比如你要在專案中使用Log4Net這個庫,如果沒有NuGet這個擴充套件,你可能要先到網上搜尋Log4Net,再將程式包的內容解壓縮到解決方案中的特定位置,然後在各專案工程中依次新增程式集引用,最後還要使用正確的設定更新 web.config。而NuGet可以簡化這一切操作。例如我們在講依賴注入的專案中,若要使用一個NuGet庫,可直接右擊專案(或引用),選擇“管理NuGet程式包”(VS2010下為“Add Library Package Reference”),如下圖:

在彈出如下視窗中選擇“聯機”,搜尋“Ninject”,然後進行相應的操作即可:

在本文中我們只需要知道如何使用NuGet來安裝庫就可以了。NuGet的詳細使用方法可檢視MSDN文件:使用 NuGet 管理專案庫

使用Ninject的一般步驟

在使用Ninject前先要建立一個Ninject核心物件,程式碼如下:

class Program { 
    static void Main(string[] args) { 
        //建立Ninject核心例項
        IKernel ninjectKernel = new StandardKernel(); 
    } 
}

使用Ninject核心物件一般可分為兩個步驟。第一步是把一個介面(IValueCalculator)繫結到一個實現該介面的類(LinqValueCalculator),如下:

...
//繫結介面到實現了該介面的類 ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator<();
...

這個繫結操作就是告訴Ninject,當接收到一個請求IValueCalculator介面的實現時,就返回一個LinqValueCalculator類的例項。

第二步是用Ninject的Get方法去獲取IValueCalculator介面的實現。這一步,Ninject將自動為我們建立LinqValueCalculator類的例項,並返回該例項的引用。然後我們可以把這個引用通過建構函式注入到ShoppingCart類。如下程式碼所示:

...
// 獲得實現介面的物件例項
IValueCalculator calcImpl = ninjectKernel.Get<IValueCalculator>(); 
// 建立ShoppingCart例項並注入依賴
ShoppingCart cart = new ShoppingCart(calcImpl); 
// 計算商品總價錢並輸出結果
Console.WriteLine("Total: {0:c}", cart.CalculateStockValue());
...

Ninject的使用的一般步驟就是這樣。該示例可正確輸出如下結果:

但看上去Ninject的使用好像使得編碼變得更加煩瑣,朋友們會問,直接使用下面的程式碼不是更簡單嗎:

...
IValueCalculator calcImpl = new LinqValueCalculator();
ShoppingCart cart = new ShoppingCart(calcImpl);
Console.WriteLine("Total: {0:c}", cart.CalculateStockValue());
...

的確,對於單個簡單的DI,用Ninject確實顯得麻煩。但如果新增多個複雜點的依賴關係,使用Ninject則可大大提高編碼的工作效率。

Ninject如何提高編碼效率

當我們請求Ninject建立某個型別的例項時,它會檢查這個型別和其它型別之間的耦合關係。如果存在依賴關係,那麼Ninject會根據依賴處理理它們,並建立所有所需類的例項。為了解釋這句話和說明使用Ninject編碼的便捷,我們再建立一個介面IDiscountHelper和一個實現該介面的類DefaultDiscountHelper,程式碼如下:

//折扣計算介面
public interface IDiscountHelper {
    decimal ApplyDiscount(decimal totalParam);
}

//預設折扣計算器
public class DefaultDiscountHelper : IDiscountHelper {
    public decimal ApplyDiscount(decimal totalParam) {
        return (totalParam - (1m / 10m * totalParam));
    }
}

IDiscounHelper介面宣告瞭ApplyDiscount方法,DefaultDiscounterHelper實現了該介面,並定義了打9折的ApplyDiscount方法。然後我們可以把IDiscounHelper介面作為依賴新增到LinqValueCalculator類中。程式碼如下:

public class LinqValueCalculator : IValueCalculator { 
    private IDiscountHelper discounter; 
 
    public LinqValueCalculator(IDiscountHelper discountParam) { 
        discounter = discountParam; 
    } 
 
    public decimal ValueProducts(params Product[] products) { 
        return discounter.ApplyDiscount(products.Sum(p => p.Price)); 
    } 
}

LinqValueCalculator類新增了一個用於接收IDiscountHelper介面的實現的建構函式,然後在ValueProducts方法中呼叫該介面的ApplyDiscount方法對計算出的商品總價錢進行打折處理,並返回折後總價。

到這,我們先來畫個圖理一理ShoppingCart、LinqValueCalculator、IValueCalculator以及新新增的IDiscountHelper和DefaultDiscounterHelper之間的關係:

以此,我們還可以新增更多的介面和實現介面的類,介面和類越來越多時,它們的關係圖看上去會像一個依賴“鏈”,和生物學中的分子結構圖差不多。

按照前面說的使用Ninject的“二個步驟”,現在我們在Main中的方法中編寫用於計算購物車中商品折後總價錢的程式碼,如下所示:

 1 class Program {
 2     static void Main(string[] args) {
 3         IKernel ninjectKernel = new StandardKernel();
 4 
 5         ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>();
 6         ninjectKernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>();
 7 
 8         IValueCalculator calcImpl = ninjectKernel.Get<IValueCalculator>();
 9         ShoppingCart cart = new ShoppingCart(calcImpl);
10         Console.WriteLine("Total: {0:c}", cart.CalculateStockValue());
11         Console.ReadKey();
12     }
13 }

輸出結果:

程式碼一目瞭然,雖然新新增了一個介面和一個類,但Main方法中只增加了第6行一句程式碼,獲取實現IValueCalculator介面的物件例項的程式碼不需要做任何改變。
定位到程式碼的第8行,這一行程式碼,Ninject為我們做的事是:
  當我們需要使用IValueCalculator介面的實現時(通過Get方法),它便為我們建立LinqValueCalculator類的例項。而當建立LinqValueCalculator類的例項時,它檢查到這個類依賴IDiscountHelper介面。於是它又建立一個實現了該介面的DefaultDiscounterHelper類的例項,並通過建構函式把該例項注入到LinqValueCalculator類。然後返回LinqValueCalculator類的一個例項,並賦值給IValueCalculator介面的物件(第8行的calcImpl)。

總之,不管依賴“鏈”有多長有多複雜,Ninject都會按照上面這種方式檢查依賴“鏈”上的每個介面和實現介面的類,並自動建立所需要的類的例項。在依賴“鏈”越長越複雜的時候,更能顯示使用Ninject編碼的高效率。

Ninject的繫結方式

我個人將Ninject的繫結方式分為:一般繫結、指定值繫結、自我繫結、派生類繫結和條件繫結。這樣分類有點牽強,只是為了本文的寫作需要和方便讀者閱讀而分,並不是官方的分類

1、一般繫結

在前文的示例中用Bind和To方法把一個介面繫結到實現該介面的類,這屬於一般的繫結。通過前文的示例相信大家已經掌握了,在這就不再累述。

2、指定值繫結

我們知道,通過Get方法,Ninject會自動幫我們建立我們所需要的類的例項。但有的類在建立例項時需要給它的屬性賦值,如下面我們改造了一下的DefaultDiscountHelper類:

public class DefaultDiscountHelper : IDiscountHelper { 
    public decimal DiscountSize { get; set; } 
 
    public decimal ApplyDiscount(decimal totalParam) { 
        return (totalParam - (DiscountSize / 10m * totalParam)); 
    } 
}

給DefaultDiscountHelper類新增了一個DiscountSize屬性,例項化時需要指定折扣值(DiscountSize屬性值),不然ApplyDiscount方法就沒意義。而例項化的動作是Ninject自動完成的,怎麼告訴Ninject在例項化類的時候給某屬性賦一個指定的值呢?這時就需要用到引數繫結,我們在繫結的時候可以通過給WithPropertyValue方法傳參的方式指定DiscountSize屬性的值,如下程式碼所示:

public static void Main() {
    IKernel ninjectKernel = new StandardKernel();

    ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>();
    ninjectKernel.Bind<IDiscountHelper>()
        .To<DefaultDiscountHelper>().WithPropertyValue("DiscountSize", 5M);

    IValueCalculator calcImpl = ninjectKernel.Get<IValueCalculator>();
    ShoppingCart cart = new ShoppingCart(calcImpl);
    Console.WriteLine("Total: {0:c}", cart.CalculateStockValue());
    Console.ReadKey();
}

只是在Bind和To方法後新增了一個WithPropertyValue方法,其他程式碼都不用變,再一次見證了用Ninject編碼的高效。
WithPropertyValue方法接收了兩個引數,一個是屬性名(示例中的"DiscountSize"),一個是屬性值(示例中的5)。執行結果如下:

如果要給多個屬性賦值,則可以在Bind和To方式後新增多個WithPropertyValue(<屬性名>,<屬性值>)方法。

我們還可以在類的例項化的時候為類的建構函式傳遞引數。為了演示,我們再把DefaultDiscountHelper類改一下:

public class DefaultDiscountHelper : IDiscountHelper { 
    private decimal discountRate; 
 
    public DefaultDiscountHelper(decimal discountParam) { 
        discountRate = discountParam; 
    } 
 
    public decimal ApplyDiscount(decimal totalParam) { 
        return (totalParam - (discountRate/ 10m * totalParam)); 
    } 
}

顯然,DefaultDiscountHelper類在例項化的時候必須給建構函式傳遞一個引數,不然程式會出錯。和給屬性賦值類似,只是用的方法是WithConstructorArgument(<引數名>,<引數值>),繫結方式如下程式碼所示:

...
ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 
ninjectKernel.Bind<IDiscountHelper>() 
    .To< DefaultDiscountHelper>().WithConstructorArgument("discountParam", 5M);
...

同樣,只需要更改一行程式碼,其他程式碼原來怎麼寫還是怎麼寫。如果建構函式有多個引數,則需在Bind和To方法後面加上多個WithConstructorArgument即可。

3.自我繫結

Niject的一個非常好用的特性就是自繫結。當通過Bind和To方法繫結好介面和類後,可以直接通過ninjectKernel.Get<類名>()來獲得一個類的例項。

在前面的幾個示例中,我們都是像下面這樣來建立ShoppingCart類例項的:

...
IValueCalculator calcImpl = ninjectKernel.Get<IValueCalculator>();
ShoppingCart cart = new ShoppingCart(calcImpl);
...

其實有一種更簡單的定法,如下:

... 
ShoppingCart cart = ninjectKernel.Get<ShoppingCart>(); 
... 

這種寫法不需要關心ShoppingCart類依賴哪個介面,也不需要手動去獲取該介面的實現(calcImpl)。當通過這句程式碼請求一個ShoppingCart類的例項的時候,Ninject會自動判斷依賴關係,併為我們建立所需介面對應的實現。這種方式看起來有點怪,其實中規中矩的寫法是:

...
ninjectKernel.Bind<ShoppingCart>().ToSelf();
ShoppingCart cart = ninjectKernel.Get<ShoppingCart>();
...

這裡有自我繫結用的是ToSelf方法,在本示例中可以省略該句。但用ToSelf方法自我繫結的好處是可以在其後面用WithXXX方法指定建構函式引數、屬性等等的值。

4.派生類繫結

通過一般繫結,當請求一個介面的實現時,Ninject會幫我們自動建立實現介面的類的例項。我們說某某類實現某某介面,也可以說某某類繼承某某介面。如果我們把介面當作一個父類,是不是也可以把父類繫結到一個繼承自該父類的子類呢?我們來實驗一把。先改造一下ShoppingCart類,給它的CalculateStockValue方法改成虛方法:

public class ShoppingCart {
    protected IValueCalculator calculator;
    protected Product[] products;

    //建構函式,引數為實現了IEmailSender介面的類的例項
    public ShoppingCart(IValueCalculator calcParam) {
        calculator = calcParam;
        products = new[]{ 
            new Product {Name = "西瓜", Category = "水果", Price = 2.3M}, 
            new Product {Name = "蘋果", Category = "水果", Price = 4.9M}, 
            new Product {Name = "空心菜", Category = "蔬菜", Price = 2.2M}, 
            new Product {Name = "地瓜", Category = "蔬菜", Price = 1.9M} 
        };
    }

    //計算購物車內商品總價錢
    public virtual decimal CalculateStockValue() {
        //計算商品總價錢 
        decimal totalValue = calculator.ValueProducts(products);
        return totalValue;
    }
}

再新增一個ShoppingCart類的子類:

public class LimitShoppingCart : ShoppingCart {
    public LimitShoppingCart(IValueCalculator calcParam)
        : base(calcParam) {
    }

    public override decimal CalculateStockValue() {
        //過濾價格超過了上限的商品
        var filteredProducts = products.Where(e => e.Price < ItemLimit);
return calculator.ValueProducts(filteredProducts.ToArray()); } public decimal ItemLimit { get; set; } }

然後把父類ShoppingCart繫結到子類LimitShoppingCart:

public static void Main() {
    IKernel ninjectKernel = new StandardKernel();

    ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>();
    ninjectKernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>()
        .WithPropertyValue("DiscountSize", 5M);
    //派生類繫結
    ninjectKernel.Bind<ShoppingCart>().To<LimitShoppingCart>()
        .WithPropertyValue("ItemLimit", 3M);

    ShoppingCart cart = ninjectKernel.Get<ShoppingCart>();
    Console.WriteLine("Total: {0:c}", cart.CalculateStockValue());
    Console.ReadKey();
}

執行結果:

從執行結果可以看出,cart物件呼叫的是子類的CalculateStockValue方法,證明了可以把父類繫結到一個繼承自該父類的子類。通過派生類繫結,當我們請求父類的時候,Ninject自動幫我們建立一個對應的子類的例項,並將其返回。由於抽象類不能被例項化,所以派生類繫結在使用抽象類的時候非常有用。

5.條件繫結

當一個介面有多個實現或一個類有多個子類的時候,我們可以通過條件繫結來指定使用哪一個實現或子類。為了演示,我們給IValueCalculator介面再新增一個實現,如下:

public class IterativeValueCalculator : IValueCalculator { 
 
    public decimal ValueProducts(params Product[] products) { 
        decimal totalValue = 0; 
        foreach (Product p in products) { 
            totalValue += p.Price; 
        } 
        return totalValue; 
    } 
}

IValueCalculator介面現在有兩個實現:IterativeValueCalculator和LinqValueCalculator。我們可以指定,如果是把該介面的實現注入到LimitShoppingCart類,那麼就用IterativeValueCalculator,其他情況都用LinqValueCalculator。如下所示:

public static void Main() {
    IKernel ninjectKernel = new StandardKernel();

    ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>();
    ninjectKernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>()
        .WithPropertyValue("DiscountSize", 5M);
    //派生類繫結
    ninjectKernel.Bind<ShoppingCart>().To<LimitShoppingCart>()
        .WithPropertyValue("ItemLimit", 3M);
    //條件繫結
    ninjectKernel.Bind<IValueCalculator>()
        .To<IterativeValueCalculator>().WhenInjectedInto<LimitShoppingCart>();

    ShoppingCart cart = ninjectKernel.Get<ShoppingCart>();
    Console.WriteLine("Total: {0:c}", cart.CalculateStockValue());
    Console.ReadKey();
}

執行結果:

執行結果是6.4,說明沒有打折,即呼叫的是計算方法是IterativeValueCalculator的ValueProducts方法。可見,Ninject會查詢最匹配的繫結,如果沒有找到條件繫結,則使用預設繫結。在條件繫結中,除了WhenInjectedInto方法,還有When和WhenClassHas等方法,朋友們可以在使用的時候再慢慢研究。

在ASP.NET MVC中使用Ninject

本文用控制檯應用程式演示了Ninject的使用,但要把Ninject整合到ASP.NET MVC中還是有點複雜的。首先要做的事就是建立一個繼承System.Web.Mvc.DefaultControllerFactory的類,MVC預設使用這個類來建立Controller類的例項(後續博文會專門講這個)。程式碼如下:

using System;
using Ninject;
using System.Web.Mvc;
using System.Web.Routing;

namespace MvcApplication1 {
    public class NinjectControllerFactory : DefaultControllerFactory {
        private IKernel ninjectKernel;
        public NinjectControllerFactory() {
            ninjectKernel = new StandardKernel();
            AddBindings();
        }
        protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType) {
            return controllerType == null ? null : (IController)ninjectKernel.Get(controllerType);
        }
        private void AddBindings() {
            // 在這新增繫結,
            // 如:ninjectKernel.Bind<IProductRepository>().To<FakeProductRepository>();
        }
    }
}
NinjectControllerFactory

現在暫時不解釋這段程式碼,大家都看懂就看,看不懂就過,只要知道在ASP.NET MVC中使用Ninject要做這麼一件事就行。

新增完這個類後,還要做一件事,就是在MVC框架中註冊這個類。一般我們在Global.asax檔案中的Application_Start方法中進行註冊,如下所示:

protected void Application_Start() {
    AreaRegistration.RegisterAllAreas();

    WebApiConfig.Register(GlobalConfiguration.Configuration);
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);

    ControllerBuilder.Current.SetControllerFactory(new NinjectControllerFactory());
}

註冊後,MVC框架就會用NinjectControllerFactory類去獲取Cotroller類的例項。在後續博文中會具體演示如何在ASP.NET MVC中使用Ninject,這裡就不具體演示了,大家知道需要做這麼兩件事就行。

雖然我們前面花了很大功夫來學習Ninject就是為了在MVC中使用這樣一個NinjectControllerFactory類,但是瞭解Ninject如何工作是非常有必要的。理解好了一種DI容器,可以使得開發和測試更簡單、更高效。

 

參考:

《Pro ASP.NET MVC 3 Framework》

相關文章