依赖注入

依赖注入

1 概述

1.1 背景

在我们日常生活中,有一个十分形象的例子来解释耦合,那就是时钟。当我们拆开时钟的后盖可以看见三个大小不一的齿轮,它们分别带动着时针、分针和秒针的旋转。但时钟的传动装置只有一个,所以这些齿轮之间相互啮合,一个带动另一个旋转,以此来共同完成一个任务。对于这样一组齿轮传动装置,如果其中一个齿轮出了问题,那么整个装置的运行都会受到影响。齿轮组中齿轮之间的啮合关系,与软件系统中对象之间的耦合关系非常相似,所以如果在软件工程中采用这样的方式来运行的话,缺点是十分明显的。

在软件工程中,对象之间的耦合关系是无法避免的,因为只有不同组件之间协同工作才能更好的实现一些功能。但随着应用规模越来越大,这种对象之间的依赖关系也会随之越来越复杂,这就会使得整个软件的内部构造变得乱如细麻,一个组件的问题会传递至其他组件,问题的规模也会随之变得,想处理这些问题也变得十分的棘手。

对象之间耦合度过高的系统,必然会出现牵一发而动全身的情形。

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

image-20201029182105113

图1

1.2 耦合

1.2.1 紧耦合

通常情况下如果我们需要在一个对象A中使用另一个对象B,我们需要在A代码编写时就完整的引用、创建相应的对象B,因此对象A就得负责对象B的整个生命周期。并且对象A的部分代码会依赖于创建的对象B,以此类推,当对象逐渐增多且都互相依赖时,尽管每个代码层级负责不同的任务,但是每个层级还是干了一些不属于它职责范围的操作,这就导致了紧耦合。

1.2.2 紧耦合的影响

想象一下我们建立一个这样的四层模型:

  • 视图层,应用的界面。它由用户界面,控件比如按钮,列表等组成。
  • 表现层,处理 UI 的逻辑部分。它包含了按钮事件调用的函数,UI 界面列表绑定的存储数据的对象。
  • 数据访问层,负责与数据仓库的交互代码。数据访问层知道如何发起一个 Web 服务调用,然后将数据存储到对象中,以便应用的其他模块可以方便的使用。
  • 数据仓库,获取实际数据的地方。

且四层之间都具有耦合关系。

如果表现层涉及数据上传,当我们上传不同的数据格式时,需要不同的数据访问层逻辑,即数据访问层对应的数据库应有所不同。这时,我们就需要在表现层实例化不同的数据访问层模块,并通过switch函数来对不同的数据进行不同的数据访问层处理。这样看起来没什么大问题。如果表现层这时需要添加一个缓冲数据的功能(在网速受限时,提高用户的使用体验),你可能又需要在表现层针对缓冲与否,创建不同的数据访问层对象。这时我们的模块涉及就违反了单一职责原则,因为我们的表现层不仅处理了UI的逻辑,还兼顾了不同数据格式数据的上传和决定数据是否使用缓冲。

这样的代码难以维护,且运行效率低下,逻辑紊乱。这时候就需要采用解耦的思想来改善代码。

1.2.3 解耦

解耦的步骤:

  • 添加一个接口,一个抽象层,增加代码灵活性
  • 在应用程序代码中加入构造函数注入
  • 将解耦的各个模块组合到一起

解耦的好处:

  • 解耦合的代码更加易于扩展。我们能够在不改变大量对象的情况下增加功能。
  • 我们能够将功能独立开来,以便编写简短的,易于阅读的单元测试。
  • 我们也获得了易于维护的代码。当程序出错的时候,我们能够更加容易发现我们需要修改哪部分内容。
  • 我们在团队协作开发的过程中,比如提交合并代码,通常不希望也应该避免团队成员之间的代码存在冲突,而解耦合有利于团队成员各自维护自己的代码片段而互相不受影响。
  • 解耦合可以使延迟绑定变得更加容易。延迟绑定,或者运行时绑定,是我们在运行时做决定而不是编译时,这在特定场合下很有用。

1.3 控制反转(IoC)

1.3.1 依赖注入

Dependency injection(DI)是一个将行为从依赖中分离的技术,简单地说,它允许开发者定义一个方法函数依赖于外部其他各种交互,而不需要编码如何获得这些外部交互的实例。 这样就在各种组件之间解耦,从而获得干净的代码,相比依赖的硬编码, 一个组件只有在运行时才调用其所需要的其他组件,因此在代码运行时,通过特定的框架或容器,将其所需要的其他依赖组件进行注入,主动推入。

依赖注入可以看成是 反转控制 (inversion of control)的一个特例。反转的是依赖,而不是其他。

依赖注入与IoC模式类似工厂模式,是一种解决调用者和被调用者依赖耦合关系的模式它解决了对象之间的依赖关系,使得对象只依赖IoC/DI容器,不再直接相互依赖,实现松耦合,然后在对象创建时,由IoC/DI容器将其依赖的对象注入需要调用的模块内,最大程度实现松耦合。

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


图2

软件系统在没有引入IoC容器之前,如果对象A依赖于对象B,那么对象A在初始化或者运行到某一点的时候,自己必须主动去创建对象B或者使用已经创建的对象B。无论是创建还是使用对象B,控制权都在自己手上,且得负责B的生命周期。
但软件系统在引入IOC容器之后,这种情形就完全改变了,如图2所示,由于IoC容器的加入,对象A与对象B之间失去了直接联系,所以,当对象A运行到需要对象B的时候,IoC容器会主动创建一个对象B注入到对象A需要的地方。
通过前后的对比,我们不难看出来:对象A获得依赖对象B的过程,由主动行为变为了被动行为,控制权颠倒过来了,这就是“控制反转”这个名称的由来。

1.3.2 IoC的好处

外部存储通过USB接口与主机相连的例子就很像IoC,当我们需要使用外设进行存储时,我们只需要将U盘插到USB的接口上就行了,主机需要用通过这个接口对U盘进行读写就行。这样的好处是:

  1. U盘和主机之间只有在相连时才产生关联,如果双方出现故障都不会对对方产生影响。这种特性体现在软件工程中,就是可维护性比较好,非常便于进行单元测试,便于调试程序和诊断故障。代码中的每一个Class都可以单独测试,彼此之间互不影响,只要保证自身的功能无误即可,这就是组件之间低耦合或者无耦合带来的好处。
  2. U盘厂商不需要根据不同电脑型号生产不同的U盘,两者只需要遵守统一的USB接口标准。同类似的方式,在软件开发过程中,每个开发团队的成员都只需要关心实现自身的业务逻辑,完全不用去关心其它的人工作进展,通过统一的接口规范就可以实现组件的组合,大大加快了开发的速度,也提高了产品的高复用性。
  3. 同USB外部设备一样,模块具有热插拔特性。IOC生成对象的方式转为外置方式,也就是把对象生成放在配置文件里进行定义,这样,当我们更换一个实现子类将会变得很简单,只要修改配置文件就可以了,完全具有热插拨的特性。

总体而言,控制反转的出现是为了降低软件开发工程中不同模块之间的耦合程度。

2 实例

2.1 没有使用IoC

下面是一段没有使用IoC实现模块之间相互调用的代码。

/************************************************
* 这是一个书店管理系统
* BookService是管理员使用模块
* BookService需要读取数据库数据,所以必须实例化DataSource对象
************************************************/
public class BookService {
    private DataSource dataSource = new DataSource(config);

    public Book getBook(long bookId) {
        try (Connection conn = dataSource.getConnection()) {
            ...
            return book;
        }
    }
}
/************************************************
* UserService是用户系统模块
* 如果UserService现在也要访问数据库,就必须实例化DataSource对象
************************************************/
public class UserService {
    private DataSource dataSource = new DataSource(config);

    public User getUser(long userId) {
        try (Connection conn = dataSource.getConnection()) {
            ...
            return user;
        }
    }
}
/************************************************
* CartServlet处理用户购买
* CartServlet继承HttpServlet 
* 该类覆写了deGet()方法
* 并传入了HttpServletRequest和HttpServletResponse两个对象,分别代表HTTP请求和响应。
************************************************/
public class CartServlet extends HttpServlet {
    private BookService bookService = new BookService();
    private UserService userService = new UserService();

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        long currentUserId = getFromCookie(req);
        User currentUser = userService.getUser(currentUserId);
        Book book = bookService.getBook(req.getParameter("bookId"));
        //do something else
        ...
    }
}

CartServlet创建了BookService,在创建BookService的过程中,又创建了DataSource组件。这种模式的缺点是,一个组件如果要使用另一个组件,必须先知道如何正确地创建它。

从上面的例子可以看出,如果一个系统有大量的组件,其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。

2.2 使用IoC

下面是一段使用IoC实现模块之间相互调用的代码。

spring不仅实现了通过容器创建组件,还实现了配置文件内的组件之间的互相调用。

<!--
	这是一个spring容器的配置文件
	文件中指明了3个bean,对应于3个java class
-->
<beans>
    <bean id="dataSource" class="DataSource" />
    <bean id="bookService" class="BookService">
        <property name="dataSource" ref="dataSource" />
    </bean>
    <bean id="userService" class="UserService">
        <property name="dataSource" ref="dataSource" />
    </bean>
</beans>

在IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,例如,BookService自己并不会创建DataSource,而是等待外部通过setDataSource()方法来注入一个DataSource:

public class BookService {
    private DataSource dataSource;

    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }
}

3 总结

在软件工程开发的工程中使用依赖注入的手段可以实现不同单元模块之间的独立开发和测试,大大提高了开发的效率。同时依赖注入通过容器创建、控制组件,实现了组件之间的松耦合,大大降低了软件的维护成本。