php
artykuł··zaktualizowano ·5 min czytania

Factory Method w PHP: jedno miejsce na wybór implementacji w runtime

#php #design-patterns #factory #dependency-injection #architecture

Fabryka odpowiada na jedno pytanie: jaką implementację utworzyć dla danego wejścia. Przydaje się, gdy odpowiedź zależy od danych z runtime, na przykład od pola w requeście albo kolumny w bazie, i gdy tej samej odpowiedzi potrzeba w więcej niż jednym miejscu. Bez obu warunków fabryka dokłada tylko kolejną warstwę.

Terminologia

W książce GoF Factory Method to konkretna struktura: klasa bazowa wywołuje abstrakcyjną metodę tworzącą, a podklasy ją nadpisują i w ten sposób wybierają konkretny produkt.

abstract class ReportExporter
{
    abstract protected function createWriter(): ReportWriter;

    public function export(Report $report): string
    {
        $writer = $this->createWriter();
        foreach ($report->rows() as $row) {
            $writer->addRow($row);
        }
        return $writer->finish();
    }
}

final class CsvReportExporter extends ReportExporter
{
    protected function createWriter(): ReportWriter
    {
        return new CsvWriter();
    }
}

W aplikacjach PHP częściej trafia się fabryka parametryzowana: jeden obiekt z metodą make(), który dostaje klucz i oddaje implementację interfejsu. Dalej piszę o tej formie, bo to ona zastępuje powielone warunki.

Problem: ta sama gałąź w kilku miejscach

Aplikacja przyjmuje płatności kartą, BLIK-iem, przelewem i na raty. Każdy dostawca ma swoje API, swój model błędów i swój format webhooków. Jeśli decyzja nie zapada w jednym miejscu, w kontrolerze ląduje coś takiego:

$gateway = match ($request->input('payment_method')) {
    'card' => new StripeGateway(config('services.stripe.secret')),
    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),
    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),
    'financing' => new FinancingGateway(config('services.financing.api_key')),
    default => throw new InvalidArgumentException('Unknown payment method'),
};

Potem ten sam wybór wraca w obsłudze zwrotów, w endpoincie webhooków i w jobie uzgadniającym płatności, za każdym razem na podstawie metody zapisanej przy płatności i za każdym razem z przepisanymi argumentami konstruktorów. Nowy dostawca albo nowa zależność któregoś z istniejących to szukanie wszystkich kopii po kodzie.

Interfejs, enum, fabryka

Zaczynam od interfejsu wyrażonego w pojęciach aplikacji. Money, ChargeResult, RefundResult i WebhookEvent to value objecty aplikacji.

enum PaymentMethod: string
{
    case Card = 'card';
    case Blik = 'blik';
    case Transfer = 'transfer';
    case Financing = 'financing';
}

interface PaymentGateway
{
    public function charge(Money $amount, array $metadata): ChargeResult;

    public function refund(string $chargeId, Money $amount): RefundResult;

    public function parseWebhook(string $payload, array $headers): WebhookEvent;
}

Fabryka mapuje metodę na klasę, a budowę obiektu zostawia kontenerowi:

use Illuminate\Contracts\Container\Container;

final class PaymentGatewayFactory
{
    /** @param array<string, class-string<PaymentGateway>> $gateways */
    public function __construct(
        private readonly Container $container,
        private readonly array $gateways,
    ) {}

    public function make(PaymentMethod $method): PaymentGateway
    {
        $class = $this->gateways[$method->value]
            ?? throw new UnsupportedPaymentMethod($method->value);

        return $this->container->make($class);
    }
}

Rejestruje się ją raz, w service providerze. To, czego poszczególne bramki potrzebują w konstruktorach (dane dostępowe, klient HTTP, logger), jest bindowane osobno, a resztą zajmuje się kontener.

public function register(): void
{
    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [
        PaymentMethod::Card->value => StripeGateway::class,
        PaymentMethod::Blik->value => BlikGateway::class,
        PaymentMethod::Transfer->value => BankTransferGateway::class,
        PaymentMethod::Financing->value => FinancingGateway::class,
    ]));
}

Miejsca wywołania przekazują już tylko własne wejście:

// kontroler: FormRequest waliduje pole regułami ['required', Rule::enum(PaymentMethod::class)]
$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));
$result = $gateway->charge($amount, ['order_id' => $order->id]);

// job zwrotu: wejściem jest kolumna rzutowana na PaymentMethod
$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);

Nowy dostawca to klasa implementująca PaymentGateway, nowy przypadek w enumie, jedna linia w mapie i bindingi jego zależności, jeśli kontener nie zbuduje ich sam.

Laravel stosuje ten sam pomysł wewnętrznie w Illuminate\Support\Manager (driver(), extend()), na którym opierają się m.in. sterowniki sesji, hashowania i kanałów powiadomień. Rozszerzenie Manager ma sens, gdy implementacje wybiera się po nazwie z plików konfiguracyjnych. Przy wyborze domenowym, jak tutaj, osobna fabryka czyta mi się lepiej.

Gdzie fabryki się psują

Najczęściej fabryka sama buduje obiekty. Jeśli woła new StripeGateway(config(...)), trzeba ją zmieniać przy każdej zmianie konstruktora bramki, a kontener w ogóle nie widzi zależności bramki. Budowę obiektów oddaj kontenerowi. Gdy kontenera nie ma, przekaż do konstruktora fabryki gotowe instancje albo closure, które je tworzą.

Drugi błąd jest mniej widoczny. PaymentGateway dostaje metody w rodzaju createPaymentIntent(), bo pierwszą integracją akurat był Stripe, i odtąd każda kolejna bramka musi tłumaczyć swój model na model Stripe'a. W tym tłumaczeniu pojawiają się błędy. Operacje nazwij od tego, co robi aplikacja (obciążenie, zwrot, odczytanie webhooka), a przekład na model dostawcy trzymaj w jego klasie.

Fabryka czy Strategy

Te dwa wzorce często chodzą w parze. Strategy to obiekt, którego zachowanie można podmienić za interfejsem, i każda PaymentGateway jest strategią przetwarzania płatności. W fabryce zapada decyzja, której strategii użyć dla danego wejścia. Jeśli strategię znasz już przy starcie aplikacji, z konfiguracji, fabryka jest niepotrzebna. Zbinduj interfejs na implementację w kontenerze i wstrzyknij ją.

Testy

Samej fabryce wystarczy jeden test: każdy przypadek enuma daje PaymentGateway. Wywali się, gdy ktoś doda przypadek i zapomni o wpisie w mapie, a także gdy kontener nie umie zbudować którejś bramki.

public function test_every_payment_method_has_a_gateway(): void
{
    $factory = $this->app->make(PaymentGatewayFactory::class);

    foreach (PaymentMethod::cases() as $method) {
        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));
    }
}

Więcej daje wspólny test kontraktu, po którym dziedziczy test każdej bramki. Każda podklasa podaje swoją bramkę z podstawionym transportem, na przykład przez Http::fake() z przykładową odpowiedzią dostawcy, jeśli bramka korzysta z klienta HTTP Laravela.

abstract class PaymentGatewayContractTest extends TestCase
{
    abstract protected function gateway(): PaymentGateway;

    public function test_charge_returns_a_charge_id(): void
    {
        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);

        $this->assertNotSame('', $result->chargeId);
    }
}

Kiedy wystarczy binding albo match

Przy jednej implementacji albo wyborze stałym dla danego wdrożenia wystarczy binding w kontenerze. Jeśli wybór zapada w dokładnie jednym miejscu, match na enumie właśnie tam czyta się lepiej niż dodatkową klasę.

Bywa też, że implementacje nie mają wspólnego interfejsu, którego wywołujący mogą używać bez sprawdzania instanceof. Fabryka tego nie naprawi, najpierw trzeba poprawić interfejs.

koniec artykułu