深入浅出依赖注入

本文首发于 深入浅出依赖注入,转载请注明出处。

本文试图以一种易于理解的行文讲解什么是「依赖注入」这种设计模式。

或许您已经在项目中已经使用过「依赖注入」,只不过由于某些原因,致使您对它的印象不是特别深刻。

「依赖注入」可能是最简单的设计模式之一,但即便如此我发现要想真正的以一种老少咸宜的方式把它讲解透彻也绝非易事。

本文在写作过程中参考了诸多优秀的与「依赖注入」相关文章,我会从以下几个方面给大家讲解「依赖注入」究竟是一种怎样的设计模式:

目录结构

  • 什么是「组件」和「服务」

    • 「组件」的定义
    • 「服务」的定义
    • 「组件」与「服务」的异同
  • 什么是控制反转和依赖注入

    • 一个简单的示例
    • 控制反转
    • 依赖注入
  • 如何实现依赖注入

    • 通过构造函数注入依赖
    • 通过 setter 设值方法注入依赖
  • 什么是依赖注入容器
  • 依赖注入的优缺点

    • 优点
    • 不足
  • 如何选择依赖注入的方式

    • 选择通过构造函数注入:
    • 选择通过 setter 设值方法注入
  • 参考资料

提示:本文内容较多,会耗费较多的阅读实现,建议抽取空闲时间进行阅读;建议不要错过参考资料部分的学习;另外,由于本人技术水平所限表述不到的地方欢迎指正。

如果您觉得本文对您有帮助,在收藏的同时请随手点个「赞」,谢谢!

什么是「组件」和「服务」

在讲解什么是依赖注入之前,我们需要对什么是依赖这个问题进行说明。

所谓的「依赖」就是指在实现某个功能模块时需要使用另外一个(或多个)「组件」或「服务」,那么这个所需的「组件」或「服务」将被称为「依赖」。

后续文中统一使用「组件」表示某个模块的「依赖」,「依赖注入」就是指向使用者注入某个「组件」以供其使用。

「组件」的定义

「组件」:它是可能被作者无法控制的其它应用使用,但使用者不能对其源码进行修改的一个功能模块。

「服务」的定义

「服务」指:使用者以同步(或异步)请求远程接口来远程使用的一个功能接口。

「组件」与「服务」的异同

「组件」和「服务」的 共同之处 就是它们都将被其他应用程序或功能模块使用。

它们的不同之处在于:

  • 「组件」是在本地使用(如 jar 文件、dll 或者源码导入)
  • 「服务」是在远程使用(如 WebService、消息系统、RPC 或者 Socket)

什么是控制反转和依赖注入

「控制反转」和「依赖注入」本质上就是一个从 问题发现实现 的过程。

即在项目中我们通过使用「依赖注入」这种技术手段实现功能模块对其依赖组件的「控制反转」。

我们在开发的过程中时长会遇到这样一个问题:如何才能将不同的「组件」进行组装才能让它们配合默契的完成某个模块的功能?

「依赖注入」就是为了完成这样的 目标:将 依赖组件 的配置和使用分离开,以降低使用者与依赖之间的耦合度。

在阐述「依赖注入」这个模式具体含义前,还是先看一个常见的示例,或许对于理解更有帮助。

一个简单的示例

这个示例的灵感来自 What is Dependency Injection? 这篇文章(译文 什么是依赖注入?)。

从事服务端研发工作的同学,应该有这样的体验。

由于 HTTP 协议是一种无状态的协议,所以我们就需要使用「Session(会话)」机制对有状态的信息进行存储。一个典型的应用场景就是存储登录用户的状态到会话中。

<?php
$user = ['uid' => 1, 'uname' => '柳公子'];
$_SESSION['user'] = $user;

上面这段代码将登录用户 $user 存储「会话」的 user 变量内。之后,同一个用户发起请求就可以直接从「会话」中获取这个登录用户数据:

<?php
$user = $_SESSION['user'];

接着,我们将这段面向过程的代码,以面向对象的方法进行封装:

<?php
class SessionStorage
{
    public function __construct($cookieName = 'PHP_SESS_ID')
    {
        session_name($cookieName);
        session_start();
    }

    public function set($key, $value)
    {
        $_SESSION[$key] = $value;
    }

    public function get($key)
    {
        return $_SESSION[$key];
    }

    public function exists($key)
    {
        return isset($this->get($key));
    }
}

并且需要提供一个接口服务类 user:

<?php
class User
{
    protected $storage;

    public function __construct()
    {
        $this->storage = new SessionStorage();
    }

    public function login($user)
    {
        if (!$this->storage->exists('user')) {
            $this->storage->set('user', $user);
        }

        return 'success';
    }

    public function getUser()
    {
        return $this->storage->get('user');
    }
}

以上就是登录所需的大致功能,使用起来也非常容易:

<?php
$user = new User();
$user->login(['uid' => 1, 'uname' => '柳公子']);
$loginUser = $user->getUser();

这个功能实现非常简单:用户登录 login() 方法依赖于 $this->storage 存储对象,这个对象完成将登录用户的信息存储到「会话」的处理。

那么对于这个功能的实现,究竟还有什么值得我们去担心呢?

一切似乎几近完美,直到我们的业务做大了,会发现通过「会话」机制存储用户的登录信息已近无法满足需求了,我们需要使用「共享缓存」来存储用户的登录信息。这个时候就会发现:

User 对象的 login() 方法依赖于 $this->storage 这个具体实现,即耦合到一起了。这个就是我们需要面对的 核心问题

既然我们已经发现了问题的症结所在,也就很容易得到 解决方案:让我们的 User 对象不依赖于具体的存储方式,但无论哪种存储方式,都需要提供 set 方法执行存储用户数据。

具体实现可以分为以下几个阶段:

  1. 定义 Storage 接口

定义 Storage 接口的作用是: 使 UserSessionStorage 实现类进行解耦,这样我们的 User 类便不再依赖于具体的实现了。

编写一个 Storage 接口似乎不会太复杂:

<?php

interface Storage
{
    public function set($key, $value);

    public function get($key);

    public function exists($key);
}

然后让 SessionStorage 类实现 Storage 接口:

<?php
class SessionStorage implements Storage
{
    public function __construct($cookieName = 'PHP_SESS_ID')
    {
        session_name($cookieName);
        session_start();
    }

    public function set($key, $value)
    {
        $_SESSION[$key] = $value;
    }

    public function get($key)
    {
        return $_SESSION[$key];
    }

    public function exists($key)
    {
        return isset($this->get($key));
    }
}
  1. 定义一个 Storage 接口让 User 类仅依赖 Storage 接口

现在我们的 User 类看起来既依赖于 Storage 接口又依赖于 SessionStorage 这个具体实现:

<?php

class User
{
    protected $storage;

    public function __construct()
    {
        $this->storage = new SessionStorage();
    }
}

当然这已经是一个完美的登录功能了,直到我将这个功能开放出来给别人使用。然而,如果这个应用同样是通过「会话」机制来存储用户信息,现有的实现不会出现问题。

但如果使用者将「会话」机制更换到下列这些存储方式呢?

  • 将会话存储到 MySQL 数据库
  • 将会话存储到 Memcached 缓存
  • 将会话存储到 Redis 缓存
  • 将会话存储到 MongoDB 数据库
<?php
// 想象下下面的所有实现类都有实现 get,set 和 exists 方法
class MysqlStorage {}

class MemcachedStorage {}

class RedisStorage {}

class MongoDBStorage {}

...

此时我们似乎无法在不修改 User 类的构造函数的的情况下,完成替换 SessionStorage 类的实例化过程。即我们的模块与依赖的具体实现类耦合到一起了。

有没有这样一种解决方案,让我们的模块仅依赖于接口类,然后在项目运行阶段动态的插入具体的实现类,而非在编译(或编码)阶段将实现类接入到使用场景中呢?

这种动态接入的能力称为「插件」。

答案是有的:可以使用「控制反转」。

控制反转

「控制反转」提供了将「插件」组合进模块的能力。

在实现「控制反转」过程中我们「反转」了哪方面的「控制」呢?其实这里的「反转」的意义就是 如何去定位「插件」的具体实现

采用「控制反转」模式时,我们通过一个组装模块,将「插件」的具体实现「注入」到模块中就可以了。

依赖注入

了解完「控制反转」,我们再来看看什么是「依赖注入」。「依赖注入」和「控制反转」之间是怎样的一种关系呢?

「控制反转」是目的:它希望我们的模块能够在运行时动态获取依赖的「插件」,然后,我们通过「依赖注入」这种手段去完成「控制反转」的目的。

这边我试着给出一个「依赖注入」的具体的定义:

应用程序对需要使用的依赖「插件」在编译(编码)阶段仅依赖于接口的定义,到运行阶段由一个独立的组装模块(容器)完成对实现类的实例化工作,并将其「注射」到应用程序中称之为「依赖注入」。

如何实现依赖注入

如何实现依赖注入或者说依赖注入有哪些形式?

Inversion of Control Containers and the Dependency Injection pattern 一文中有过相关的阐述:

依赖注入的形式主要有三种,我分别将它们叫做构造注入( Constructor Injection)、设值
方法注入( Setter Injection)和接口注入( Interface Injection)

本文将结合上面的示例稍微讲下:

  1. 通过构造函数注入依赖
  2. 通过 setter 设值方法注入依赖

这两种注入方式。

通过构造函数注入依赖

通过前面的文章我们知道 User 类的构造函数既依赖于 Storage 接口,又依赖于 SessionStorage 这个具体的实现。

现在我们通过重写 User 类的构造函数,使其仅依赖于 Storage 接口:

<?php

class User
{
    protected $storage;

    public function __construct(Storage $storage)
    {
        $this->storage = $storage;
    }
}

我们知道 User 类中的 login 和 getUser 方法内依赖的是 $this->storage 实例,也就无需修改这部分的代码了。

之后我们就可以通过「依赖注入」完成将 SessionStorage 实例注入到 User 类中,实现高内聚低耦合的目标:

<?php
$storage = new SessionStorage('SESSION_ID');
$user = new User($storage);

通过 setter 设值方法注入依赖

设值注入也很简单:

<?php

class User
{
    protected $storage;

    public function setStorage(Storage $storage)
    {
        $this->storage = $storage;
    }
}

使用也几乎和构造方法注入一样:

<?php
$storage = new SessionStorage('SESSION_ID');
$user = new User();
$user->setStorage($storage);

什么是依赖注入容器

上面实现依赖注入的过程仅仅可以当做一个演示,真实的项目中肯定没有这样使用的。那么我们在项目中该如何去实现依赖注入呢?

嗯,这是个好问题,所以现在我们需要了解另外一个与「依赖注入」相关的内容「依赖注入容器」。

依赖注入容器我们在给「依赖注入」下定义的时候有提到 由一个独立的组装模块(容器)完成对实现类的实例化工作,那么这个组装模块就是「依赖注入容器」。

「依赖注入容器」是一个知道如何去实例化和配置依赖组件的对象。

尽管,我们已经能够将 User 类与实现分离,但是还需要进一步,才能称之为完美。

定义一个简单的服务容器:

<?php
class Container
{
    public function getStorage()
    {
        return new SessionStorage();
    }

    public function getUser()
    {
        $user = new User($this->getStorage());
        return $user;
    }
}

使用也很简单:

<?php
$container = new Container();
$user = $container->getUser();

我们看到,如果我们需要使用 User 对象仅需要通过 Container 容器的 getUser 方法即可获取这个实例,而无需关心它是如何被创建创建出来的。

这样,我们就了解了「依赖注入」几乎全部的细节了,但是现实总是会比理想更加骨感。因为,我们现有的依赖注入容器还相当的脆弱,因为它同样依赖于 SessionStorage,一旦我们需要替换这个实现,还是不得不去修改里面的源代码,而无法实现在运行时配置。

做了这么多工作,还是这样的结果,真是晴天霹雳啊!

为什么不考虑将实现类相关数据写入到配置文件中,在容器中实例化是从配置文件中读取呢?

有关使用依赖注入容器的更加详细的使用可以阅读我翻译的 依赖注入 系列文章,文章还部分篇章没有翻译,所以你也可以直接阅读 原文

依赖注入的优缺点

优点

  • 提供系统解耦的能力
  • 可以明确的了解到组件之间的依赖关系
  • 简化测试工作

前两个比较好理解,稍微说下依赖注入是如何简化测试的。

如果我们在实现 User 类时,还没有实现具体的 SessionStorage 类,而仅定义了 Storage 接口。

那么在测试时,可以编写一个 NopStorage 先用于测试,之后等实现了 SessionStorage 在进行替换即可。

不足

组件与注入器之间不会有依赖关系,因此组件无法从注入器那里获得更多的服务,只能获得配置信息中所提供的那些。

如何选择依赖注入的方式

如何选择依赖注入方式在 Inversion of Control Containers and the Dependency Injection pattern 一文中有给出相关论述。

选择通过构造函数注入:

  • 能够在构造阶段就创建完整、合法的对象;
  • 带有参数的构造子可以明确地告诉你如何创建一个合法的对象;
  • 可以隐藏任何不可变的字段。

选择通过 setter 设值方法注入

  • 如果依赖的「插件」太多时,选择设值注入更优

说完了什么是「控制反转」和「依赖注入」,相信大家已经对这两个概念有了相对比较清晰的了解。我想说的是任何事物的了解程度都不是一蹴而就的,所以即便有号称能一句话讲明白什么是「依赖注入」的文章,其实还是需要我们有了相对深入的了解后才能感悟其中的真意,所谓「读书百遍,其义自见」就是这个道理。

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注