Tagi kontenera zależności we frameworku Symfony na przykładzie wielu implementacji interfejsu
W dzisiejszym artykule przedstawię praktyczny przykład zastosowania tagów w kontenerze zależności frameworka Symfony. Podczas pracy nad moim serwisem dzięki możliwościom frameworka mogłem w prosty sposób zbudować strukturę kodu potrzebną do eksportowania tego samego zbioru danych w wielu formatach.
Artykuł zawiera (uproszczone na potrzeby przykładów) fragmenty kodu RadioListy, otwartoźródłowego serwisu internetowego służącego do publikowania wykazów radiowych. Kod serwisu jest dostępny publicznie na GitHubie.
Dowiedz się więcej o serwisie RadioLista
Struktura klas
RadioLista umożliwia użytkownikom eksportowanie wykazów radiowych w wielu formatach takich jak strona HTML, plik CSV, arkusz XLSX, dokument PDF czy arkusz ODS.
W ramach refaktoryzacji oraz wprowadzania nowych formatów eksportu chciałem usunąć mechanikę generowania dokumentów z kontrolera, w którym dotychczas się znajdowała, i przenieść ją do oddzielnych usług.
Tak wygląda (w uproszczeniu) docelowa forma kontrolera, którą chciałem osiągnąć:
<?php namespace App\Controller; use App\Entity\RadioTable; use App\Export\RadioTableExporterProvider; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class RadioTableController { private $exporterProvider; public function __construct(RadioTableExporterProvider $exporterProvider) { $this->exporterProvider = $exporterProvider; } /** * @Route("/radio-table/{id}/download.{_format<csv|ods|xlsx|html|pdf>}") */ public function download(RadioTable $radioTable, string $_format): Response { $exporter = $this->exporterProvider->getExporterForFileExtension($_format); return new Response($exporter->render($radioTable)); } }
RadioTableExporterProvider
to prosty serwis, którego jedynym zadaniem jest gromadzenie wszystkich możliwych mechanizmów eksportu oraz zwrócenie jednego z nich w zależności od podanego rozszerzenia pliku: na przykład dla formatu html
powinna zostać zwrócona instancja klasy HtmlExporter
, a dla formatu csv
instancja klasy CsvExporter
.
Każdy $exporter
jest implementacją prostego interfejsu ExporterInterface
:
<?php namespace App\Export; use App\Entity\RadioTable; interface ExporterInterface { public function render(RadioTable $radioTable): string; }
Tak prezentuje się najprostsza przykładowa implementacja w postaci HtmlExporter
:
<?php namespace App\Export; use App\Entity\RadioTable; use Twig\Environment; class HtmlExporter implements ExporterInterface { private $twig; public function __construct(Environment $twig) { $this->twig = $twig; } public function render(RadioTable $radioTable): string { return $this->twig->render('radio_table.html.twig', [ 'radio_table' => $radioTable, 'radio_stations' => $radioTable->getRadioStations(), ]); } }
W taki sposób zrealizowałem klasę RadioTableExporterProvider
:
<?php namespace App\Export; class RadioTableExporterProvider { private $exporters; public function __construct(iterable $exporters) { $this->exporters = $exporters; } public function getExporterForExtension(string $extension): ExporterInterface { foreach ($this->exporters as $exporter) { $supportedExtension = strtolower( substr( strrchr(get_class($exporter), '\\'), 1, -1 * strlen('Exporter') ) ); if ($extension === $supportedExtension) { return $exporter; } } throw new \RuntimeException(sprintf( 'Cannot find radio table exporter for "%s" file type.', $extension )); } }
Metoda getExporterForExtension()
odnajduje właściwy obiekt na podstawie nazwy klasy.
Wyszukiwanie właściwiej implementacji można wykonać również w sposób bardziej elastyczny, dodając do interfejsu ExporterInterface
metodę supports()
, dzięki której każdy mechanizm eksportu mógłby sam zdecydować o tym, czy potrafi obsłużyć określony format pliku.
W RadioLiście nie ma takiej potrzeby, dlatego wybrałem przedstawiony wyżej prymitywny sposób, który przy okazji wymusza jednolitą konwencję nazewniczą klas.
Konfiguracja frameworka
W jaki sposób zapewnić dostarczenie do konstruktora klasy RadioTableExporterProvider
kolekcji wszystkich implementacji interfejsu ExporterInterface
?
Najprostszym sposobem jest dodanie tego samego taga (na przykład app.radio_table_exporter
) do konfiguracji każdej usługi będącej mechanizmem eksportu oraz wykorzystanie właściwości !tagged_iterator
wraz z nazwą taga w konfiguracji argumentów konstruktora klasy RadioTableExporterProvider
.
Wystarczy kilka poniższych linii w pliku services.yaml
:
services: App\Export\RadioTableExporterProvider: arguments: $exporters: !tagged_iterator app.radio_table_exporter App\Export\CsvExporter: tags: ['app.radio_table_exporter'] App\Export\HtmlExporter: tags: ['app.radio_table_exporter'] App\Export\OdsExporter: tags: ['app.radio_table_exporter'] App\Export\PdfExporter: tags: ['app.radio_table_exporter'] App\Export\XlsxExporter: tags: ['app.radio_table_exporter']
Powyższa konfiguracja sprawi, że konstruktor klasy RadioTableExporterProvider
jako argument otrzyma iterowalny obiekt przechowujący wszystkie implementacje ExporterInterface
.
Istnieje jednakże jeszcze prostszy sposób, który zapewnia większą elastyczność i bardziej samoobsługowy DX. Wystarczy zaprzęgnąć do pracy mechanikę kontenera zależności Symfony w postaci operatora _instanceof
i wprowadzić następującą zmianę:
services: App\Export\RadioTableExporterProvider: arguments: $exporters: !tagged_iterator app.radio_table_exporter _instanceof: App\Export\ExporterInterface: tags: ['app.radio_table_exporter']
Teraz dodawanie nowych implementacji interfejsu ExporterInterface
będzie jeszcze prostsze, bo nie będzie wymagana żadna zmiana konfiguracji — framework sam wykryje nowe klasy.
Komentarze (1)
Piotr
Cześć. Bardzo fajny artykuł. Dzięki! :)
Dodaj komentarz