浅谈依赖注入

最近几天在看一本名为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 不好的实现

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

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)保留在了表现层中。之所以这样处理,是可以使得尽可能的使得表现层能够可配置而其他部分尽可能的可以保持不变。

3. 结语

一不小心我们就编写出了紧耦合的代码,有时候以为分层了就可以解决这一问题,但是大多数的时候,都没有正确的实现分层。之所以容易写出紧耦合的代码有一个原因是因为编程语言或者开发环境允许我们只要需要一个新的实例对象,就可以使用new关键字来实例化一个。如果我们需要添加依赖,Visual Studio有些时候可以自动帮我们添加引用。这使得我们很容易就犯错,使用new关键字,就可能会引入以来;添加引用就会产生依赖。

减少new引入的依赖及紧耦合最好的方式是使用构造函数注入依赖这种设计模式:即如果我们需要一个依赖的实例,通过构造函数注入。在第二个部分的实现演示了如何针对抽象而不是具体编程。

构造函数注入是反转控制的一个例子,因为我们反转了对依赖的控制。不是使用new关键字创建一个实例,而是将这种行为委托给了第三方实现。

希望本文能够给大家了解如何真正实现三层架构,编写松散耦合,可维护,可测试性的代码提供一些帮助。

Leave a Reply