十六、添加依赖注入容器

我们已经到了现代化进程的最后一步。我们将通过将页面脚本的剩余逻辑移动到依赖项注入容器中,来移除页面脚本的最后一部分。容器将负责协调应用程序中的所有对象创建活动。在此过程中,我们将再次修改前端控制器,并开始添加指向控制器类而不是文件路径的路由。

对于现代化过程的最后一步,最好安装 PHP5.3 或更高版本。这是因为应用程序逻辑的关键部分需要闭包。如果我们不能访问 PHP5.3,那么有一个不太可行但仍然可行的选项来实现依赖注入容器。我们将这一情况作为本章最后一个“共同问题”来处理。

什么是依赖注入容器?

依赖注入作为一种技术,我们从本书早期开始就一直在实践。重申一下,依赖注入背后的思想是我们从外部将依赖项推送到对象中。这与通过 new 关键字在类内部创建依赖项对象相反,或者通过globals关键字超出当前范围引入依赖项。

关于控制反转和依赖注入的概述,请阅读 Fowler 关于容器的文章http://martinfowler.com/articles/injection.html

为了完成依赖项注入活动,我们已经在页面脚本中手动创建了必要的对象。对于任何需要依赖项的对象,我们首先创建依赖项,然后创建依赖它的对象并传入依赖项。这个创建过程有时是深层次的,就像依赖项有依赖项一样。不管复杂性和深度如何,这样做的逻辑目前嵌入在页面脚本中。

依赖注入容器背后的想法是将所有对象创建逻辑保存在一个地方,这样我们就不再需要使用页面脚本来设置对象。我们可以将容器中的每个对象创建逻辑放在一个唯一的名称下,称为服务。

然后,我们可以告诉容器返回任何已定义服务对象的新实例。或者,我们可以告诉容器创建并返回该服务对象的共享实例,这样每次我们得到它时,它总是同一个实例。仔细组合容器服务的新实例和共享实例将允许我们减少依赖项创建逻辑。

在任何时候,我们都不会将容器传递到任何需要依赖关系的对象中。要做到这一点,需要使用一种称为服务定位器的模式。我们避免服务定位器活动,因为这样做违反了范围。当容器位于一个对象内部,并且该对象使用它来检索依赖项时,我们只需要从开始的地方走一步;也就是说,使用global关键字。因此,我们不传递容器——它完全位于它创建的对象的范围之外。

PHPLAND 中有许多不同的容器实现,每个都有自己的优缺点。为了使事情适合我们的现代化进程,我们将使用Mlaphp\Di。这是一个精简的容器实现,非常适合我们的过渡需要。

添加 DI 容器

添加 DI 容器的流程一般如下:

  1. 添加一个新的services.php包含文件以创建容器并管理其服务。
  2. 在容器中定义一个router服务。
  3. 修改前端控制器以包含services.php文件并使用router服务,然后抽查应用。
  4. 将创建逻辑从每个页面脚本提取到容器:
    1. 在为页面脚本控制器类命名的容器中创建服务。
    2. 将页面脚本中的逻辑复制到容器服务中。根据需要重命名变量以使用 DI 容器属性。
    3. 将页面 URL 路径路由到容器服务名称(即控制器名称)。
    4. 抽查并提交更改。
    5. 继续,直到将所有页面脚本提取到容器中。
  5. 移除空的pages/目录,提交、推送并通知 QA。

添加 DI 容器包含文件

为了防止现有的安装文件变得更大,我们将引入一个新的services.php安装文件。是的,这意味着在前端控制器中添加另一个include,但如果我们一直努力,那么在我们的应用程序中几乎没有剩余的包含项。这一个意义不大。

首先,我们需要为文件选择一个合适的位置。如果它与我们已有的任何其他安装文件一起使用,可能是在现有的includes/目录中,这可能是最好的。

然后我们用下面的行创建文件。(我们将在此文件中添加更多内容)由于该文件将作为最后一个安装文件加载,我们可以假定自动加载将处于活动状态,因此无需加载Di类文件:

includes/services.php
1 <?php
2 $di = new \Mlaphp\Di($GLOBALS);
3 ?>

结果是,新的$di实例加载了所有现有的全局变量值。这些值作为属性保留在容器上。例如,如果我们的设置文件创建了一个$db_user变量,我们现在可以额外访问该值作为$di->db_user。这些是副本,不是引用,因此对其中一个的更改不会影响另一个。

为什么我们保留现有变量作为属性?

目前,我们的页面脚本直接访问全局变量进行创建工作。但是,在后面的步骤中,创建逻辑将不再在全局范围内。它将位于 DI 容器的“内部”。因此,我们使用变量的副本填充 DI 容器,否则这些变量将可用。

添加路由器服务

既然在位置有了 DI 容器,那么让我们添加我们的第一个服务。

回想一下,DI 容器的目的是为我们创建对象。当前,前端控制器创建了一个路由器对象,因此我们将向容器中添加一个router服务。(在下一步中,我们将让前端控制器使用此服务,而不是自己创建路由器。)

services.php文件中,添加以下行:

includes/services.php
1 <?php
2 // set a container service for the router
3 $di->set('router', function () use ($di) {
4 $router = new \Mlaphp\Router('/path/to/app/pages');
5 $router->setRoutes(array());
6 return $router;
7 });
8 ?>

让我们稍微检查一下服务定义。

  • 服务名称为router。对于打算创建一次作为共享实例的服务对象,我们将使用所有小写名称;对于打算每次创建为新实例的服务对象,我们将使用完全限定的类名。因此,在这种情况下,我们的目的是通过容器只提供一个共享的router。(这是一种约定,而不是由容器强制执行的规则。)
  • 服务定义是可调用的。在这种情况下,它是一个闭包。闭包没有收到任何参数,但它确实使用了当前范围中的$di对象。这使得定义代码可以在构建服务对象时访问容器属性和其他容器服务。
  • 我们创建并返回由服务名称表示的对象。我们不需要检查对象是否已经存在于容器中;如果我们请求一个共享实例,容器内部将为我们实现这一点。

通过这段代码,容器现在知道如何创建router服务。只有在调用$di->newInstance()(获取服务对象的新实例)或$di->get()(获取服务对象的共享实例)时,才会执行延迟加载的代码。

修改前控制器

既然有了 DI 容器和router服务定义,我们修改了前端控制器来加载容器并使用router服务。

docroot/front.php
1 <?php
2 require dirname(__DIR__) . '/includes/setup.php';
3 require dirname(__DIR__) . '/includes/services.php';
4
5 // get the shared router service
6 $router = $di->get('router');
7
8 // match against the url path
9 $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
10 $route = $router->match($path);
11
12 // container service, or page script?
13 if ($di->has($route)) {
14 // create a new $controller instance
15 $controller = $di->newInstance($route);
16 } else {
17 // require the page script
18 require $route;
19 }
20
21 // invoke the controller and send the response
22 $response = $controller->__invoke();
23 $response->send();
24 ?>

我们对以前的实现进行了以下更改:

  • 我们为services.php容器文件添加了一个require,这是我们最后一次设置。
  • 我们不直接创建路由器对象,而是get()$di容器中创建router服务对象的共享实例。
  • 我们的调度逻辑有所改变。在我们从$router得到$route后,我们检查$di集装箱has()是否有匹配的服务。如果是,则将该$route作为新$controller实例的服务名称;否则,它会将$route视为pages/中创建$controller的文件。无论哪种方式,代码都会调用控制器并发送响应。

在这些更改之后,我们会抽查应用程序,以确保新的router服务正常工作。如果没有,我们将撤消并重做到目前为止的更改,直到应用程序像以前一样工作。

一旦应用程序运行,我们可能希望提交我们的更改。这就是说,如果将来的更改变糟,我们可以恢复到一个已知的工作状态。

将页面脚本提取到服务

现在是对传统应用程序进行现代化的最后一步。我们将逐个删除页面脚本,并将其逻辑放入容器中。

创建一个容器服务

选择任何一个页面脚本,确定它用来创建其$controller实例的类。然后,在 DI 容器中,为该类名创建一个空的服务定义。

例如,如果我们有此页面脚本:

pages/articles.php
1 <?php
2 $db = new Database($db_host, $db_user, $db_pass);
3 $articles_gateway = new ArticlesGateway($db);
4 $users_gateway = new UsersGateway($db);
5 $article_transactions = new ArticleTransactions(
6 $articles_gateway,
7 $users_gateway
8 );
9 $response = new \Mlaphp\Response('/path/to/app/views');
10 $controller = new \Controller\ArticlesPage(
11 $request,
12 $response,
13 $user,
14 $article_transactions
15 );
16 ?>

我们知道正在实例化的控制器类是Controller\ArticlesPage。在我们的services.php文件中,我们使用该名称创建了一个空的服务定义:

includes/services.php
1 <?php
2 $di->set('Controller\ArticlesPage', function () use ($di) {
3 });
4 ?>

接下来,我们将页面脚本设置逻辑移动到服务定义中。当我们这样做时,我们应该注意我们期望从全局范围中得到的任何变量,并在它们前面加上$di->以引用适当的容器属性。(回想一下,这些都是从services.php文件的早期$GLOBALS加载的。)我们还将在定义的末尾返回控制器实例。

当我们完成时,服务定义将如下所示:

includes/services.php
1 <?php
2 $di->set('Controller\ArticlesPage', function () use ($di) {
3 // replace `$variables` with `$di->` properties
4 $db = new Database($di->db_host, $di->db_user, $di->db_pass);
5 // create dependencies
6 $articles_gateway = new ArticlesGateway($db);
7 $users_gateway = new UsersGateway($db);
8 $article_transactions = new ArticleTransactions(
9 $articles_gateway,
10 $users_gateway
11 );
12 $response = new \Mlaphp\Response('/path/to/app/views');
13 // return the new instance
14 return new \Controller\ArticlesPage(
15 $request,
16 $response,
17 $user,
18 $article_transactions
19 );
20 });
21 ?>

一旦我们将逻辑复制到容器中,我们将从pages/中删除原始页面脚本文件。

将 URL 路径路由到容器服务

现在我们已经删除了页面脚本以支持容器服务,我们需要确保路由器指向容器服务,而不是现在缺少的页面脚本。我们通过在setRoutes()方法参数中添加一个数组元素来实现这一点,其中键是 URL 路径,值是服务名称。

例如,如果 URL 路径为/articles.php并且我们的新容器服务名为Controller\ArticlesPage,我们将修改我们的router服务,如下所示:

includes/services.php
1 <?php
2 // ...
3 $di->set('router', function () use ($di) {
4 $router = new \Mlaphp\Router($di->pages_dir);
5 $router->setRoutes(array(
6 // add a route that points to a container service name
7 '/articles.php' => 'Controller\ArticlesPage',
8 ));
9 return $router;
10 });
11 ?>

抽查并提交

最后,我们检查从页面脚本到容器服务的转换是否如我们预期的那样工作。我们通过浏览或以其他方式调用该 URL 来抽查旧页面脚本的 URL 路径。如果成功,那么我们知道容器服务已经成功地取代了现在已删除的页面脚本。

如果没有,我们需要撤销和重做我们的更改,看看哪里出了问题。我在这里看到的最常见错误是:

  • 无法将页面脚本中的$var变量替换为服务定义中的$di->var属性
  • 无法从服务定义返回对象
  • 控制器服务名称与映射的路由值不匹配

一旦我们确定应用程序将 URL 路由到新的容器服务,并且该服务工作正常,我们就提交更改。

做。。。虽然

我们继续下一页脚本并重新开始该过程。当所有页面脚本都转换为容器服务并删除后,我们就完成了。

删除页面/提交、推送、通知 QA

在我们将所有页面脚本提取到 DI 容器后,pages/目录应该是空的。我们现在可以安全地移除它。

这样,我们就可以提交工作,推送到公共存储库,并通知 QA 我们有新的更改供他们审查。

常见问题

我们如何完善我们的服务定义?

当我们将对象创建逻辑提取到容器中时,每个服务定义都可能相当长,并且可能是重复的。最好减少重复并细化服务定义,使其简短明了。我们可以通过进一步将对象创建逻辑的每个部分提取到它自己的服务来实现这一点。

例如,如果我们有几个使用请求对象的服务,我们可以将对象创建逻辑提取到它自己的服务中,然后在其他服务中引用该服务。我们可以将其命名以表明我们的意图,即将其用作共享服务(request)或新实例(Mlaphp\Request)。然后,其他服务可以使用get()newInstance(),而不是在内部创建请求。

考虑到我们早期的Controller\ArticlesPage服务,我们可以将其拆分为几个可重用的服务,如下所示:

includes/services.php
1 <?php
2 // ...
3
4 $di->set('request', function () use ($di) {
5 return new \Mlaphp\Request($GLOBALS);
6 });
7
8 $di->set('response', function () use ($di) {
9 return new \Mlaphp\Response('/path/to/app/views');
10 });
11
12 $di->set('database', function () use ($di) {
13 return new \Database(
14 $di->db_host,
15 $di->db_user,
16 $di->db_pass
17 );
18 });
19
20 $di->set('Domain\Articles\ArticlesGateway', function () use ($di) {
21 return new \Domain\Articles\ArticlesGateway($di->get('database'));
22 });
23
24 $di->set('Domain\Users\UsersGateway', function () use ($di) {
25 return new \Domain\Users\UsersGateway($di->get('database'));
26 });
27
28 $di->set('Domain\Articles\ArticleTransactions', function () use ($di) {
29 return new \Domain\Articles\ArticleTransactions(
30 $di->newInstance('Domain\Articles\ArticlesGateway'),
31 $di->newInstance('Domain\Users\UsersGateway'),
32 );
33 });
34
35 $di->set('Controller\ArticlesPage', function () use ($di) {
36 return new \Controller\ArticlesPage(
37 $di->get('request'),
38 $di->get('response'),
39 $di->user,
40 $di->newInstance('Domain\Articles\ArticleTransactions')
41 );
42 });
43 ?>

注意服务现在如何引用容器中的其他服务来构建自己的对象。当我们获得Controller\ArticlesPage服务对象的新实例时,它会寻址$di容器,以获得共享请求和响应对象、$user属性以及ArticleTransactions服务对象的新实例。反过来,它递归地寻址$di容器以获取该服务对象的依赖项,依此类推。

如果页面脚本中有 include 怎么办?

尽管我们已经尽了最大努力删除它们,但我们的页面脚本中仍可能有一些包含文件。当我们将页面脚本逻辑复制到容器中时,除了复制它们之外,我们别无选择。但是,一旦所有页面脚本都转换为容器,我们就可以寻找共性,并开始将包含逻辑提取到设置脚本或单独的类中(如果需要,这些类本身可以成为服务)。

我们可以减小 services.php 文件的大小吗?

根据应用程序中页面脚本的数量,我们的 DI 容器最终可能会有数十个或数百个服务定义。在一个文件中可以管理或扫描很多内容。

如果我们愿意,将容器拆分为多个文件,并进行一系列 include 调用以引入各种定义是完全合理的。

我们可以减少路由器服务的规模吗?

作为 DI 容器文件长度的子集,特别是router服务可能变得非常长。这是因为我们将应用程序中的每个 URL 映射到一个服务;如果有数百个 URL,那么将有数百条router行。

或者,我们可以创建一个单独的routes.php文件,让它返回一个路由数组。然后我们可以在setRoutes()调用中包含该文件:

includes/routes.php
1 <?php return array(
2 '/articles.php' => 'Controller\ArticlesPage',
3 ); ?>
includes/services.php
1 <?php
2 // ...
3 $di->set('router', function () use ($di) {
4 $router = new \Mlaphp\Router($di->pages_dir);
5 $router->setRoutes(include '/path/to/includes/routes.php');
6 return $router;
7 });
8 ?>

这至少会减少services.php文件的大小,即使它不会减少 routes 数组的大小。

如果我们不能更新到 PHP5.3 怎么办?

本章中的示例展示了使用闭包封装对象创建逻辑的 DI 容器。闭包只在 PHP5.3 中可用,所以如果我们停留在早期版本的 PHP 上,看起来使用 DI 容器根本不是一个选项。

事实证明并非如此。通过一些额外的努力和对不雅的容忍,我们仍然可以为 PHP5.2 和更早版本构建 DI 容器。

首先,我们需要扩展 DI 容器,以便向其添加方法。然后,我们不再将服务定义创建为闭包,而是将它们创建为扩展容器上的方法:

classes/Di.php
1 <?php
2 class Di extends \Mlaphp\Di
3 {
4 public function database()
5 {
6 return new \Database(
7 $this->db_host,
8 $this->db_user,
9 $this->db_pass
10 );
11 }
12 }
13 ?>

(注意我们如何在方法中使用$this而不是$di。)

然后,在我们的services.php文件中,可调用项成为此方法的引用,而不是内联闭包:

includes/services.php
1 <?php
2 $di->set('database', array($di, 'database'));
3 ?>

这很混乱,但可行。它也可能变得相当冗长。我们之前拆分Controller\ArticlesPage的示例最终看起来更像这样:

includes/services.php
1 <?php
2 // ...
3 $di->set('request', array($di, 'request'));
4 $di->set('response', array($di, 'response'));
5 $di->set('database', array($di, 'database'));
6 $di->set('Domain\Articles\ArticlesGateway', array($di, 'ArticlesGateway'));
7 $di->set('Domain\Users\UsersGateway', array($di, 'UsersGateway'));
8 $di->set(
9 'Domain\Articles\ArticleTransactions',
10 array($di, 'ArticleTransactions')
11 );
12 $di->set('Controller\ArticlesPage', array($di, 'ArticlesPage'));
13 ?>
classes/Di.php
1 <?php
2 class Di extends \Mlaphp\Di
3 {
4 public function request()
5 {
6 return new \Mlaphp\Request($GLOBALS);
7 }
8
9 public function response()
10 {
11 return new \Mlaphp\Response('/path/to/app/views');
12 }
13
14 public function database()
15 {
16 return new \Database(
17 $this->db_host,
18 $this->db_user,
19 $this->db_pass
20 );
21 }
22
23 public function ArticlesGateway()
24 {
25 return new \Domain\Articles\ArticlesGateway($this->get('database'));
26 }
27
28 public function UsersGateway()
29 {
30 return new \Domain\Users\UsersGateway($this->get('database'));
31 }
32
33 public function ArticleTransactions()
34 {
35 return new \Domain\Articles\ArticleTransactions(
36 $this->newInstance('ArticlesGateway'),
37 $this->newInstance('UsersGateway'),
38 );
39 }
40
41 public function ArticlesPage()
42 {
43 return new \Controller\ArticlesPage(
44 $this->get('request'),
45 $this->get('response'),
46 $this->user,
47 $this->newInstance('ArticleTransactions')
48 );
49 }
50 }
51 ?>

不幸的是,我们可能不得不打破一些样式约定,以使服务名称看起来像它们的相关方法名称。我们还必须将用于新实例的服务方法名称缩短为它们的结束类名,而不是它们的完全限定名。否则,我们会发现自己的方法名太长且容易混淆。

这可能很快让人困惑,但确实有效。总之,如果我们可以升级到 PHP5.3 或更高版本,那就更好了。

回顾和下一步

我们终于完成了现代化进程。我们不再有任何页面脚本。我们所有的应用程序逻辑都已转换为类,剩下的唯一包含文件是引导和设置过程的一部分。我们所有的对象创建逻辑都存在于一个容器中,在这个容器中,我们可以直接修改它,而不必干扰对象的内部。

在这之后,下一步可能是什么?答案是持续改进,这将持续你的职业生涯。