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.
Aktualizacja 2025: w aplikacjach opartych o PHP 8 i framework Symfony 5.3 lub nowszy zamiast pokazanej wyżej modyfikacji pliku services.yaml można użyć metody opartej o atrybuty PHP.
Dzięki takiemu rozwiązaniu konfigurację można zadeklarować bezpośrednio w plikach klas. Wystarczy użyć atrybutów TaggedIterator i AutoconfigureTag.
Deklaracja przypisywania taga Symfony w interfejsie ExporterInterface:
<?php
namespace App\Export;
use App\Entity\RadioTable;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.radio_table_exporter')]
interface ExporterInterface
{
public function render(RadioTable $radioTable): string;
}
Konfiguracja wstrzyknięcia wszystkich implementacji do klasy RadioTableExporterProvider:
<?php
namespace App\Export;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
class RadioTableExporterProvider
{
private $exporters;
public function __construct(
#[TaggedIterator('app.radio_table_exporter')] iterable $exporters
) {
$this->exporters = $exporters;
}
// ...
}
Komentarze (1)
Piotr
Cześć. Bardzo fajny artykuł. Dzięki! :)
Dodaj komentarz