Tagi kontenera zależności we frameworku Symfony na przykładzie wielu implementacji interfejsu

Kategoria: Webmastering Data publikacji:

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;
    }

    
    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.

Dodaj komentarz