.NET深入学习- - 控制反转(依赖注入)

1 IoC理论的背景
    我们都知道,在采用面向对象方法设计的软件系统中,它的底层实现都是由N个对象组成的,所有的对象通过彼此的合作,最终实现系统的业务逻辑。

图1:软件系统中耦合的对象
    图1齿轮组中齿轮之间的啮合关系,与软件系统中对象之间的耦合关系非常相似。对象之间的耦合关系是无法避免的,也是必要的,这是协同工作的基础。现在,伴随着工业级应用的规模越来越庞大,对象之间的依赖关系也越来越复杂,经常会出现对象之间的多重依赖性关系,因此,架构师和设计师对于系统的分析和设计,将面临更大的挑战。对象之间耦合度过高的系统,必然会出现牵一发而动全身的情形。

图2:对象之间复杂的依赖关系


    耦合关系不仅会出现在对象与对象之间,也会出现在软件系统的各模块之间,以及软件系统和硬件系统之间。如何降低系统之间、模块之间和对象之间的耦合度,是软件工程永远追求的目标之一。为了解决对象之间的耦合度过高的问题,有人提出了IOC理论,用来实现对象之间的“解耦”,目前这个理论已经被成功地应用到实践当中,很多的J2EE项目均采用了IOC框架产品spring


2 什么是控制反转(IoC) 
    IOC是Inversion of Control的缩写,多数书籍翻译成“控制反转”,还有些书籍翻译成为“控制反向”或者“控制倒置”。 
    1996年,Michael Mattson在一篇有关探讨面向对象框架的文章中,首先提出了IOC 这个概念。对于面向对象设计及编程的基本思想,简单来说就是把复杂系统分解成相互合作的对象,这些对象类通过封装以后,内部实现对外部是透明的,从而降低了解决问题的复杂度,而且可以灵活地被重用和扩展。IOC理论提出的观点大体是这样的:借助于“第三方”实现具有依赖关系的对象之间的解耦,如下图:

 图3:IOC解耦过程


    大家看,由于引进了中间位置的“第三方”,也就是IOC容器,使得A、B、C、D这4个对象没有了耦合关系,齿轮之间的传动全部依靠“第三方”了,全部对象的控制权全部上缴给“第三方”IOC容器,所以,IOC容器成了整个系统的关键核心,它起到了一种类似“粘合剂”的作用,把系统中的所有对象粘合在一起发挥作用,如果没有这个“粘合剂”,对象与对象之间会彼此失去联系,这就是有人把IOC容器比喻成“粘合剂”的由来。
我们再来做个试验:把上图中间的IOC容器拿掉,然后再来看看这套系统:
 

图4:拿掉IoC容器后的系统


    我们现在看到的画面,就是我们要实现整个系统所需要完成的全部内容。这时候,A、B、C、D这4个对象之间已经没有了耦合关系,彼此毫无联系,这样的话,当你在实现A的时候,根本无须再去考虑B、C和D了,对象之间的依赖关系已经降低到了最低程度。所以,如果真能实现IOC容器,对于系统开发而言,参与开发的每一成员只要实现自己的类就可以了,跟别人没有任何关系!
    那么为什么叫控制反转(IOC)呢?
    软件系统在没有引入IOC容器之前,如图1所示,对象A依赖于对象B,那么对象A在初始化或者运行到某一点的时候,自己必须主动去创建对象B或者使用已经创建的对象B。无论是创建还是使用对象B,控制权都在自己手上。
    软件系统在引入IOC容器之后,这种情形就完全改变了,如图3所示,由于IOC容器的加入,对象A与对象B之间失去了直接联系,所以,当对象A运行到需要对象B的时候,IOC容器会主动创建一个对象B注入到对象A需要的地方。
    通过前后的对比,我们不难看出来:对象A获得依赖对象B的过程,由主动行为变为了被动行为,控制权颠倒过来了,这就是“控制反转”这个名称的由来。

3 IOC的别名:依赖注入(DI) 


   2004年,Martin Fowler探讨了同一个问题,既然IOC是控制反转,那么到底是“哪些方面的控制被反转了呢?”,经过详细地分析和论证后,他得出了答案:“获得依赖对象的过程被反转了”。控制被反转之后,获得依赖对象的过程由自身管理变为了由IOC容器主动注入。于是,他给“控制反转”取了一个更合适的名字叫做“依赖注入(Dependency Injection)”。他的这个答案,实际上给出了实现IOC的方法:注入。所谓依赖注入,就是由IOC容器在运行期间,动态地将某种依赖关系注入到对象之中。

所以,依赖注入(DI)和控制反转(IOC)是从不同的角度的描述的同一件事情,就是指通过引入IOC容器,利用依赖关系注入的方式,实现对象之间的解耦。

4. 简单例子

1.如果在 Class A 中,有 Class B 的实例,则称 Class A 对 Class B 有一个依赖。例如下面类 Human 中用到一个 Father 对象,我们就说类 Human 对类 Father 有一个依赖。

public class Human {
    ...
    Father father;
    ...
    public Human() {
        father = new Father();
    }
}

仔细看这段代码我们会发现存在一些问题:
(1). 如果现在要改变 father 生成方式,如需要用new Father(String name)初始化 father,需要修改 Human 代码;
(2). 如果想测试不同 Father 对象对 Human 的影响很困难,因为 father 的初始化被写死在了 Human 的构造函数中;
(3). 如果new Father()过程非常缓慢,单测时我们希望用已经初始化好的 father 对象 Mock 掉这个过程也很困难。

2. 依赖注入 上面将依赖在构造函数中直接初始化是一种 Hard init 方式,弊端在于两个类不够独立,不方便测试。我们还有另外一种 Init 方式,如下:

public class Human {
    ...
    Father father;
    ...
    public Human(Father father) {
        this.father = father;
    }
}

上面代码中,我们将 father 对象作为构造函数的一个参数传入。在调用 Human 的构造方法之前外部就已经初始化好了 Father 对象。像这种非自己主动初始化依赖,而通过外部来传入依赖的方式,我们就称为依赖注入。
现在我们发现上面 1 中存在的两个问题都很好解决了,简单的说依赖注入主要有两个好处:
(1). 解耦,将依赖之间解耦。
(2). 因为已经解耦,所以方便做单元测试,尤其是 Mock 测试。

5  使用IOC框架应该注意什么
    使用IOC框架产品能够给我们的开发过程带来很大的好处,但是也要充分认识引入IOC框架的缺点,做到心中有数,杜绝滥用框架。
    第一、软件系统中由于引入了第三方IOC容器,生成对象的步骤变得有些复杂,本来是两者之间的事情,又凭空多出一道手续,所以,我们在刚开始使用IOC框架的时候,会感觉系统变得不太直观。所以,引入了一个全新的框架,就会增加团队成员学习和认识的培训成本,并且在以后的运行维护中,还得让新加入者具备同样的知识体系。
    第二、由于IOC容器生成对象是通过反射方式,在运行效率上有一定的损耗。如果你要追求运行效率的话,就必须对此进行权衡。
    第三、具体到IOC框架产品(比如:Spring)来讲,需要进行大量的配制工作,比较繁琐,对于一些小的项目而言,客观上也可能加大一些工作成本。
    第四、IOC框架产品本身的成熟度需要进行评估,如果引入一个不成熟的IOC框架产品,那么会影响到整个项目,所以这也是一个隐性的风险。
    我们大体可以得出这样的结论:一些工作量不大的项目或者产品,不太适合使用IOC框架产品。另外,如果团队成员的知识能力欠缺,对于IOC框架产品缺乏深入的理解,也不要贸然引入。最后,特别强调运行效率的项目或者产品,也不太适合引入IOC框架产品,象WEB2.0网站就是这种情况。

6.来看个更详细的例子(篇幅较长,以.NET为例,涉及到DDD(领域开发模式))

相对来说质量比较好的代码,复用性比较强,耦合度比较低,我们更希望写出松耦合的代码,但仔细观察会发现,所谓松耦合,写着写着就成了紧耦合

1 不好的实现


编写松耦合代码的第一步,可能大家都熟悉,那就是对系统分层。比如下面的经典的三层架构。

Classic 3-tier architecture

分完层和实现好是两件事情,并不是说分好层之后就能够松耦合了。

1.1 紧耦合的代码

有很多种方式来设计一个灵活的,可维护的复杂应用,但是n层架构是一种大家比较熟悉的方式,这里面的挑战在于如何正确的实现n层架构。

假设要实现一个很简单的电子商务网站,要列出商品列表,如下:

product list page

下面就具体来演示通常的做法,是如何一步一步把代码写出紧耦合的。

1.1.1 数据访问层

要实现商品列表这一功能,首先要编写数据访问层,需要设计数据库及表,在SQLServer中设计的数据库表Product结构如下:

Product Table

表设计好之后,就可以开始写代码了。在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 分析

现在,按照三层“架构”我们的代码写好了,并且也达到了要求。整个项目的结构如下图:

 Solution layout

这应该是我们通常经常写的所谓的三层架构。在Visual Studio中,三层之间的依赖可以通过项目引用表现出来。

1.2.1 依赖关系图

现在我们来分析一下,这三层之间的依赖关系,很明显,上面的实现中,DomianLogic需要依赖SqlDataAccess,因为DomainLogic中用到了Product这一实体,而这个实体是定义在DataAccess这一层的。WebUI这一层需要依赖DomainLogic,因为ProductService在这一层,同时,还需要依赖DataAccess,因为在UI中也用到了Product实体,现在整个系统的依赖关系是这样的:

Dependency graph in three-tier architecture

1.2.2 耦合性分析

使用三层结构的主要目的是分离关注点,当然还有一个原因是可测试性。我们应该将领域模型从数据访问层和表现层中分离出来,这样这两个层的变化才不会污染领域模型。在大的系统中,这点很重要,这样才能将系统中的不同部分隔离开来。

现在来看之前的实现中,有没有模块性,有没有那个模块可以隔离出来呢。现在添加几个新的case来看,系统是否能够响应这些需求:

添加新的用户界面

除了WebForm用户之外,可能还需要一个WinForm的界面,现在我们能否复用领域层和数据访问层呢?从依赖图中可以看到,没有任何一个模块会依赖表现层,因此很容易实现这一点变化。我们只需要创建一个WPF的富客户端就可以。现在整个系统的依赖图如下:

WPF client

更换新的数据源

可能过了一段时间,需要把整个系统部署到云上,要使用其他的数据存储技术,比如Azure Table Storage Service。现在,整个访问数据的协议发生了变化,访问Azure Table Storage Service的方式是Http协议,而之前的大多数.NET 访问数据的方式都是基于ADO.NET 的方式。并且数据源的保存方式也发生了改变,之前是关系型数据库,现在变成了key-value型数据库。

Azure datatable

由上面的依赖关系图可以看出,所有的层都依赖了数据访问层,如果修改数据访问层,则领域逻辑层,和表现层都需要进行相应的修改。

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<FeaturedProductsViewModel> 即可,当我们在从Controller创建View的时候,可以进行选择,然后会自动生成。整个用于展示的信息放在了SummaryText字段中。

这里就引入了一个视图模型(View-Specific Models),他封装了视图的行为,这些模型只是简单的POCOs对象(Plain Old CLR Objects)。FeatureProductsViewModel中包含了一个List列表,每个元素是一个ProductViewModel类,其中定义了一些简单的用于数据展示的字段。

FeatureProductsViewModel

现在在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类中只定义了商品的基本信息比如名称和单价。整个关系图如下:

Domain model

现在来看表现层,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对象。

two product class in the system

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 依赖关系图

现在,整个系统的依赖关系图如下:

Dependency graph in DDD

表现层和数据访问层都依赖领域模型层,这样,在前面的case中,如果我们新添加一个UI界面;更换一种数据源的存储和获取方式,只需要修改对应层的代码即可,领域模型层保持了稳定。

2.2.2 时序图

整个系统的时序图如下:

Sequence Diagram

系统启动的时候,在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)层。如下图:

presentation model layer

将Controllers和ViewModel从表现层移到了表现模型层,仅仅将视图(.aspx和.ascx文件)和聚合根对象(Composition Root)保留在了表现层中。之所以这样处理,是可以使得尽可能的使得表现层能够可配置而其他部分尽可能的可以保持不变。

发布了45 篇原创文章 · 获赞 4 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_36730649/article/details/104675382