PHP 之道

PHP 依赖注入

来自维基百科的定义:

依赖注入(Dependency Injection,简称 DI)是一种软件设计模式。它允许移除硬编码的依赖关系,并使得在运行时或编译时更改这些依赖成为可能。

这句话把这个概念说得比实际情况复杂多了。简而言之,依赖注入就是通过构造函数、方法调用或设置属性的方式,为一个组件提供它所需要的依赖项(其他对象)。 就这么简单。

1. 基本概念

我们可以用一个简单甚至有些天真的例子来演示这个概念。

假设我们有一个 Database(数据库)类,它需要一个适配器(Adapter)来和数据库进行通信。在下面的代码中,我们在构造函数中直接实例化了适配器,这就创建了一个硬依赖。这会导致代码极难测试,并且意味着 Database 类和适配器被死死地耦合在了一起。

<?php
namespace Database;

class Database
{
    protected $adapter;

    public function __construct()
    {
        // 错误示范:硬编码依赖,高度耦合
        $this->adapter = new MySqlAdapter;
    }
}

class MysqlAdapter {}

我们可以重构这段代码,通过使用“依赖注入”来松绑这种依赖关系。在重构后的代码中,我们在构造函数中注入该依赖,并使用了 PHP 8 的构造器属性提升 (constructor property promotion) 语法,使其在整个类中作为属性直接可用:

<?php
namespace Database;

class Database
{
    // 正确示范:通过参数将依赖注入进来
    public function __construct(protected MySqlAdapter $adapter)
    {
    }
}

class MysqlAdapter {}

现在,是我们依赖项交给了 Database 类,而不是让它自己去创建依赖。除了构造函数,我们甚至可以创建一个接收依赖项作为参数的方法来设置它,或者如果 $adapter 属性是 public(公开)的,我们也可以直接为它赋值。

2. 解决复杂问题

如果你曾经阅读过关于依赖注入的文章,你可能见过 “控制反转 (Inversion of Control)”“依赖倒置原则 (Dependency Inversion Principle)” 这样的术语。这些就是依赖注入所要解决的“复杂问题”。

2.1 控制反转 (Inversion of Control, IoC)

正如字面意思,控制反转就是“反转”系统的控制权,将系统的组织控制逻辑与我们的对象彻底分离开来。在依赖注入的语境下,这意味着通过在系统的其他地方控制和实例化依赖项,来解除组件间的强绑定。

多年来,PHP 框架一直在努力实现控制反转。然而,问题变成了:我们到底反转了哪一部分的控制权?又转移到了哪里?例如,传统的 MVC 框架通常会提供一个超级对象或基类控制器,其他控制器必须继承它才能获得依赖项的访问权。这确实是一种控制反转,然而,这种方法并没有“松绑”依赖,仅仅是“转移”了它们。

依赖注入允许我们极其优雅地解决这个问题:只在需要的时候,准确注入我们需要的依赖,完全不需要任何硬编码的依赖。

3. S.O.L.I.D. 面向对象设计原则

3.1 单一职责原则 (Single Responsibility Principle)

单一职责原则关乎参与者和高层架构。它指出:“一个类应该只有一个引起它变化的原因。” 这意味着每个类应该只负责软件提供的一项单一功能。这种方法最大的好处是提高了代码的可重用性。通过将类设计为只做一件事,我们可以在任何其他程序中使用(或重用)它,而无需对其进行修改。

3.2 开闭原则 (Open/Closed Principle)

开闭原则关乎类设计和功能扩展。它指出:“软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。” 这意味着在需要新功能时,我们不应该去修改现有的代码,而是编写能被现有代码调用的新代码。在实践中,这意味着我们应该编写实现并遵循接口 (interfaces) 的类,并在参数中使用这些接口进行类型提示 (type-hint),而不是指定具体的类。
这种方法最大的好处是,我们可以在不修改现有代码的情况下非常轻松地扩展新功能,这意味着我们可以减少 QA 测试时间,并显著降低对应用程序产生负面影响的风险。我们可以更快、更有信心地部署新代码。

3.3 里氏替换原则 (Liskov Substitution Principle)

里氏替换原则关乎子类型和继承。它指出:“子类绝不能破坏父类的类型定义。” 或者用 Robert C. Martin 的话来说,“子类型必须能够替换掉它们的基类型。”
例如,如果我们有一个定义了 embed() 方法的 FileInterface 接口,并且我们有 AudioVideo 两个类都实现了该接口,那么我们可以预期调用 embed() 方法始终会执行我们期望的操作。如果稍后我们创建了实现了该接口的 PDF 类或 Gist 类,我们自然也明白它的 embed() 方法会做什么。这种方法最大的好处是,它让我们能够构建灵活且易于配置的程序:当我们把一种类型(如 FileInterface)的对象替换为另一种时,不需要修改程序中的任何其他代码。

3.4 接口隔离原则 (Interface Segregation Principle)

接口隔离原则 (ISP) 关乎业务逻辑与客户端的通信。它指出:“不应该强迫客户端依赖它不使用的方法。” 这意味着,与其设计一个大而全的单体接口让所有类去实现,不如提供一系列小型的、特定概念的接口,让具体类去实现其中的一个或多个。
例如,一个 Car(汽车)或 Bus(公交车)类会需要一个 steeringWheel()(方向盘)方法,但 Motorcycle(摩托车)或 Tricycle(三轮车)类则不需要。反之亦然,摩托车需要 handlebars()(车把手)方法,而汽车不需要。完全没有必要让所有这些交通工具都去实现方向盘和车把手,因此我们应该拆分原始的通用接口。

3.5 依赖倒置原则 (Dependency Inversion Principle)

依赖倒置原则关乎移除独立类之间的硬链接,以便通过传递不同的类来引入新功能。它指出我们应该 “依赖于抽象,而不是依赖于具体实现”。简而言之,这意味着我们的依赖项应该是接口(约定)或抽象类,而不是具体的实现类。我们可以根据这个原则轻松重构上面的例子:

<?php
namespace Database;

class Database
{
    // 我们现在依赖的是一个接口 (AdapterInterface),而不是具体的类
    public function __construct(protected AdapterInterface $adapter)
    {
    }
}

interface AdapterInterface {}

class MysqlAdapter implements AdapterInterface {}

现在 Database 类依赖于一个接口而不是具体实现,这带来了许多好处:

  • 并行开发与测试: 假设你们是一个团队,适配器正由你的同事开发。在我们的第一个例子中,你必须等待同事写完适配器,才能在单元测试中对其进行模拟 (mock)。现在依赖变成了一个接口(约定),你可以毫无顾虑地模拟这个接口进行测试,因为你知道你的同事最终会根据这个约定来构建适配器。
  • 极强的可扩展性: 如果一年后你们决定迁移到不同类型的数据库,你只需编写一个实现了原始接口的新适配器并注入它。你不需要重构任何核心业务代码,因为新适配器必定遵循了接口设定的约定。

4. 容器 (Containers)

关于“依赖注入容器 (Dependency Injection Containers)”,你首先应该明白的是:它们与依赖注入本身不是一回事。

容器只是一个帮助我们实现依赖注入的便捷工具。然而,它们经常被误用来实现一种被称为 “服务定位器 (Service Location)” 的反模式。如果你把 DI 容器直接注入到类中去充当服务定位器,这可以说是制造了一个比你原本想消除的硬依赖更严重的依赖。它还会让你的代码变得极不透明,最终导致难以测试。

大多数现代框架都有自己的依赖注入容器,允许你通过配置文件把依赖关系“组装”起来。在实践中,这意味着你可以写出与它所基于的框架一样干净、低耦合的业务代码。

5. 延伸阅读