Wzorzec projektowy fabryka - okładka

Wzorzec projektowy Factory (Fabryka)

Fabryka jest bardzo powszechnie używanym i łatwym w zastosowaniu wzorcem projektowym. Mówiąc o wzorcu projektowym Fabryka można wyróżnić cztery rodzaje fabryk – Factory (fabryka), Factory Method (metoda wytwórcza), Static Factory (fabryka statyczna) oraz AbstractFactory (fabryka abstrakcyjna). Głównym założeniem fabryki, tak jak w rzeczywistym świecie, jest wytwarzanie obiektów. Dzięki wykorzystaniu fabryki, można ukryć szczegóły implementacyjne tworzenia obiektów i odseparować je od logiki biznesowej.

Ten wpis jest jednym z cyklu wpisów o wzorcach projektowych. Zachęcam też do sprawdzenia wpisów dotyczących innych wzorców:

We wpisach z tego cyklu w przykładach wykorzystuję TypeScript. Jeżeli miałeś/aś jakąkolwiek styczność z językami silnie typowanymi, to zrozumienie przykładów nie powinno stanowić dla Ciebie problemu.

Factory (Fabryka)

Aby lepiej zobrazować praktyczne zastosowanie fabryki, nakreślę przykładowy problem do rozwiązania. Firma, dla której tworzymy oprogramowanie, posiada system sprzedażowy i potrzebuje generować faktury dla klientów. Z uwagi na fakt, że klienci pochodzą z różnych części świata, faktury muszą być dostosowane do kraju pochodzenia klienta. Przykładowymi dostosowaniami są: format daty, kierunek ułożenia tekstu (od lewej do prawej lub od prawej do lewej) oraz waluta.

Potencjalnie tworzy to wiele różnych możliwości i jeśli chcielibyśmy obsłużyć wszystkie możliwe kombinacje za pomocą jednej klasy lub metody to kod stałby się zupełnie nieczytelny, nie wspominając nawet o możliwościach jego rozbudowy. Krokiem naprzód w designie omawianego rozwiązania może być stworzenie prostej fabryki, gdzie faktura dla każdego obsługiwanego kraju będzie tworzona w osobnej prywatnej metodzie. Do generowania faktury zostanie udostępniona w naszym przypadku jedynie jedna publiczna metoda pozwalająca na zdefiniowanie dla jakiego kraju faktura ma zostać wygenerowana. Najbardziej trywialna implementacja omawianej fabryki może wyglądać następująco (pominąłem implementacje klas i interfejsów nieistotnych z punktu widzenia omawianego wzorca):


class InvoiceFactory {
    public createInvoice( params: InvoiceParams ): IInvoice {
        switch( params.country ) {
            case 'PL':
                return this._createInvoicePL( params );
            case 'USA':
                return this._createInvoiceUSA( params );
            case 'SAU':
                return this._createInvoiceSAU( params );
            default:
                throw new Error( 'Unsupported country!' );
        }
    }

    private _createInvoicePL( params: InvoiceParams ): InvoicePL {
        return new InvoicePL( params );
    }

    private _createInvoiceUSA( params: InvoiceParams ): InvoiceUSA {
        return new InvoiceUSA( params );
    }

    private _createInvoiceSAU( params: InvoiceParams ): InvoiceSAU {
        return new InvoiceSAU( params );
    }
}

Mając już gotową fabrykę, jedyne co trzeba zrobić to stworzyć instancję klasy InvoiceFactory i wywołać metodę createInvoice w celu wygenerowania faktury. Oczekiwanym rezultatem wywołania metody createInvoice jest zwrócenie obiektu spełniającego interfejs IInvoice. Dzięki wykorzystaniu fabryki, z poziomu logiki biznesowej detale dotyczące generowania faktur są niewidoczne. Co więcej, dzięki odseparowaniu od siebie poszczególnych wariantów faktur w kodzie fabryki, logika generowania faktur została nieco uproszczona. Tworzenie nowej faktury z użyciem nowo powstałej fabryki przedstawia kod poniżej:


const factory: InvoiceFactory = new InvoiceFactory()

const invoice: IInvoice = factory.createInvoice( { country: 'PL', ...params } );

Obecna implementacja pozwala na wygenerowanie faktur dla klientów z Polski, Stanów Zjednoczonych oraz Arabii Saudyjskiej. W celu dodania wsparcia dla kolejnych krajów należałoby rozszerzyć fabrykę o kolejne metody oraz warunek w metodzie createInvoice.

Factory method (metoda wytwórcza)

Prosta fabryka przedstawiona we wcześniejszej części wpisu ma pewien zasadniczy problem. Poza generowaniem elementów faktur specyficznych dla kraju pochodzenia klienta, istnieje część faktury, która będzie identyczna niezależnie od kraju pochodzenia klienta. W celu dalszego uproszczenia kodu wykorzystany zostanie wzorzec projektowy metody wytwórczej. Dzięki metodzie wytwórczej możliwe jest zdefiniowanie części wspólniej dla generowanej faktury, oraz części specyficznych dla konkretnych podklas. Wcześniejszy kod wzbogacony o wzorzec metody wytwórczej przedstawiam poniżej:


abstract class Invoice {
    protected abstract format( invoice: IInvoice ): IInvoice;
    
    public create(): IInvoice {
        return this.format( this._generateInvoice() );
    }
}

class InvoicePL extends Invoice {
    protected format( invoice: IInvoice ): IInvoice {
        invoice.direction = 'ltr';
        invoice.currency = 'PLN';
        invoice.dateFormat = 'DD/MM/YYYY';

        return invoice;
    }
}

class InvoiceFactory {
    public createInvoice( params: InvoiceParams ): IInvoice {
        switch( params.country ) {
            case 'PL':
                return this._createInvoicePL( params );
            case 'USA':
                return this._createInvoiceUSA( params );
            case 'SAU':
                return this._createInvoiceSAU( params );
            default:
                throw new Error( 'Unsupported country!' );
        }
    }

    private _createInvoicePL( params: InvoiceParams ): InvoicePL {
        const invoice: InvoicePL = new InvoicePL( params );

        return invoice.create();
    }

    private _createInvoiceUSA( params: InvoiceParams ): InvoiceUSA {
        const invoice: InvoiceUSA = new InvoiceUSA( params );

        return invoice.create();
    }

    private _createInvoiceSAU( params: InvoiceParams ): InvoiceSAU {
        const invoice: InvoiceSAU = new InvoiceSAU( params );

        return invoice.create();
    }
}

Część wspólna procesu generowania faktury dla każdego kraju została zamknięta w prywatnej metodzie _generateInvoice klasy abstrakcyjnej Invoice, natomiast części specyficzne dla poszczególnych krajów są definiowane w chronionej metodzie format, która jest implementowana w klasach potomnych, czyli w naszym wypadku klasach faktur dla poszczególnych krajów. Powyższy kod de facto łączy ze sobą wzorce fabryka oraz metoda wytwórcza. Decydując się na złączenie dwóch wzorców w jednym fragmencie kodu chciałem pokazać, że dzięki umiejętnemu wykorzystaniu oraz łączeniu wzorców projektowych można uzyskać czysty i czytelny kod.

Ponadto, kod ten jest czytelny, pozbawiony duplikatów oraz będzie jeszcze łatwiejszy w rozbudowie. W przypadku potrzeby dodania kolejnego kraju do listy obsługiwanych krajów wystarczy zdefiniować klasę z implementacją metody format oraz dodać ją do fabryki. Dodatkowym benefitem zastosowania metowy wytwórczej w tym wypadku jest to, że jeśli programista, który pracuje z przedstawioną fabryką pierwszy raz dostanie zadanie dodania do listy nowego kraju, to nie musi on się wgryzać w logikę tworzenia faktury. Wystarczy, że zaimplementuje metodę format w nowej klasie oraz doda jej obsługę do fabryki!

Fabryka - obrazek dekoracyjny

Static Factory (Fabryka Statyczna)

Mając już kod fabryki odpowiedzialnej za generowanie faktur pojawił się pewien problem. Mianowicie, w systemie istnieje kilka instacji fabryk i zaszła sytuacja, że kilku klientów otrzymało faktury z tymi samymu numerami. W celu uniknięcia duplikatów generowanych faktur można wykorzystać wzorzec fabryki statycznej. Fabryka statyczna może przechowywać listę uprzednio wygenerowanych faktur i jeśli w systemie została już wygenerowana faktura o podanym numerze, to fabryka może na przykład rzucić błędem lub poprosić o inny numer faktury. Przykładowa implementacja fabryki statycznej, w omawianym przeze mnie przypadku mogłaby wyglądać następująco:


class InvoiceFactory {
    private static generatedInvoices: Set = new Set();

    public static createInvoice( params: InvoiceParams ): IInvoice {
        if ( InvoiceFactory.generatedInvoices.has( params.invoiceId ) ) {
            throw new Error( 'invoiceId already used!' );
        }

        switch( params.country ) {
            case 'PL':
                return this._createInvoicePL( params );
            case 'USA':
                return this._createInvoiceUSA( params );
            case 'SAU':
                return this._createInvoiceSAU( params );
            default:
                throw new Error( 'Unsupported country!' );
        }
    }

    private _createInvoicePL( params: InvoiceParams ): InvoicePL {
        const invoice: InvoicePL = new InvoicePL( params );

        return invoice.create();
    }

    private _createInvoiceUSA( params: InvoiceParams ): InvoiceUSA {
        const invoice: InvoiceUSA = new InvoiceUSA( params );

        return invoice.create();
    }

    private _createInvoiceSAU( params: InvoiceParams ): InvoiceSAU {
        const invoice: InvoiceSAU = new InvoiceSAU( params );

        return invoice.create();
    }
}

const invoice: IInvoice = InvoiceFactory.createInvoice( { country: 'PL', ...params } );

Posiadanie listy wygenerowanych faktur w fabryce pozwala w prosty sposób uniknąć duplikatów. Alternatywą dla tego rozwiązania byłoby wykorzystanie wzorca projektowego Singleton przedstawionego we wpisie Wzorzec projektowy Singleton, i powołanie do życia obiektu odpowiedzialnego za kontrolę numerów faktur. Każda instancja fabryki chcąc wygenerować fakturę musiałaby wykonać zapytanie do Singletona. W moim odczuciu, wykorzystanie statycznej fabryki w tym przypadku jest prostsze.

Statyczne fabryki są równie przydatne w przypadku różnego rodzaju klas z helperami. W takim wypadku nie ma większego sensu tworzenia instancji klasy przy każdym wykorzystaniu. Zdecydowanie prościej jest wykorzystać metodę statyczną np. HelperClass.doX( params ).

Abstract Factorty (Fabryka Abstrakcyjna)

Przedstawiony system generowania faktur wdrożono na serwer produkcyjny i okazało się, że działa tak dobrze, że postanowiono go rozbudować. Oprócz generowania faktur, koniecznne będzie generowanie raportów sprzedaży dla poszczególnych klientów. Do poradzenia sobie z tym zadaniem można wykorzystać fabrykę abstrakcyjną. W dużym skrócie, fabryka abstrakcyjna wykorzystuje w swojej implementacji inne fabryki, a jednocześnie sama jest fabryką. Przedstawiam rozwiązanie przedstawionego problemu z wykorzystaniem fabryki abstrakcyjnej:


class DocumentsFactory {
    public static generateDocument( params: IDocumentParams ): IDocument {
        switch( params.documentType ) {
            case 'invoice':
                return InvoiceFactory.createInvoice( params );
            case 'report':
                return ReportFactory.createReport( params );
        }
    }
}

W tym miejscu należy zaznaczyć, że tym przypadku zwracane z fabryk InvoiceFactory oraz ReportFactory obiekty muszą implementować wspólny interfejs IDocument. Fabryki abstrakcyjne znajdują swoje zastosowanie, gdy w systemie istnieje kilka fabryk zwracających obiekty wywodzące się z “jednej rodziny”. W naszym przypadku są to fabryki zwracające różne rodzaje dokumentów.

Podsumowanie

Mam nadzieję, że przedstawione przeze mnie przykłady przedstawiły Ci w jasny i przejrzysty sposób czym są fabryki oraz jak i kiedy je stosować. Zachęcam do wypróbowania fabryk w praktyce, pozostawienia komentarza i zapoznania się z materiałami dodatkowymi.

Źródła i materiały dodatkowe: