Spring中的依赖注入
依赖注入(Dependency Injection,DI)这个词让人望而生畏,现在已经演变成一项复杂的编程技巧或设计模式理念。但事实证明,依赖注入并不像它听上去那么复杂。在项目中应用 DI,你会发现你的代码会变得异常简单并且更容易理解和测试。
在开始前先用iDEA创建一个项目,依赖管理使用Maven,选择quickstart即可

DI功能是如何实现的
任何一个有实际意义的应用都会由两个或者更多的类组成,这些类相互之间进行协作来完成特定的业务逻辑。按照传统的做法,每个对象负责管理与自己相互协作的对象(即它所依赖的对象)的引用,这将会导致高度耦合和难以测试的代码。
假设现在有两个接口,Kinght和Quest,分别代表骑士和探险任务。
/**
* 这是一个骑士接口,实现它成为骑士吧
*/
public interface Knight {
/**
* 开始探险
*/
void embarkOnQuest();
}
/**
* 这是个探险接口,实现它成为真正的探险吧!
*/
public interface Quest {
/**
* 开始从事...
*/
void embark();
}
一个拯救少女的探险RescueDamselQuest
/**
* 拯救美丽少女的探险
*/
public class RescueDamselQuest implements Quest {
public void embark() {
System.out.println("开始了营救美丽少女的任务!");
}
}
一个拯救少女的骑士DamselRescuingKnight
/**
* 这是一个拯救少女的骑士
*/
public class DamselRescuingKnight implements Knight {
// 定义一个拯救少女的任务
private RescueDamselQuest quest;
public DamselRescuingKnight() {
this.quest = new RescueDamselQuest();
}
@Override
public void embarkOnQuest() {
quest.embark();
}
}
可以看到,DamselRescuingKnight 在它的构造函数中自行创建了 Rescue DamselQuest。这使得 DamselRescuingKnight 紧密地和 RescueDamselQuest 耦合到了一起,因此极大地限制了这个骑士执行探险的能力。如果一个少女需要救援,这个骑士能够召之即来。但是如果一条恶龙需要杀掉,或者一个圆桌……额……需要滚起来,那么这个骑士就爱莫能助了。
耦合具有两面性(two-headed beast)。一方面,紧密耦合的代码难以测试、难以复用、难以理解,并且典型地表现出 “打地鼠” 式的 bug 特性(修复一个 bug,将会出现一个或者更多新的 bug)。另一方面,一定程度的耦合又是必须的 —— 完全没有耦合的代码什么也做不了。为了完成有实际意义的功能,不同的类必须以适当的方式进行交互。总而言之,耦合是必须的,但应当被小心谨慎地管理。
通过 DI,对象的依赖关系将由系统中负责协调各对象的第三方组件在创建对象的时候进行设定。对象无需自行创建或管理它们的依赖关系,如图 1.1 所示,依赖关系将被自动注入到需要它们的对象当中去。

为了展示这一点,让我们看一看以下的 BraveKnight,这个骑士不仅勇敢,而且能挑战任何形式的探险:
/**
* 这是一个勇敢的骑士
*/
public class BraveKnight implements Knight {
// 定义一个探险任务
private Quest quest;
// 接受一个探险任务
public BraveKnight(Quest quest) {
this.quest = quest;
}
@Override
public void embarkOnQuest() {
quest.embark();
}
}
我们可以看到,不同于之前的 DamselRescuingKnight,BraveKnight 没有自行创建探险任务,而是在构造的时候把探险任务作为构造器参数传入。这是依赖注入的方式之一,即构造器注入(constructor injection)。
更重要的是,传入的探险类型是 Quest,也就是所有探险任务都必须实现的一个接口。所以,BraveKnight 能够响应 RescueDamselQuest、SlayDragonQuest、MakeRoundTableRounderQuest(让圆桌滚起来) 等任意的 Quest 实现。
这里的要点是 BraveKnight 没有与任何特定的 Quest 实现发生耦合。对它来说,被要求挑战的探险任务只要实现了 Quest 接口,那么具体是哪种类型的探险就无关紧要了。这就是 DI 所带来的最大收益 —— 松耦合。如果一个对象只通过接口(而不是具体实现或初始化过程)来表明依赖关系,那么这种依赖就能够在对象本身毫不知情的情况下,用不同的具体实现进行替换。
对依赖进行替换的一个最常用方法就是在测试的时候使用 mock 实现。我们无法充分地测试 DamselRescuingKnight,因为它是紧耦合的;但是可以轻松地测试 BraveKnight,只需给它一个 Quest 的 mock 实现即可。
首先在pom.xml添加mock依赖,然后创建测试类BraveKnightTest
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>4.4.0</version>
</dependency>
import static org.mockito.Mockito.*;
import org.junit.Test;
public class BraveKnightTest {
@Test
public void knightShouldEmbarkOnQuest() {
Quest mockQuest = mock(Quest.class);
BraveKnight knight = new BraveKnight(mockQuest);
knight.embarkOnQuest();
verify(mockQuest, times(1)).embark();
}
}
你可以使用 mock 框架 Mockito 去创建一个 Quest 接口的 mock 实现。通过这个 mock 对象,就可以创建一个新的 BraveKnight 实例,并通过构造器注入这个 mock Quest。当调用 embarkOnQuest() 方法时, 你可以要求 Mockito框架验证 Quest 的 mock 实现的 embark() 方法仅仅被调用了一次。
将 Quest 注入到 Knight 中
让我们欢迎Spring登场吧,在pom.xml中添加Spring框架依赖
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.7</maven.compiler.source>
<maven.compiler.target>1.7</maven.compiler.target>
<spring.version>5.3.18</spring.version> <!-- 统一定义版本 -->
</properties>
...
...
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
</dependency>
现在 BraveKnight 类可以接受你传递给它的任意一种 Quest 的实现,但该怎样把特定的 Query 实现传给它呢?假设,希望 BraveKnight 所要进行探险任务是杀死一只怪龙,SlayDragonQuest 也许是挺合适的。
import java.io.PrintStream;
/**
* 屠龙探险
*/
public class SlayDragonQuest implements Quest{
private PrintStream stream;
public SlayDragonQuest(PrintStream stream) {
this.stream = stream;
}
@Override
public void embark() {
stream.println("踏上弑龙之旅!");
}
}
我们可以看到,SlayDragonQuest 实现了 Quest 接口,这样它就适合注入到 BraveKnight 中去了。与其他的Java入门样例有所不同,SlayDragonQuest没有使用 System.out.println(),而是在构造方法中请求一个更为通用的 PrintStream。这里最大的问题在于,我们该如何将 SlayDragonQuest 交给 BraveKnight 呢?又如何将 PrintStream 交给 SlayDragonQuest 呢?
创建应用组件之间协作的行为通常称为装配(wiring)。Spring 有多种装配 bean 的方式,采用 XML 是很常见的一种装配方式。以下是一个简单的 Spring配置文件:knights.xml,该配置文件将 BraveKnight、SlayDragonQuest 和 PrintStream 装配到了 一起。
在项目的src\main目录下创建resource目录,然后resource下创建META-INF\spring目录,最后在spring下创建knights.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="knight" class="org.spring.BraveKnight">
<constructor-arg ref="quest" />
</bean>
<bean id="quest" class="org.spring.SlayDragonQuest">
<constructor-arg value="#{T(System).out}" />
</bean>
</beans>
在这里,BraveKnight 和 SlayDragonQuest 被声明为 Spring 中的 bean。就 BraveKnight bean 来讲,它在构造时传入了对 SlayDragonQuest bean 的引用,将其作为构造器参数。同时, SlayDragonQuest bean 的声明使用了 Spring 表达式语言(Spring Expression Language),将 System.out(这是一个 PrintStream)传入到了 SlayDragonQuest 的构造器中。
尽管 BraveKnight 依赖于 Quest,但是它并不知道传递给它的是什么类型的 Quest,也不知道这个 Quest 来自哪里。与之类似,SlayDragonQuest 依赖于 PrintStream,但是在编码时它并不需要知道这个 PrintStream 是什么样子的。只有 Spring 通过它的配置,能够了解这些组成部分是如何装配起来的。这样的话,就可以在不改变所依赖的类的情况下,修改依赖关系。
现在已经声明了 BraveKnight 和 Quest 的关系,接下来我们只需要装载 XML 配置文件,并把应用启动起来。
观察它如何工作
Spring 通过应用上下文(Application Context)装载 bean 的定义并把它们组装起来。Spring 应用上下文全权负责对象的创建和组装。Spring 自带了多种应用上下文的实现,它们之间主要的区别仅仅在于如何加载配置。 因为 knights.xml 中的 bean 是使用 XML 文件进行配置的,所以选择 ClassPathXmlApplicationContext 作为应用上下文相对是比较合适的。该类加载位于应用程序类路径下的一个或多个 XML 配置文件。KnightMain.java 加载包含 Knight 的 Spring 上下文,main() 方法调用 ClassPathXmlApplicationContext 加载 knights.xml,并获得 Knight 对象的引用。
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class KnightMain {
public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context =
new ClassPathXmlApplicationContext("META-INF/spring/knights.xml");
Knight knight = context.getBean(Knight.class);
knight.embarkOnQuest();
context.close();
}
}
这里的 main() 方法基于 knights.xml 文件创建了 Spring 应用上下文。随后它调用该应用上下文获取一个 ID 为 knight 的 bean。得到Knight 对象的引用后,只需简单调用 embarkOnQuest() 方法就可以执行所赋予的探险任务了。注意这个类完全不知道我们的英雄骑士接受哪种探险任务(是的,它只知道kinght是个Kinght接口实现的骑士类),而且完全没有意识到这是由 BraveKnight 来执行的。只有 knights.xml 文件知道哪个骑士执行哪种探险任务。