(译)依赖注入

原文链接

在软件工程中,依赖注入是一种技术,一个对象(或一个静态方法)凭借此技术提供了其它对象的依赖。依赖是可被使用的一个对象(一个服务)。注入是传递依赖到将使用它的一个依赖于它的对象(一个客户端)。服务是客户端状态的一部分,传递服务给客户端,而非允许客户端来建立或寻找服务,是本设计模式的基本要求。

依赖注入的目的是实现对构造和对象使用的关注点分离。

依赖注入是更广泛的控制反转技术的一种形式。与其他形式的控制反转一样,依赖注入支持依赖反转原则。客户端将提供依赖的职责委托给外部代码(注入器)。客户端不允许调用注入器代码;注入代码构造了服务并调用客服端来注入它们。这意味着客户端不需要知道注入代码,如何构造服务甚至于实际上使用的是哪个服务;客户端只需要知道服务的内在接口,因为它们定义了客户端如何使用服务。这分离了使用和构造的责任。

目的

依赖注入设计模式解决了像以下这样的问题:

  • 应用程序或类如何独立于其对象的创建方式?
  • 如何在单独的配置文件中指定创建对象的方式?
  • 应用程序如何支持不同的配置?

直接在需要对象的类中创建对象是不灵活的,因为它将类提交给特定对象,并且以后无法独立于类来更改实例化。如果需要其他对象,它会让类无法重用,并且它使类难以测试,因为真实对象不能被模拟对象替换。

类不再负责创建它所需的对象,也不必像在抽象工厂设计模式中那样将实例化委托给工厂对象。

另请参见下面的 UML 类和序列图。

概览

依赖注入将客户端依赖的创建与客户端的行为分开,这使程序设计解耦并遵循依赖反转和单一责任原则。它与服务定位器模式形成鲜明对比,后者允许客户端了解他们用于查找依赖的系统。

注入是依赖注入的基本单元,不是新的或自定义的机制。它的工作方式与“参数传递”的工作方式相同。“参数传递”作为注入暗示着这个过程中客户端与细节隔离开来的。

注入也是关于在传递中谁主导的问题,并且与传递是如何完成无关,无论是通过引用还是值。

依赖注入涉及四个角色:

  • 要被使用的 service 对象
  • 依赖于它所使用的服务的 client 对象
  • 定义客户端如何使用服务的 interfaces
  • injector,负责构建服务并将其注入客户端

做一个类比,

  • service - 一个电磁炉或燃气灶,一个烤箱,一个烧烤架
  • client - 准备烹制牛排的厨师但是对使用的是什么烹饪设备无感
  • interface - 一种改变烹饪设备温度的方式
  • injector - 那个叫厨师在特定设备上烹饪的人

任何可以被使用的对象都可以被视为服务。任何使用其它对象的对象都可以被视为 客户端。这些名称与对象的用途无关,只与对象在注入中扮演的角色有关。

接口是客户端期望其依赖的类型。问题在于它们可以访问的内容。它们可能真的是由服务实现的接口类型,但也可能是抽象类甚至是具体服务本身,尽管这最后会违反 DIP 并牺牲能够进行测试的动态解耦。它只要求客户不知道它们是什么,因此从不将它们视为具体的,比如构建或扩展它们。

客户端应该不具体了解其依赖的具体实现。它应该只知道接口的名称和 API。因此,即使接口背后的内容发生变化,客户端也不需要更改。但是,如果接口从类被重构为接口类型的类(反之亦然),则需要重新编译客户端。如果客户端和服务单独发布,这一点很重要。这种不幸的耦合是依赖注入无法解决的。

注入器将服务引入客户端。通常,它也构建客户端。注入器可以通过将对象(如客户端)处理并随后作为另一客户端的服务来将非常复杂的对象图连接在一起。注射器实际上可能是许多物体一起工作但可能不是客户端。注入器可以用其他名称来指代,例如:汇编程序,提供程序,容器,工厂,构建器,弹簧,构造代码或主程序。

作为依赖注入的铁律,要求所有对象分离构造和行为。依靠 DI 框架来执行构造能够不使用 new 关键字,或者不那么严格,只允许值对象直接构造。

分类

控制反转(IoC)比 DI 更普遍。简而言之,IoC 意味着让其他代码调用你,而不是自己调用。没有 DI 的 IoC 示例是模板方法模式。在模版方法模式中,多态性是通过子类化来实现的,即继承。

依赖注入通过组合实现 IoC,因此通常与策略模式相同,但是策略模式旨在使依赖项在整个对象的生命周期内可互换,但在依赖项注入中,可能只使用依赖的单个实例,这仍然实现了多态性,但通过委托和组合实现。

依赖注入框架

应用框架类如CDI以及它的实现 Weld,Spring,Guice,Play framework ,Salta,Glassfish HK2, Dagger,托管扩展性框架(MEF)支持依赖注入,但不需要执行依赖注入。

优点

  • 依赖注入允许客户端只修改了客户端的行为就可以灵活地进行配置。客户端可以对支持客户期望的内部接口的任何事情采取行动。

  • 依赖注入可用于将系统的配置详细信息外部化为配置文件,从而允许在不重新编译的情况下重新配置系统。可以针对需要不同组件实现的不同情况编写单独的配置。这包括但不限于测试。

  • 因为依赖注入不需要对代码行为进行任何更改,所以它可以作为重构应用于遗留代码。结果是客户端更加独立,并且使用存根或模拟对象模拟其他未测试的对象,更容易单独进行单元测试。这种易测性通常是使用依赖注入时注意到的第一个好处。

  • 依赖注入允许客户端删除它需要使用的具体实现的所有知识。这有助于将客户端与设计更改和缺陷的影响隔离开来。它提高了可重用性,可测试性和可维护性。

  • 减少应用程序对象中的样板代码,因为初始化或设置依赖项的所有工作都由提供程序组件处理。
    依赖注入允许并发或独立开发。两个开发人员可以独立开发彼此使用的类,而只需要知道类将通过的接口。插件通常由第三方商店开发,甚至从不与创建使用插件的产品的开发人员交谈。

  • 依赖注入减少了类与其依赖之间的耦合。

    缺点

  • 依赖注入创建客户端,要求配置细节由构造代码提供。当明显的默认值可用时,这可能很麻烦。

  • 依赖注入可能使代码难以跟踪(读取),因为它将行为与构造分开。这意味着开发人员必须引用更多文件来跟踪系统的执行情况。

  • 依赖注入框架通过反射或动态编程实现。这可能会妨碍IDE自动化的使用,例如“查找引用”,“显示调用层次结构”和安全重构。

  • 依赖注入通常需要更多的前期开发工作,因为人们不能在需要的时间和地点调用出正确的东西,但必须要求它被注入,然后确保它已被注入。

  • 依赖注入迫使复杂性从类中移出并进入类之间的联系,这可能并不总是令人满意或容易管理。

  • 依赖注入会扩张依赖注入框架的依赖。

结构

UML 类和序列图

在上面的 UML 类图中,需要 ServiceA 和 ServiceB 对象的 Client 类不直接实例化ServiceA1 和 ServiceB1 类。相反,Injector 类创建对象并将它们注入到 Client 中,这使得 Client 独立于创建对象的方式(实例化哪些具体类)。

UML 序列图显示了运行时交互:Injector 对象创建 ServiceA1 和 ServiceB1 对象。此后,Injector 创建 Client 对象并注入 ServiceA1 和 ServiceB1 对象。

例子

没有依赖注入

在以下 Java 示例中,Client 类包含由 Client 构造函数初始化的 Service 成员变量。客户端控制使用哪种服务实现并控制其构造。在这种情况下,客户端被称为对 ExampleService 具有硬编码依赖性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 一个没有依赖注入的例子
public class Client {
// 对此客户端使用的服务的内部引用

private ExampleService service;

// Constructor
Client() {
// 指定特定的实现而不是使用依赖注入
service = new ExampleService();
}

// 客户端中使用服务的方法
public String greet() {
return "Hello " + service.getName();
}
}

依赖注入是初始化成员变量的替代技术,而像如上所示那样显式创建服务对象。

客户端对象至少有三种方式可以接收对外部模块的引用:

依赖注入的类型

  • 构造器注入:依赖是通过客户端的类构造器提供的。
  • setter 注入:客户端公开了一个 setter 方法,注入器用它来注入依赖。
  • 接口注入:依赖的接口提供了一个注入器方法,该方法将依赖项注入传递给它的任何客户端。客户端必须实现一个接口,该接口公开接受依赖 setter 方法。

其它类型

DI 框架可能具有超出上述范围的其他类型的注入。

测试框架也可以使用其他类型。一些现代测试框架甚至不要求客户端主动接受依赖注入,从而使遗留代码可测试。特别是,在 Java 语言中,可以使用反射在测试时公开私有属性,从而通过赋值接受注入。

在控制反转中的一些尝试不提供完全去除依赖性,而是简单地将一种形式的依赖替换为另一种形式。根据经验,如果程序员只能查看客户端代码并告诉我们正在使用什么框架,那么客户端就会对框架进行硬编码依赖。

构造器注入

此方法要求客户端在构造器中为依赖提供参数。

1
2
3
4
5
//构造函数
Client(Service service) {
// 保存指向传入 service 的引用到客户端中
this.service = service;
}

Setter 注入

此方法要求客户端为依赖项提供 setter 方法。

1
2
3
4
5
// Setter 方法
public void setService(Service service) {
// 保存指向传入 service 的引用到客户端中
this.service = service;
}

Interface 注入

这只是客户端将角色接口发布到客户端依赖的 setter 方法。它可用于确定注入依赖时注入器应如何与客户端通信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Service setter 接口.
public interface ServiceSetter {
public void setService(Service service);
}

// Client class
public class Client implements ServiceSetter {
// 被客户端使用的指向服务的内部引用
private Service service;

// 设置客户端要使用的服务
@Override
public void setService(Service service) {
this.service = service;
}
}

构造器注入比较

比较所有的依赖注入方式,构造器注入是首选的,因为它可用于确保客户端对象始终处于有效状态,而不是让其某些依赖项引用为null(不设置)。但是,就其本身而言,它缺乏后续更改其依赖关系的灵活性。这可以是使客户端不可变并因此线程安全的第一步。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Constructor
Client(Service service, Service otherService) {
if (service == null) {
throw new InvalidParameterException("service must not be null");
}
if (otherService == null) {
throw new InvalidParameterException("otherService must not be null");
}

// 保存服务引用到客户端中
this.service = service;
this.otherService = otherService;
}

Setter 注入比较

要求客户端为每个依赖提供 setter 方法。这使得可以随时自由地操纵依赖引用的状态。这提供了灵活性,但是如果要注入多个依赖项,则客户端很难确保在提供客户端之前注入所有依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 设置被客户端使用的服务
public void setService(Service service) {
if (service == null) {
throw new InvalidParameterException("service must not be null");
}
this.service = service;
}

// 设置其他被客户端使用的服务
public void setOtherService(Service otherService) {
if (otherService == null) {
throw new InvalidParameterException("otherService must not be null");
}
this.otherService = otherService;
}

因为这些注入是独立进行的,所以无法确定注入器何时完成到客户端的连接。注入器无法调用其setter,依赖就会为 null。这就要需要客户端组装依赖的时候对其进行强制检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 设置被客户端使用的服务
public void setService(Service service) {
this.service = service;
}

// 设置其他被客户端使用的服务
public void setOtherService(Service otherService) {
this.otherService = otherService;
}

// 检查客户端所引用的服务
private void validateState() {
if (service == null) {
throw new IllegalStateException("service must not be null");
}
if (otherService == null) {
throw new IllegalStateException("otherService must not be null");
}
}

// 使用服务引用的方法
public void doSomething() {
validateState();
service.doYourThing();
otherService.doYourThing();
}

Interface 注入比较

接口注入的优点是依赖关系可以完全忽略其客户端,但仍然可以接收对新客户端的引用,并使用它向客户端发送引用自身。通过这种方式,依赖关系成为注入器。关键是注入方法(可能只是一个经典的setter 方法)是通过接口提供的。

仍然需要装配程序来介绍客户端及其依赖。装配程序将引用客户端,将其转换为设置该依赖的setter 接口,并将其传递给该依赖对象,该依赖对象将转向并将引用自身传递回客户端。

对于具有值的接口注入,除了简单地将引用传递回自身之外,依赖必须做一些事情。这可以充当工厂或子装配程序来解析其他依赖,从而从主装配程序中抽象出一些细节。它可以是引用计数,以便依赖项知道有多少客户端正在使用它。如果依赖项维护了一组客户端,那么以后可以将它们全部注入其自身的不同实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Service setter interface.
public interface ServiceSetter {
public void setService(Service service);
}

// 客户端类
public class Client implements ServiceSetter {
// 被客户端使用的指向服务的内部引用
private Service service;

// 设置客户端要使用的服务
@Override
public void setService(Service service) {
this.service = service;
}
}

// Injector class
public class ServiceInjector {
Set<ServiceSetter> clients;
public void inject(ServiceSetter client) {
clients.add(client);
client.setService(new ServiceFoo());
}
public void switchToBar() {
for (Client client : clients) {
client.setService(new ServiceBar());
}
}
}

// Service classes
public class ServiceFoo implements Service {}
public class ServiceBar implements Service {}

装配的例子

在主方法中手动装配是实现依赖注入的一种方式。

1
2
3
4
5
6
7
8
9
10
11
12
public class Injector {
public static void main(String[] args) {
// 首先构建依赖
Service service = new ExampleService();

// 以构造函数的形式注入依赖
Client client = new Client(service);

// 使用对象
System.out.println(client.greet());
}
}

上面的示例手动构造对象图,然后在一个点调用它以使其工作。需要注意的是,这种注入器并不纯净。它使用它构造的对象之一。它与 ExampleService 只有纯粹的构造关系,但混合了Client的构造和使用。这不应该是常见的。然而,这是不可避免的。就像面向对象的软件需要像 main()这样的非面向对象的静态方法来启动时,依赖注入的对象图需要至少一个(最好只有一个)入口点才能使整个事情开始。

主方法中的手动构造可能不是这样直接的,并且可能涉及调用建造者,工厂或其他构建模式。这可以是相当先进和抽象的。一旦构造代码不再是应用程序的自定义代码而是通用的,那么该代码行就会从手动依赖注入转移到框架依赖注入。

像 Spring 这样的框架可以在返回一个引用给客户端之前构造这些相同的对象并将它们连接在一起。所有提及具体的 ExampleService 都可以从代码移动到配置数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.beans.factory.BeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;

public class Injector {
public static void main(String[] args) {
// -- 装配对象 -- //
BeanFactory beanfactory = new ClassPathXmlApplicationContext("Beans.xml");
Client client = (Client) beanfactory.getBean("client");

// -- 使用对象 -- //
System.out.println(client.greet());
}
}

像 Spring 这样的框架允许装配细节外部化到配置文件中。此代码(上面)构造对象并根据Beans.xml(下面的)将它们连接在一起。仍然构造 ExampleService,尽管它仅在下面提到。可以通过这种方式定义长而复杂的对象图,并且代码中提到的类,是带有入口方法的,在此例中是greet()。

1
2
3
4
5
6
7
8
9
10
11
12
13
 <?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-3.0.xsd">

<bean id="service" class="ExampleService">
</bean>

<bean id="client" class="Client">
<constructor-arg value="service" />
</bean>
</beans>

在上面的示例中,客户端和服务不必经历任何由 spring 提供的更改。它们被允许保持简单的 POJO。这展示了 spring 如何连接完全不知道它存在的服务和客户端。如果将 Spring 注解添加到类中,则无法说明这一点。通过保持特定于 Spring 的注解和调用在许多类中扩散,系统只能松散地依赖于 Spring。如果系统的生命周期比 Spring 长,这可能很重要。

保持 POJO 纯净的选择并非没有成本。不是花费精力来开发和维护复杂的配置文件,而是可以简单地使用注解来标记类,让 spring 完成其余的工作。如果依赖于诸如按类型或名称匹配的约定,则解析依赖关系可以很简单。这是选择约定优于配置。同样可以说,当重构到另一个框架时,删除框架特定注解将是任务的一个微不足道的部分,并且许多注入注解现在已经标准化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.beans.factory.BeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class Injector {
public static void main(String[] args) {
// 装配对象
BeanFactory beanfactory = new AnnotationConfigApplicationContext(MyConfiguration.class);
Client client = beanfactory.getBean(Client.class);

// 使用对象
System.out.println(client.greet());
}
}
1
2
3
4
5
6
7
8
9
10
11
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@ComponentScan
static class MyConfiguration {
@Bean
public Client client(ExampleService service) {
return new Client(service);
}
}
1
2
3
4
5
6
@Component
public class ExampleService {
public String getName() {
return "World!";
}
}

装配比较

就依赖注入而言,不同的注入器实现(工厂,服务定位器和依赖注入容器)并没有那么不同。最重要的是它们被允许使用的地方。将调用从工厂或服务定位器从客户端移动到主方法,突然地主方法就制造出了一个相当好的依赖注入容器。

通过将所有注入器的知识移出,一个完全不了解外部世界的整洁的客户端就产生了。但是,使用其他对象的任何对象都可以被视为客户端。包含main的对象也不例外。这个主对象不使用依赖注入。它实际上使用的是服务定位器模式。这是无法避免的,因为必须在某处进行服务实现的选择。

将依赖外部化为配置文件不会改变这一事实。使这个现实成为优秀设计的一部分是服务定位器不会遍布整个代码库。每个应用程序仅限于一个地方。这使得代码库的其余部分可以自由地使用依赖注入来创建整洁的客户端。

依赖注入模式

到目前为止,这些例子都是关于构造字符串的过于简单的例子。但是,依赖注入模式在构造对象图时最有用,其中对象通过消息进行通信。在主方法中构造的对象将在持续整个程序生命周期。典型的模式是构造对象图,然后在一个对象上调用一个方法,将控制流发送到对象图中。正如主方法是静态代码的入口点一样,这一个方法是应用程序非静态代码的入口点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws IOException {

// 构造代码
Greeter greeter = new Greeter(System.out); // 这是用来连接对象的,可能有很多行
//行为代码.
greeter.greet(); // 这是对对象图上一个对象的一个方法的调用。
}

class Greeter {
public void greet() {
this.out.println("Hello world!");
}
public Greeter(PrintStream out) {
this.out = out;
}
private PrintStream out;
}
非典型前端coder wechat
想要随时Follow我的最新博客,可扫码关注我的公众号