Elastyczne fabryki
Wzorzec kreacyjny fabryki jest jednym z najczęściej używanych, a zarazem najłatwiejszych w implementacji ze wzorców projektowych - stąd jego duża popularność. Przedstawię dzisiaj metodykę elastycznego użycia go w Laravel'u oraz Symfony.
Wzorzec fabryki - nazywany bardziej poprawnie metodą wytwórczą - jest stosowany do rozwiązania problemu, kiedy musimy utworzyć (lub też wybrać) obiekt, który w zależności od danego rodzaju/typu ma mieć inną funkcjonalność. Tak bardzo ogólnie to ujmując oczywiście. Łatwiej zdecydowanie pokazać - dlatego odsyłam do refactoring.guru gdzie wszystko zostało w sposób przejrzysty przedstawione i ładnie wyśnione :)
Sama implementacja w czystym PHP nie jest problematyczna - trudniej trochę to zrobić w sposób elastyczny przy wykorzystaniu service container'ów framework'ów - ale o tym poniżej :)
Implementacja w Laravel
Tutaj pozwolę sobie posłużyć swoim przykładem zaimplementowanym w moim projekcie prostej aplikacji SPA KosmCODE Core.
Dla lepszego zrozumienia zrobię małe wprowadzenie 'fabularne' do problematyki. Jako, że chciałem aby strona była bardziej SEO frendly, to zdecydowałem się zrobić elastyczny system slug'ów do renderowania danego typu treści, który jest wystawiany na route'cie GET /{$slug}
. Sam slug
jest przechowywany w modelu o tej samej nazwie oraz jest relacją polimorficzną do innych modeli (contentowych) - takich jak:
Article
- który to reprezentuje pojedyńczy Artykuł na stronie (tak, tak, ten co obecnie czytasz chociażby :) )Page
- reprezentacja pojedyńczej strony statycznej.
Sama fabryka służy do renderowania treści z tych dwóch typów. Zastosowanie w tym wypadku polimorfizmu na relacji oraz elastyczności tego wzorca powoduje, że do-implementowanie kolejnego, nowego typu content'owego będzie niezmiernie łatwe i zgrabne :)
Tyle z teorii - czas na kod!
Sama fabryka SluggableFactory
wygląda następująco:
<?php
namespace App\Services\Slug\Factory;
use App\Models\Slug;
use App\Services\Slug\Factory\Factories\SluggableInterface;
use Illuminate\Contracts\Foundation\Application;
/**
* Sluggable Factory
*/
class SluggableFactory implements SluggableFactoryInterface
{
/**
* @param iterable<int, class-string> $factories
*/
public function __construct(
protected readonly iterable $factories,
protected readonly Application $application,
) {
}
/**
* {@inheritDoc}
*/
public function getBySlug(Slug $slug): ?SluggableInterface
{
foreach ($this->factories as $factory) {
if ($slug->getContentableType() !== $factory::supportedModel()) {
continue;
}
return $this->application->make($factory);
}
return null;
}
}
W konstruktorze są wstrzykiwane:
$factories
- array przechowujący className'y fabryczek (Page, Article)$application
- główna instancja service'u Laravel'a
Poszczególne fabryczki implementują ten poniższy interfejs:
<?php
namespace App\Services\Slug\Factory\Factories;
use App\Dto\ResponseDTO;
use App\Models\Slug;
/**
* Interface of Slug Factory
*/
interface SluggableInterface
{
public static function supportedModel(): string;
/**
* Method getResponseDTOBySlug
*/
public function getResponseDTOBySlug(Slug $slug): ?ResponseDTO;
}
Gdzie metoda:
supportedModel
- zwraca className obsługiwanego modelu (contentowego)getResponseDTOBySlug
- zwraca DTO z przygowanym contentem do renderowania na stronie.
Cała magia dzieje się na ServiceProvider'rze
- w moim przypadku jest to główny SP (AppServiceProvider
) - na nim jest stawiana i bindowana sama fabryka.
Oto jak on wygląda w obecnej formie:
<?php
namespace App\Providers;
use App\Services\Slug\Factory\SluggableFactory;
use App\Services\Slug\Factory\SluggableFactoryInterface;
use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider;
use Config;
use Exception;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
const CONFIG_BINDINGS_KEY = 'app.bindings';
const CONFIG_FACTORIES_SLUGGABLE_KEY = 'app.factories.sluggable';
/** @var array<mixed> */
public array $bindings = [];
/**
* Register any application services.
*/
public function register(): void
{
if ($this->app->isLocal()) {
$this->app->register(IdeHelperServiceProvider::class);
}
$configBindings = Config::get(self::CONFIG_BINDINGS_KEY);
if (is_array($configBindings)) {
$this->bindings = $configBindings;
}
$this->registryFactories();
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Password::defaults(function () {
return Password::min(8)
->mixedCase()
->numbers()
->letters()
->symbols();
});
}
protected function registryFactories(): void
{
$this->app->bind(
SluggableFactoryInterface::class,
function (Application $app) {
return new SluggableFactory(
$this->getFactoriesFromConfigByKey(self::CONFIG_FACTORIES_SLUGGABLE_KEY),
$app
);
}
);
}
/**
* @return iterable<int, class-string>
*
* @throws Exception
*/
protected function getFactoriesFromConfigByKey(string $configKey): iterable
{
$factoriesClasses = Config::get($configKey);
if (! is_iterable($factoriesClasses)) {
throw new Exception('Config factories `'.$configKey.'` is not array');
}
return $factoriesClasses;
}
}
I ja widać, same klasy fabryczek należące do tej fabryki są przechowywane w configu (config/app.php
) - co pozwala na łatwe dodanie nowego typu obsługiwanego (wystarczy dodać do configu, oraz oczywiście utworzyć nową klasę i zaimplementować ją według interfejsu). I w sumie to tyle - nie musimy się babrać dalej w kodzie, dodając jakieś brzydkie i niepotrzebne nowe case
w switch
czy innym match
:)
Implementacja w Symfony
W Symfony zasada działania jest zbieżna - różni się jedynie metodyka bindowania na configu - i tutaj niechętnie przyznam, jest to trochę prostsze do zrobienia. Wystarczy tak naprawdę dobrze określić na configu serviców bindingi.
Przykład (znaleziony) - config/service.yml
:
...
_instanceof:
App\ChannelHandlerInterface:
tags: ['app.channel_handler']
App\ChannelFactory:
arguments:
- !tagged_iterator app.channel_handler
I tak:
_instanceof
mówi,service container'owi
że wszystkie klasy implementujące dany interfejs - w tym przypadkuChannelHandlerInterface
mają być ztagowane pod nazwąapp.channel_handler
- Teraz wystarczy jedynie określić fabrykę i podać ten tag jako argument (na konstruktor w DI) jako iterator (
!tagged_iterator
).
Tak przygotowana fabryka wygląda w taki oto sposób:
final class ChannelFactory
{
public function __construct(private ChannelInterface ...$channels)
{
}
public function create(string $type): ChannelInterface
{
foreach($this->channels as $channel) {
if ($type === $channel::getType()) {
return new $channel;
}
}
throw new InvalidArgumentException('Unknown channel given');
}
}
Otrzymujemy to samo w obu przypadkach :) W Symfony, ze względu na inny system service containera jest to trochę prostsze w użyciu.
Dzięki i do następnego! :)