Symfony 組件的真正依賴注入

已發表: 2022-03-11

Symfony2 是一個高性能 PHP 框架,它使用依賴注入容器模式,其中組件為 DI 容器提供依賴注入接口。 這允許每個組件不關心其他依賴項。 “內核”類初始化 DI 容器並將其註入到不同的組件中。 但這意味著 DI 容器可以用作服務定位器。

Symfony2 甚至為此提供了 'ContainerAware' 類。 許多人認為服務定位器是 Symfony2 中的反模式。 就個人而言,我不同意。 與 DI 相比,它是一種更簡單的模式,適用於簡單的項目。 但是Service Locator模式和DI-container模式結合在一個項目中絕對是一種反模式。

Symfony 組件的真正依賴注入

在本文中,我們將嘗試在不實現服務定位器模式的情況下構建 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 上找到此應用程序的源代碼。