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

本文目錄:

爲什麼需要依賴注入

在[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;
    
    //構造函數,參數爲實現了IEmailSender接口的類的實例
    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接口的類(示例中的MyEmailSender)的實例引用作爲參數,傳遞給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>();
        }
    }
}

現在暫時不解釋這段代碼,大家都看懂就看,看不懂就過,只要知道在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》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章