依赖注入(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类:

public class ShoppingCart {
    //计算购物车内商品总价钱
    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}
        };
        IValueCalculator calculator = new LinqValueCalculator();

        //计算商品总价钱
        decimal totalValue = calculator.ValueProducts(products);

        return totalValue;
    }
}

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中的方法中编写用于计算购物车中商品折后总价钱的代码,如下所示:

class Program {
    static void Main(string[] args) {
        IKernel ninjectKernel = new StandardKernel();

        ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>();
        ninjectKernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>();

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

输出结果:

代码一目了然,虽然新添加了一个接口和一个类,但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》