Symfony 组件的真正依赖注入
已发表: 2022-03-11Symfony2 是一个高性能 PHP 框架,它使用依赖注入容器模式,其中组件为 DI 容器提供依赖注入接口。 这允许每个组件不关心其他依赖项。 “内核”类初始化 DI 容器并将其注入到不同的组件中。 但这意味着 DI 容器可以用作服务定位器。
Symfony2 甚至为此提供了 'ContainerAware' 类。 许多人认为服务定位器是 Symfony2 中的反模式。 就个人而言,我不同意。 与 DI 相比,它是一种更简单的模式,适用于简单的项目。 但是Service Locator模式和DI-container模式结合在一个项目中绝对是一种反模式。
在本文中,我们将尝试在不实现服务定位器模式的情况下构建 Symfony2 应用程序。 我们将遵循一个简单的规则:只有 DI-container builder 才能知道 DI-container。
DI容器
在依赖注入模式中,DI-container定义了服务依赖,服务只能提供一个接口进行注入。 有很多关于依赖注入的文章,你可能都读过。 所以我们不关注理论,只看基本思想。 DI可以有3种类型:
在 Symfony 中,可以使用简单的配置文件来定义注入结构。 以下是如何配置这 3 种注入类型:
services: my_service: class: MyClass constructor_injection_service: class: SomeClass1 arguments: ["@my_service"] method_injection_service: class: SomeClass2 calls: - [ setProperty, "@my_service" ] property_injection_service: class: SomeClass3 properties: property: "@my_service"
自举项目
让我们创建我们的基础应用程序结构。 在此过程中,我们将安装 Symfony DI 容器组件。
$ mkdir trueDI $ cd trueDI $ composer init $ composer require symfony/dependency-injection $ composer require symfony/config $ composer require symfony/yaml $ mkdir config $ mkdir www $ mkdir src
为了让 composer autoloader 在 src 文件夹中找到我们自己的类,我们可以在 composer.json 文件中添加 'autoloader' 属性:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
让我们创建我们的容器构建器并禁止容器注入。
// in src/TrueContainer.php use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\ContainerInterface; class TrueContainer extends ContainerBuilder { public static function buildContainer($rootPath) { $container = new self(); $container->setParameter('app_root', $rootPath); $loader = new YamlFileLoader( $container, new FileLocator($rootPath . '/config') ); $loader->load('services.yml'); $container->compile(); return $container; } public function get( $id, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE ) { if (strtolower($id) == 'service_container') { if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE !== $invalidBehavior ) { return; } throw new InvalidArgumentException( 'The service definition "service_container" does not exist.' ); } return parent::get($id, $invalidBehavior); } }
这里我们使用 Config 和 Yaml symfony 组件。 您可以在此处的官方文档中找到详细信息。 我们还定义了根路径参数“app_root”以防万一。 get 方法重载了父类的默认 get 行为并阻止容器返回“service_container”。
接下来,我们需要应用程序的入口点。
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
这个是用来处理http请求的。 我们可以为控制台命令、cron 任务等提供更多入口点。 每个入口点都应该获得某些服务,并且应该了解 DI 容器结构。 这是我们唯一可以从容器请求服务的地方。 从现在开始,我们将尝试仅使用 DI 容器配置文件来构建此应用程序。
Http内核
HttpKernel(不是存在服务定位器问题的框架内核)将是我们应用程序 Web 部分的基础组件。 这是一个典型的 HttpKernel 工作流程:
绿色方块是事件。
HttpKernel 对 Request 和 Response 对象使用 HttpFoundation 组件,对事件系统使用 EventDispatcher 组件。 使用 DI 容器配置文件初始化它们没有问题。 HttpKernel 必须使用 EventDispatcher、ControllerResolver 和可选的 RequestStack(用于子请求)服务进行初始化。
这是它的容器配置:
# in config/events.yml services: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher
# in config/kernel.yml services: request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] request_stack: class: Symfony\Component\HttpFoundation\RequestStack resolver: class: Symfony\Component\HttpKernel\Controller\ControllerResolver http_kernel: class: Symfony\Component\HttpKernel\HttpKernel arguments: ["@dispatcher", "@resolver", "@request_stack"]
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' }
如您所见,我们使用“工厂”属性来创建请求服务。 HttpKernel 服务只获取 Request 对象并返回 Response 对象。 它可以在前端控制器中完成。
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $HTTPKernel = $container->get('http_kernel'); $request = $container->get('request'); $response = $HTTPKernel->handle($request); $response->send();
或者可以使用“工厂”属性将响应定义为配置中的服务。
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
然后我们把它放在前端控制器中。
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
控制器解析器服务从请求服务的属性中获取“_controller”属性以解析控制器。 这些属性可以在容器配置中定义,但看起来有点棘手,因为我们必须使用 ParameterBag 对象而不是简单的数组。
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, \App\Controller\DefaultController::defaultAction ]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
这是具有 defaultAction 方法的 DefaultController 类。
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Response; class DefaultController { function defaultAction() { return new Response("Hello cruel world"); } }
有了所有这些,我们应该有一个工作应用程序。
这个控制器非常没用,因为它无法访问任何服务。 在 Symfony 框架中,通过在控制器中注入 DI 容器并将其用作服务定位器来解决此问题。 我们不会那样做。 所以让我们将控制器定义为服务,并在其中注入请求服务。 这是配置:
# in config/controllers.yml services: controller.default: class: App\Controller\DefaultController arguments: [ "@request"]
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, ["@controller.default", defaultAction ]]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' }
和控制器代码:
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class DefaultController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction() { $name = $this->request->get('name'); return new Response("Hello $name"); } }
现在控制器可以访问请求服务。 如您所见,该方案具有循环依赖关系。 它之所以有效,是因为 DI 容器在创建之后和方法和属性注入之前共享服务。 所以在创建控制器服务时,请求服务已经存在。
以下是它的工作原理:
但这只是因为首先创建了请求服务。 当我们在前端控制器中获取响应服务时,请求服务是第一个初始化的依赖。 如果我们先尝试获取控制器服务,会导致循环依赖错误。 它可以通过使用方法或属性注入来修复。
但是还有另一个问题。 DI 容器将使用依赖项初始化每个控制器。 因此,即使不需要它们,它也会初始化所有现有的服务。 幸运的是,容器具有延迟加载功能。 Symfony DI 组件使用 'ocramius/proxy-manager' 作为代理类。 我们必须在它们之间架起一座桥梁。
$ composer require symfony/proxy-manager-bridge
并在容器构建阶段定义:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
现在我们可以定义惰性服务了。
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
因此,只有在调用实际方法时,控制器才会初始化依赖的服务。 此外,它避免了循环依赖错误,因为控制器服务将在实际初始化之前共享; 尽管我们仍然必须避免循环引用。 在这种情况下,我们不应该将控制器服务注入到请求服务中或将请求服务注入到控制器服务中。 显然我们需要控制器中的请求服务,所以让我们避免在容器启动阶段对请求服务进行注入。 HttpKernel 有为此目的的事件系统。
路由
显然我们希望为不同的请求使用不同的控制器。 所以我们需要一个路由系统。 让我们安装 symfony 路由组件。
$ composer require symfony/routing
路由组件具有可以使用路由配置文件的类 Router。 但是这些配置只是 Route 类的键值参数。 Symfony 框架使用它自己的来自 FrameworkBundle 的控制器解析器,它通过 'ContainerAware' 接口在控制器中注入容器。 这正是我们试图避免的。 HttpKernel 控制器解析器按原样返回类对象,如果它已经存在于“_controller”属性中,作为带有控制器对象和操作方法字符串的数组(实际上,控制器解析器将按原样返回它,如果它只是一个数组)。 所以我们必须将每条路由定义为一个服务,并在其中注入一个控制器。 让我们添加一些其他的控制器服务来看看它是如何工作的。

# in config/controllers.yml # ... controller.page: lazy: true class: App\Controller\PageController arguments: [ "@request"]
// in src/App/Controller/PageController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class PageController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction($id) { return new Response("Page $id doesn't exist"); } }
HttpKernel 组件具有使用“kernel.request”事件的 RouteListener 类。 这是惰性控制器的一种可能配置:
# in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction']
# in config/routing.yml imports: - { resource: 'routes/default.yml' } services: route.collection: class: Symfony\Component\Routing\RouteCollection calls: - [ add, ["route_home", "@route.home"] ] - [ add, ["route_page", "@route.page"] ] router.request_context: class: Symfony\Component\Routing\RequestContext calls: - [ fromRequest, ["@request"] ] router.matcher: class: Symfony\Component\Routing\Matcher\UrlMatcher arguments: [ "@route.collection", "@router.request_context" ] router.listener: class: Symfony\Component\HttpKernel\EventListener\RouterListener arguments: matcher: "@router.matcher" request_stack: "@request_stack" context: "@router.request_context"
# in config/events.yml service: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher calls: - [ addSubscriber, ["@router.listener"]]
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' }
此外,我们的应用程序中还需要一个 URL 生成器。 这里是:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
URL 生成器可以注入到控制器和渲染服务中。 现在我们有了一个基础应用程序。 任何其他服务都可以以与将配置文件注入某些控制器或事件调度程序相同的方式定义。 例如,这里有一些 Twig 和 Doctrine 的配置。
枝条
Twig 是 Symfony2 框架中的默认模板引擎。 许多 Symfony2 组件可以在没有任何适配器的情况下使用它。 因此,对于我们的应用程序来说,这是一个显而易见的选择。
$ composer require twig/twig $ mkdir src/App/View
# in config/twig.yml services: templating.twig_loader: class: Twig_Loader_Filesystem arguments: [ "%app_root%/src/App/View" ] templating.twig: class: Twig_Environment arguments: [ "@templating.twig_loader" ]
教义
Doctrine 是 Symfony2 框架中使用的 ORM。 我们可以使用任何其他 ORM,但 Symfony2 组件已经可以使用许多 Docrine 特性。
$ composer require doctrine/orm $ mkdir src/App/Entity
# in config/doctrine.yml parameters: doctrine.driver: "pdo_pgsql" doctrine.user: "postgres" doctrine.password: "postgres" doctrine.dbname: "true_di" doctrine.paths: ["%app_root%/src/App/Entity"] doctrine.is_dev: true services: doctrine.config: class: Doctrine\ORM\Configuration factory: [ Doctrine\ORM\Tools\Setup, createAnnotationMetadataConfiguration ] arguments: paths: "%doctrine.paths%" isDevMode: "%doctrine.is_dev%" doctrine.entity_manager: class: Doctrine\ORM\EntityManager factory: [ Doctrine\ORM\EntityManager, create ] arguments: conn: driver: "%doctrine.driver%" user: "%doctrine.user%" password: "%doctrine.password%" dbname: "%doctrine.dbname%" config: "@doctrine.config"
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' } - { resource: 'twig.yml' } - { resource: 'doctrine.yml' }
我们也可以使用 YML 和 XML 映射配置文件来代替注解。 我们只需要使用 'createYAMLMetadataConfiguration' 和 'createXMLMetadataConfiguration' 方法并使用这些配置文件设置文件夹的路径。
在每个控制器中单独注入每个需要的服务很快就会变得非常烦人。 为了使它更好一点,DI容器组件具有抽象服务和服务继承。 所以我们可以定义一些抽象的控制器:
# in config/controllers.yml services: controller.base_web: lazy: true abstract: true class: App\Controller\Base\WebController arguments: request: "@request" templating: "@templating.twig" entityManager: "@doctrine.entity_manager" urlGenerator: "@router.generator" controller.default: class: App\Controller\DefaultController parent: controller.base_web controller.page: class: App\Controller\PageController parent: controller.base_web
// in src/App/Controller/Base/WebController.php namespace App\Controller\Base; use Symfony\Component\HttpFoundation\Request; use Twig_Environment; use Doctrine\ORM\EntityManager; use Symfony\Component\Routing\Generator\UrlGenerator; abstract class WebController { /** @var Request */ protected $request; /** @var Twig_Environment */ protected $templating; /** @var EntityManager */ protected $entityManager; /** @var UrlGenerator */ protected $urlGenerator; function __construct( Request $request, Twig_Environment $templating, EntityManager $entityManager, UrlGenerator $urlGenerator ) { $this->request = $request; $this->templating = $templating; $this->entityManager = $entityManager; $this->urlGenerator = $urlGenerator; } } // in src/App/Controller/DefaultController // … class DefaultController extend WebController { // ... } // in src/App/Controller/PageController // … class PageController extend WebController { // ... }
还有许多其他有用的 Symfony 组件,例如 Form、Command 和 Assets。 它们是作为独立组件开发的,因此使用 DI 容器进行集成应该不成问题。
标签
DI-container 也有一个标签系统。 标签可以由编译器通道类处理。 Event Dispatcher 组件有自己的 Compiler Pass 来简化事件侦听器订阅,但它使用 ContainerAwareEventDispatcher 类而不是 EventDispatcher 类。 所以我们不能使用它。 但是我们可以为事件、路由、安全和任何其他目的实现我们自己的编译器传递。
例如,让我们为路由系统实现标签。 现在要定义路由,我们必须在 config/routes 文件夹中的路由配置文件中定义路由服务,然后将其添加到 config/routing.yml 文件中的路由收集服务中。 它看起来不一致,因为我们在一个地方定义路由器参数而在另一个地方定义路由器名称。
使用标签系统,我们可以在标签中定义一个路由名称,并使用标签名称将此路由服务添加到路由集合中。
DI 容器组件使用编译器传递类在实际初始化之前对容器配置进行任何修改。 因此,让我们为路由器标签系统实现我们的编译器传递类。
// in src/CompilerPass/RouterTagCompilerPass.php namespace CompilerPass; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; class RouterTagCompilerPass implements CompilerPassInterface { /** * You can modify the container here before it is dumped to PHP code. * * @param ContainerBuilder $container */ public function process(ContainerBuilder $container) { $routeTags = $container->findTaggedServiceIds('route'); $collectionTags = $container->findTaggedServiceIds('route_collection'); /** @var Definition[] $routeCollections */ $routeCollections = array(); foreach ($collectionTags as $serviceName => $tagData) $routeCollections[] = $container->getDefinition($serviceName); foreach ($routeTags as $routeServiceName => $tagData) { $routeNames = array(); foreach ($tagData as $tag) if (isset($tag['route_name'])) $routeNames[] = $tag['route_name']; if (!$routeNames) continue; $routeReference = new Reference($routeServiceName); foreach ($routeCollections as $collection) foreach ($routeNames as $name) $collection->addMethodCall('add', array($name, $routeReference)); } } }
// in src/TrueContainer.php //... use CompilerPass\RouterTagCompilerPass; // ... $container = new self(); $container->addCompilerPass(new RouterTagCompilerPass()); // ...
现在我们可以修改我们的配置:
# in config/routing.yml # … route.collection: class: Symfony\Component\Routing\RouteCollection tags: - { name: route_collection } # ...
# in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] tags: - { name: route, route_name: 'route_home' } route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction'] tags: - { name: route, route_name: 'route_page' }
如您所见,我们通过标签名称而不是服务名称获取路由集合,因此我们的路由标签系统不依赖于实际配置。 此外,可以使用“添加”方法将路由添加到任何收集服务。 编译器传递器可以显着简化依赖项的配置。 但它们可能会向 DI 容器添加意外行为,因此最好不要修改现有逻辑,例如更改参数、方法调用或类名。 只需像我们使用标签一样添加一个新的。
包起来
我们现在有一个仅使用 DI 容器模式的应用程序,并且它是仅使用 DI 容器配置文件构建的。 如您所见,以这种方式构建 Symfony 应用程序没有严重的挑战。 您可以简单地可视化所有应用程序依赖项。 人们使用 DI 容器作为服务定位器的唯一原因是服务定位器的概念更容易理解。 使用 DI 容器作为服务定位器的庞大代码库可能是这个原因的结果。
您可以在 GitHub 上找到此应用程序的源代码。