理解 Dependency Injection 实现原理

来源:互联网 发布:多重网络会影响网速吗 编辑:程序博客网 时间:2024/05/17 08:08

现代较新的 Web Framework 都强调自己有 Dependency Injection (以下简称 DI ) 的特色,只是很多人对它的运作原理还是一知半解。

所以接下来我将用一个简单的范例,来为各位介绍在 PHP 中如何实现简易的 DI 。
基本范例

这是一个应用程式的范例,它只包含了登入处理程序。在这个范例中, App 类别的建构式参考了新的 Auth 与 Session 的物件实体,并在 App::login() 中使用。

注:请特别注意,为了呈现重点,我忽略掉很多程式码,同时也没有进行良好的架构设计;所以请不要把这个范例用在你的程式中,或是对为什么我没有进行错误处理,以及为什么要采用奇怪的设计提出质疑。

class App{    protected $auth = null;    protected $session = null;    public function __construct($dsn, $username, $password)    {        $this->auth = new Auth($dsn, $username, $password);        $this->session = new Session();    }    public function login($username, $password)    {        if ($this->auth->check($username, $password)) {            $this->session->set('username', $username);            return true;        }        return false;    }}

而 Auth 类别是从资料库验证使用者身份,这里我仅用简单的描述来呈现效果。

class Auth{    public function __construct($dsn, $user, $pass)    {        echo "Connecting to '$dsn' with '$user'/'$pass'...\n";    }    public function check($username, $password)    {        echo "Checking username, password from database...\n";        return true;    }}

Session 类别也是概念性的实作:

class Session{    public function set($name, $value)    {        echo "Set session variable '$name' to '$value'.";    }}

最后我们让程式动起来, client 程式如下:

$app = new App('mysql://localhost', 'username', 'password');$username = 'jaceju';if ($app->login($username, 'password')) {    echo "$username just signed in.\n";}

注:这里的 client 程式指的是实际操作这些物件实体的程式。

各位可以先试着想想这个程式在可扩充性上有什么问题?例如我想把身份认证方式换成第三方服务的机制,或是改用其他媒介来存放 session 内容等。

还有如果想在没有资料库连线、或是没有 HTTP session 的环境下对 App::login() 方法的逻辑进行隔离测试,各位会怎么做呢?
解除依赖关系

上面的范例因为 App 类别已经依赖了 Auth 类别和 Session 类别,而这两个类别都有实作跟系统环境有关的程式逻辑,这么一来就会让 App 类别难以进行底层机制的切换或是隔离测试。所以接下来我们要做的,就是把它们的依赖关系解除。

修改后的 App 类别如下:

class App{    protected $auth = null;    protected $session = null;    public function __construct(Auth $auth, Session $session)    {        $this->auth = $auth;        $this->session = $session;    }}$auth = new Auth('mysql://localhost', 'username', 'password');$session = new Session();$app = new App($auth, $session);

首先我们在 App 类别的建构式 __construct 原本的资料库设定参数移除,并将原来直接以 new 关键字所产生的物件实体,改用方法参数的方式来注入。而使用 new 关键字产生物件实体的程式码,就移到 App 类别外。

这种「将依赖的类别改用方法参数来注入」的作法,就是我们说的「依赖注入 (Dependency Injection) 」。

常见依赖注入的方式有两种: Constructor Injection 及 Setter Injection 。它们的实作形式并没有什么不同,差别只在于是不是类别建构式而已。

不过 Constructor Injection 必须在建立物件实体时就进行注入,而 Setter Injection 则是可以在物件实体建立后才透过 setter 函式来进行注入。而这里为了方便解说,我采用的是 Constructor Injection 。
依赖抽象介面

好了,现在的问题是 Auth 类别的实作还是依赖在资料库上,所以我们也要让 Auth 类别跟资料库之间解除依赖关系,让它成为一个抽象介面。

这里的抽象介面是指观念上的意义,而非语言层级上的抽象类别 (Abstract Class) 或介面 (Interface) 。至于在实作上该用抽象类别还是介面,在这个范例里并没有差别,大家可以自行判断;这里我用介面 (Interface) ,因为我仅需要 Auth::check() 这个介面方法的定义而已。

这一步首先我把原来的 Auth 类别重新命名为 DbAuth 类别:

class DbAuth{    public function __construct($dsn, $user, $pass)    {        echo "Connecting to '$dsn' with '$user'/'$pass'...\n";    }    public function check($username, $password)    {        echo "Checking username, password from database...\n";        return true;    }}

接着建立一个 Auth 介面,它包含了 Auth::check() 方法的定义:

interface Auth{    public function check($username, $password);}

然后让 DbAuth 类别实作 Auth 介面:

class DbAuth implements Auth{    // ...}

最后把原来初始化 Auth 类别的物件实体的程式码,改为初始化 DbAuth 的物件实体。

$auth = new DbAuth('mysql://localhost', 'username', 'password');$session = new Session();$app = new App($auth, $session);

透过 Auth 介面的帮助,我们已经让 App 类别与实际的资料库操作类别分离开来了。现在只要是实作 Auth 介面的类别,都可以被 App 类别所接受,例如我们可能会改用 HTTP 认证来取代资料库认证:

class HttpAuth implements Auth{    public function check($username, $password)    {        echo "Checking username, password from HTTP Authentication...\n";        return true;    }}$auth = new HttpAuth();$session = new Session();$app = new App($auth, $session);

当然其他类型的认证方式也可以透过建立新的类别来使用,而不会影响到 App 类别的内部实作。
DI 容器

现在又有个问题, client 程式还是依赖于 DbAuth 类别或是 HttpAuth 类别;通常这种状况在需要编译型的语言 (例如 Java ) 中,程式一旦编译完成布署出去后,就很难再进行修改。

如果我们可以改用设定的方式来告诉程式,在不同的状况下对应不同的类别,然后让程式自行判断环境来产生需要的物件实体,这样就可以解开 client 程式对实作类别的依赖关系。

这里要引入一个技术,称为 DI 容器 (Dependency Injection Container) 。 DI 容器主要的作用在于帮我们解决产生物件实体时,应该参考哪一个类别。我们先来看看用法:

Container::register('Auth', 'DbAuth', ['mysql://localhost', 'username', 'password']);$auth = Container::get('Auth');$session = new Session();$app = new App($auth, $session);

首先我们在 DI 容器中先以 Container::register() 方法来注册 Auth 这个别名实际上要对应哪个类别,以及建立物件实体时会用到的初始化参数。要注意,这里的别名并不是指真正的类别或介面,但我们可以用相同的名称以避免认知上的问题。

然后我们用 Container::get() 方法取得别名所对应类别的物件实体,上面例子里的 $auth 就是 DbAuth 类别的物件实体。

这么一来,我们就可以把注册的程式码移出 client 程式之外,并将注册参数改用设定档引入,顺利解开 client 程式对实作类别的依赖。
DI 容器原理

那么 DI 容器的原理是怎么运作的呢?首先在 Container::register() 方法注册的部份,它其实只是把参数记到 $map 这个类别静态属性里。

class Container{    protected static $map = [];    public static function register($name, $class, $args = null)    {        static::$map[$name] = [$class, $args];    }    // ...}

重点在 Container::get() 方法,它透过 namemap 属性中对应的类别名称和初始化参数取出;接着判断类别是不是存在,如果存在的话就建立对应的物件实体。

class Container{    // ...    public static function get($name)    {        list($class, $args) = isset(static::$map[$name]) ?                              static::$map[$name] :                              [$name, null];        if (class_exists($class, true)) {            $reflectionClass = new ReflectionClass($class);            return !empty($args) ?                   $reflectionClass->newInstanceArgs($args) :                   new $class();        }        return null;    }}

比较特别的是,如果初始化参数不是空值 (null) 时,则必须透过 ReflectionClass::newInstanceArgs() 方法来建立物件实体。 ReflectionClass 类别可以映射出指定类别的内部结构,并提供方法来操作这个结构; Reflection 是现代语言常见的机制, PHP 在这方面也提供了完整的 API 供开发者使用,请参考: PHP: Reflection 。

Container::get() 方法也可以在没有注册的状况下,直接把别名当成类别名称,然后协助我们初始化对应的物件实体;例如:

$session = Container::get('Session');

手动注入

现在我们的 client 程式已经修改成以下的样子:

$auth = Container::get('Auth');$session = Container::get('Session');$app = new App($auth, $session);

不过当初始化参数较多的状况下,重复写好几次 Container::get() 看起来也是挺囉嗦的。

接下来我们实作一个 Container::inject() 方法,提供开发者可以一次注入所有依赖物件实体:

$app = Container::inject('Auth', 'Session', function ($auth, $session) {    return new App($auth, $session);});

这里我们让 Container::inject() 接受不定个数的参数,除了最后一个参数必须是 callback 型态外,其他都是要传递给 Container::get() 的参数。 Container::inject() 的实作方式如下:

class Container{    // ...    public static function inject()    {        $args = func_get_args();        $callback = array_pop($args);        $injectArgs = [];        foreach ($args as $name) {            $injectArgs[] = Container::get($name);        }        return call_user_func_array($callback, $injectArgs);    }

在参数个数不定的状况下,可以用 func_get_args() 函式来取得所有参数;而 array_pop() 可以取出最后一个参数值做为 callback 。剩下的参数就透过 Container::get() 来取得物件实体,最后再透过 call_user_func_array() 函式将处理好的参数传递给 callback 执行。
自动解决所有依赖注入

在我们的范例里, Container 类别如果可以提供一个方法,自动为我们解决所有 App 类别依赖问题,那么程式就可以更干净些。

要做到这点,我们就必须知道要注入的方法所需要参数的类型;而在 PHP 中的 Type Hinting ,就可以告诉我们参数所对应的变数类型或类别。

回到 App::__construct() 建构子上,我们看到 authsession 两个参数的 type hint 分别对应到 Auth 与 Session 这两个类别,刚好就可以用来当做我们做自动依赖注入的条件。

class App{    public function __construct(Auth $auth, Session $session)    {    }}

接着我们为 Container 类别提供一个 resolve() 方法,它可以接受一个类别名称用来建立物件实体,而不需要再使用 new 关键字。

$app = Container::resolve('App');

我们希望 Container::resolve() 方法会自动产生参数所对应的物件,解决这个类别建构子所需要的依赖关系。它的实作如下:

class Container{    // ...    public static function resolve($name)    {        if (!class_exists($name, true)) {            return null;        }        $reflectionClass = new ReflectionClass($name);        $reflectionConstructor = $reflectionClass->getConstructor();        $reflectionParams = $reflectionConstructor->getParameters();        $args = [];        foreach ($reflectionParams as $param) {            $class = $param->getClass()->getName();            $args[] = static::get($class);        }        return !empty($args) ?               $reflectionClass->newInstanceArgs($args) :               new $class();    }}

Container::resolve() 方法与 Container::get() 方法的原理类似,但较特别的是它使用了 ReflectionClass::getConstructor() 方法来取得类别建构子的 ReflectionMethod 实体;接着再用 ReflectionMethod::getParameters() 取出参数的 ReflectionParameter 物件集合 (阵列) 。

而后我们就可以在回圈中一一透过 ReflectionParameter::getClass() 方法与 ReflectionClass::getName() 方法来取得 type hint 所指向的类别或介面名称。当有了参数所对应的类别或介面名称后,就可以用 Container::get() 方法来取得参数的物件实体。

最后把这些物件带回建构子的参数里,并初始化我们所需要的物件实体,就完成了 App 类别的自动依赖注入。
深入思考

再强调一次,这里的范例只是为了介绍 DI 容器的原理,并不能真正用在实务上。因为一个完整的 DI 容器还要考虑以下的问题:

类别不存在时的处理。与其他非类别的参数整合。如何建立设定档机制以便切换依赖关系。递回地自动注入物件实体。取得 Singleton 物件实体。可以透过原始码上的 DocBlock 注解来注明依赖关系。

目前已经有很多 DI Framework 帮我们处理好这些事情了,建议大家如果真的需要在专案中使用 DI 时,应该采用这些 Framework 。
总结

如果专案并不会有太多变化性,那么依赖注入对我们来说就不是那么重要。但是如果希望程式对特定类别的依赖性降低,只针对抽象介面实作,那么依赖注入就有其必要性。

在 PHP 上的 DI 容器的基本实作原理也不复杂,透过 Reflection 机制就可以看到类别内部的结构,让我们对它的建构子注入我们想要的参数值。

DI 容器要考量的部份也不少,但这些功能都已经有 Framework 实作,我们应该在专案中使用它们而尽可能不要自行开发。

希望透过以上的介绍,可以让大家对 Framework 的依赖注入机制有基本的认知。

注:上述程式码都可以在 php-di-container-examples 找到。

转载自網站製作學習誌
原文链接:http://jaceju.net/2014-07-27-php-di-container/

原创粉丝点击