Injeksi Ketergantungan Sejati dengan Komponen Symfony
Diterbitkan: 2022-03-11Symfony2, kerangka kerja PHP berkinerja tinggi, menggunakan pola Kontainer Injeksi Ketergantungan di mana komponen menyediakan antarmuka injeksi ketergantungan untuk penampung DI. Ini memungkinkan setiap komponen untuk tidak peduli dengan dependensi lainnya. Kelas 'Kernel' menginisialisasi wadah DI dan menyuntikkannya ke dalam komponen yang berbeda. Tapi ini berarti DI-container dapat digunakan sebagai Service Locator.
Symfony2 bahkan memiliki kelas 'ContainerAware' untuk itu. Banyak yang berpendapat bahwa Service Locator adalah anti-pola di Symfony2. Secara pribadi, saya tidak setuju. Ini adalah pola yang lebih sederhana dibandingkan dengan DI dan bagus untuk proyek sederhana. Tetapi pola Service Locator dan pola DI-container yang digabungkan dalam satu proyek jelas merupakan anti-pola.
Pada artikel ini kami akan mencoba membangun aplikasi Symfony2 tanpa menerapkan pola Service Locator. Kami akan mengikuti satu aturan sederhana: hanya pembuat DI-container yang dapat mengetahui tentang DI-container.
DI-wadah
Dalam pola Injeksi Ketergantungan, wadah DI menentukan dependensi layanan dan layanan hanya dapat memberikan antarmuka untuk injeksi. Ada banyak artikel tentang Injeksi Ketergantungan, dan Anda mungkin telah membaca semuanya. Jadi jangan fokus pada teori dan lihat saja ide dasarnya. DI dapat terdiri dari 3 jenis:
Di Symfony, struktur injeksi dapat didefinisikan menggunakan file konfigurasi sederhana. Berikut cara konfigurasi 3 jenis injeksi ini:
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"
Proyek Bootstrap
Mari kita buat struktur aplikasi dasar kita. Sementara kita melakukannya, kita akan menginstal komponen Symfony DI-container.
$ 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
Untuk membuat composer autoloader menemukan kelas kita sendiri di folder src, kita dapat menambahkan properti 'autoloader' di file composer.json:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
Dan mari kita buat pembuat kontainer kita dan melarang injeksi kontainer.
// 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); } }
Di sini kita menggunakan Config dan komponen symfony Yaml. Anda dapat menemukan detailnya dalam dokumentasi resmi di sini. Kami juga mendefinisikan parameter jalur root 'app_root' untuk berjaga-jaga. Metode get membebani perilaku get default kelas induk dan mencegah container mengembalikan "service_container".
Selanjutnya, kita membutuhkan titik masuk untuk aplikasi.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
Yang ini dimaksudkan untuk menangani permintaan http. Kami dapat memiliki lebih banyak titik masuk untuk perintah konsol, tugas cron, dan lainnya. Setiap titik masuk seharusnya mendapatkan layanan tertentu dan harus tahu tentang struktur wadah DI. Ini adalah satu-satunya tempat di mana kami dapat meminta layanan dari wadah. Mulai saat ini kami akan mencoba membangun aplikasi ini hanya menggunakan file konfigurasi DI-container.
HttpKernel
HttpKernel (bukan kernel kerangka kerja dengan masalah pencari layanan) akan menjadi komponen dasar kami untuk bagian web aplikasi. Berikut adalah alur kerja HttpKernel yang khas:
Kotak hijau adalah peristiwa.
HttpKernel menggunakan komponen HttpFoundation untuk objek Request and Response dan komponen EventDispatcher untuk sistem event. Tidak ada masalah dalam menginisialisasi mereka dengan file konfigurasi DI-container. HttpKernel harus diinisialisasi dengan EventDispatcher, ControllerResolver, dan opsional dengan layanan RequestStack (untuk sub-permintaan).
Berikut adalah konfigurasi wadah untuk itu:
# 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' }
Seperti yang Anda lihat, kami menggunakan properti 'pabrik' untuk membuat layanan permintaan. Layanan HttpKernel hanya mendapatkan objek Permintaan dan mengembalikan objek Respon. Itu bisa dilakukan di pengontrol depan.
// 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();
Atau respons dapat didefinisikan sebagai layanan dalam konfigurasi dengan menggunakan properti 'pabrik'.
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
Dan kemudian kami hanya mendapatkannya di pengontrol depan.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
Layanan penyelesai pengontrol mendapatkan properti '_controller' dari atribut layanan Permintaan untuk menyelesaikan pengontrol. Atribut ini dapat didefinisikan dalam konfigurasi kontainer, tetapi terlihat sedikit lebih rumit karena kita harus menggunakan objek ParameterBag daripada array sederhana.
# 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" # ...
Dan inilah kelas DefaultController dengan metode defaultAction.
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Response; class DefaultController { function defaultAction() { return new Response("Hello cruel world"); } }
Dengan semua ini di tempat, kita harus memiliki aplikasi yang berfungsi.
Kontroler ini sangat tidak berguna karena tidak memiliki akses ke layanan apa pun. Dalam kerangka Symfony, masalah ini diselesaikan dengan menyuntikkan wadah DI dalam pengontrol dan menggunakannya sebagai pencari layanan. Kami tidak akan melakukan itu. Jadi mari kita definisikan controller sebagai layanan dan menyuntikkan layanan permintaan di dalamnya. Berikut konfigurasinya:
# 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' }
Dan kode pengontrol:
// 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"); } }
Sekarang pengontrol memiliki akses ke layanan permintaan. Seperti yang Anda lihat, skema ini memiliki dependensi melingkar. Ia bekerja karena DI-container berbagi layanan setelah pembuatan dan sebelum metode dan injeksi properti. Jadi ketika layanan pengontrol dibuat, layanan permintaan sudah ada.
Berikut cara kerjanya:
Tetapi ini hanya berfungsi karena layanan permintaan dibuat terlebih dahulu. Ketika kami mendapatkan layanan respons di pengontrol depan, layanan permintaan adalah dependensi yang diinisialisasi pertama. Jika kami mencoba mendapatkan layanan pengontrol terlebih dahulu, itu akan menyebabkan kesalahan ketergantungan melingkar. Ini dapat diperbaiki dengan menggunakan metode atau injeksi properti.
Tapi ada masalah lain. DI-container akan menginisialisasi setiap pengontrol dengan dependensi. Jadi itu akan menginisialisasi semua layanan yang ada bahkan jika itu tidak diperlukan. Untungnya, container memiliki fungsi lazy loading. Komponen Symfony DI menggunakan 'ocramius/proxy-manager' untuk kelas proxy. Kita harus memasang jembatan di antara mereka.
$ composer require symfony/proxy-manager-bridge
Dan tentukan pada tahap pembuatan kontainer:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Sekarang kita dapat mendefinisikan layanan malas.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
Jadi pengontrol akan menyebabkan inisialisasi layanan yang bergantung hanya ketika metode aktual dipanggil. Selain itu, ini menghindari kesalahan ketergantungan melingkar karena layanan pengontrol akan dibagikan sebelum inisialisasi yang sebenarnya; meskipun kita masih harus menghindari referensi melingkar. Dalam hal ini kita tidak boleh menyuntikkan layanan pengontrol di layanan permintaan atau layanan permintaan ke dalam layanan pengontrol. Jelas kita membutuhkan layanan permintaan di pengontrol, jadi mari kita hindari injeksi dalam layanan permintaan pada tahap inisiasi kontainer. HttpKernel memiliki sistem acara untuk tujuan ini.

Rute
Rupanya kami ingin memiliki pengontrol yang berbeda untuk permintaan yang berbeda. Jadi kita membutuhkan sistem routing. Mari kita instal komponen perutean symfony.
$ composer require symfony/routing
Komponen routing memiliki class Router yang dapat menggunakan file konfigurasi routing. Tetapi konfigurasi ini hanyalah parameter nilai kunci untuk kelas Route. Kerangka kerja Symfony menggunakan penyelesai pengontrolnya sendiri dari FrameworkBundle yang menyuntikkan wadah ke pengontrol dengan antarmuka 'ContainerAware'. Inilah tepatnya yang kami coba hindari. Resolver pengontrol HttpKernel mengembalikan objek kelas seolah-olah sudah ada di atribut '_controller' sebagai array dengan objek pengontrol dan string metode tindakan (sebenarnya, resolver pengontrol akan mengembalikannya seolah-olah itu hanya array). Jadi kita harus mendefinisikan setiap rute sebagai layanan dan menyuntikkan pengontrol di dalamnya. Mari tambahkan beberapa layanan pengontrol lain untuk melihat cara kerjanya.
# 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"); } }
Komponen HttpKernel memiliki kelas RouteListener yang menggunakan event 'kernel.request'. Berikut adalah satu kemungkinan konfigurasi dengan pengontrol malas:
# 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' }
Kami juga membutuhkan generator URL di aplikasi kami. Ini dia:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
Generator URL dapat disuntikkan ke pengontrol dan layanan rendering. Sekarang kami memiliki aplikasi dasar. Layanan lain apa pun dapat didefinisikan dengan cara yang sama seperti file konfigurasi disuntikkan ke pengontrol atau operator acara tertentu. Sebagai contoh, berikut adalah beberapa konfigurasi untuk Twig dan Doctrine.
Ranting
Twig adalah mesin templat default dalam kerangka kerja Symfony2. Banyak komponen Symfony2 dapat menggunakannya tanpa adaptor apa pun. Jadi ini adalah pilihan yang jelas untuk aplikasi kita.
$ 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" ]
Doktrin
Doctrine adalah ORM yang digunakan dalam framework Symfony2. Kita bisa menggunakan ORM lain, tapi komponen Symfony2 sudah bisa menggunakan banyak fitur 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' }
Kami juga dapat menggunakan file konfigurasi pemetaan YML dan XML sebagai ganti anotasi. Kita hanya perlu menggunakan metode 'createYAMLMetadataConfiguration' dan 'createXMLMetadataConfiguration' dan mengatur jalur ke folder dengan file konfigurasi ini.
Ini dapat dengan cepat menjadi sangat menjengkelkan untuk menyuntikkan setiap layanan yang dibutuhkan di setiap pengontrol secara individual. Untuk membuatnya sedikit lebih baik, komponen DI-container memiliki layanan abstrak dan pewarisan layanan. Jadi kita dapat mendefinisikan beberapa pengontrol abstrak:
# 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 { // ... }
Ada banyak komponen Symfony berguna lainnya seperti Form, Command, dan Assets. Mereka dikembangkan sebagai komponen independen sehingga integrasi mereka menggunakan DI-container seharusnya tidak menjadi masalah.
Tag
DI-container juga memiliki sistem tag. Tag dapat diproses oleh kelas Compiler Pass. Komponen Event Dispatcher memiliki Compiler Pass sendiri untuk menyederhanakan langganan event listener, tetapi menggunakan kelas ContainerAwareEventDispatcher alih-alih kelas EventDispatcher. Jadi kita tidak bisa menggunakannya. Tetapi kita dapat mengimplementasikan pass kompiler kita sendiri untuk event, rute, keamanan, dan tujuan lainnya.
Misalnya, mari kita terapkan tag untuk sistem perutean. Sekarang untuk menentukan rute kita harus mendefinisikan layanan rute dalam file konfigurasi rute di folder config/routes dan kemudian menambahkannya ke layanan pengumpulan rute di file config/routing.yml. Tampaknya tidak konsisten karena kami mendefinisikan parameter router di satu tempat dan nama router di tempat lain.
Dengan sistem tag, kita cukup mendefinisikan nama rute dalam sebuah tag dan menambahkan layanan rute ini ke kumpulan rute menggunakan nama tag.
Komponen DI-container menggunakan class pass compiler untuk membuat modifikasi apa pun pada konfigurasi container sebelum inisialisasi yang sebenarnya. Jadi mari kita implementasikan kelas pass compiler kita untuk sistem tag router.
// 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()); // ...
Sekarang kita dapat memodifikasi konfigurasi kita:
# 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' }
Seperti yang Anda lihat, kami mendapatkan koleksi rute dengan nama tag alih-alih nama layanan, jadi sistem tag rute kami tidak bergantung pada konfigurasi sebenarnya. Juga, rute dapat ditambahkan ke layanan pengumpulan apa pun dengan metode 'tambah'. Pelewat kompiler dapat menyederhanakan konfigurasi dependensi secara signifikan. Tetapi mereka dapat menambahkan perilaku tak terduga ke wadah DI, jadi lebih baik untuk tidak mengubah logika yang ada seperti mengubah argumen, pemanggilan metode, atau nama kelas. Cukup tambahkan yang baru di atas yang ada seperti yang kami lakukan dengan menggunakan tag.
Bungkus
Kami sekarang memiliki aplikasi yang hanya menggunakan pola wadah DI, dan itu dibangun hanya menggunakan file konfigurasi wadah-DI. Seperti yang Anda lihat, tidak ada tantangan serius dalam membangun aplikasi Symfony dengan cara ini. Dan Anda cukup memvisualisasikan semua dependensi aplikasi Anda. Satu-satunya alasan mengapa orang menggunakan DI-container sebagai pencari lokasi layanan adalah karena konsep pencari lokasi layanan lebih mudah dipahami. Dan basis kode besar dengan wadah DI yang digunakan sebagai pencari layanan mungkin merupakan konsekuensi dari alasan itu.
Anda dapat menemukan kode sumber aplikasi ini di GitHub.