Template Method (Metoda Szablonowa)

template method uml

Opis

Wzorzec projektowy Template Method (Metoda Szablonowa) należy do grupy wzorców behawioralnych. Odpowiedzialny jest za dostarczenie metody definiującej szkielet algorytmu. Klasy pochodne mają do nadpisania konkretne części tego algorytmu, ale sama struktura przepisu i czasem też niektóre jej elementy są niezmienialne i znajdują się w klasie bazowej.

Problem i rozwiązanie

Dwa lub więcej podobnych algorytmów, które różnią się tylko jego częścią. Można iść w parametryzowane klasy, czy metody. Niestety, często skończy się to powieleniem kodu, zbędnej ifologii i operowaniu na typach prostych. Zamiast tego można skorzystać z dobrodziejstw programowania obiektowego wykorzystując polimorfizm.

Dziedziczenie nie jest czymś popularnym w kontekście rozmów o dobrych praktykach. Warto pamiętać, że jest to jeden z podstawowych konceptów tego paradygmatu i wcale nie należy się go bać. Oczywiście kompozycja zamiast dziedziczenia to bardzo dobra zasada i w wielu przypadkach faktycznie w tę stronę powinno się pójść. Jeżeli już mowa o dziedziczeniu to wzorzec projektowy Metoda Szablonowa jest jego najlepszym przykładem. Hermetyzacja, wykorzystanie klas i metod abstrakcyjnych, pola i metody finalne lub o ograniczonym zasięgu. Wszystko jest na swoim miejscu.

Plusy i minusy

Wzorzec Template Method przestrzega regułę otwarte-zamknięte (open-closed). Dzięki temu łatwo go rozszerzać. Oczywiście. jeśli algorytm jest bardzo skomplikowany lub źle podzielony to tworzenie kolejnych klas potomnych może być trudne. Jest to nie kwestia samego wzorca, a gorszej implementacji. Co chyba najważniejsze, mechanizm ten przestrzega regułę DRY i gwarantuje reużywalność. Zamiast powielać ten sam kod w wielu miejscach można umieścić go w klasie bazowej.

Łatwo też dzięki niemu pozbyć się niepotrzebnych warunkowań. Każda klasa, która dziedziczy bazowy algorytm, dokładnie wie jaką ma odpowiedzialność. Sama klasa bazowa bardzo dokładnie pilnuje poprawności całego algorytmu. Szkielet, który tworzy jest nienaruszalny. To samo tyczy się części uniwersalnej. Za to całą resztę deleguje do klas potomnych.

Jeżeli pominie się kilka szczegółów przy implementacji tego wzorca, bardzo łatwo można złamać regułę podstawienia Liskov (liskov substitution). Dodatkowo kod opierający się o to rozwiązanie jest dość trudny do przetestowania. Niby posiada jedną publiczną metodę, więc wydaje się że mniej kodu do pokrycia testami. Łatwiej jednak testuje się mniejsze jednostki. Widać zresztą w moim przykładzie, że testy nie są idealne.

Przykładowa implementacja w PHP

Przykład zawiera implementację synchronizacji logów z plików o dwóch formatach: .txt i .csv. Teoretycznie, w dzień logi zapisywane są do plików albo przesyłane z innego serwisu jako pliki, a w nocy wykonuje się synchronizacja tych logów zapisując je za pomocą repozytorium – chociażby do bazy danych.

Cały przepis na tę akcję jest niezmienny i został zawarty w metodzie szablonowej synchronize(), która jest określona jako publiczna – stanowi interfejs. Dodatkowo jest też finalna . Klasy podrzędne nie będą w stanie jej nadpisać. Pozostałe metody zazwyczaj będą prywatne lub chronione. Te uniwersalne implementowane są w klasie abstrakcyjnej, po to by nie duplikować kodu. Przykładem jest getFilePath(). Za to te, które są odpowiedzialne za różnice w działaniu między klasami potomnymi oznaczane są jako abstrakcyjne. W tym wypadku: readFromFile() i transform().

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod;

use DateTimeImmutable;

final readonly class Log
{
    public function __construct(
        public DateTimeImmutable $date,
        public string $message
    ) {}
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod;

interface LogRepositoryInterface
{
    public function save(array $logs): void;
}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod;

abstract class AbstractFileLogSynchronizer
{
    private const string FILE_PATH = __DIR__ . '/synchronization/files/';

    public function __construct(
        private LogRepositoryInterface $logRepository
    ) {}

    final public function synchronize(string $fileName): void
    {
        $unprocessedLogs = $this->readFromFile(
            $this->getFilePath($fileName)
        );
        $logs = $this->transform($unprocessedLogs);
        $this->logRepository->save($logs);
    }

    abstract protected function readFromFile(string $filePath): array;

    abstract protected function transform(array $unprocessedLogs): array;

    private function getFilePath(string $fileName): string
    {
        return self::FILE_PATH . $fileName;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod;

use DateTimeImmutable;

final class CsvFileLogSynchronizer extends AbstractFileLogSynchronizer
{
    protected function readFromFile(string $filePath): array
    {
        $unprocessedLogs = [];
        $file = fopen($filePath, 'r');
        while (($unprocessedLog = fgetcsv($file, 0, ':', '"', '')) !== false) {
            $unprocessedLogs[] = $unprocessedLog;
        }

        fclose($file);

        return $unprocessedLogs;
    }

    protected function transform(array $unprocessedLogs): array
    {
        $logs = [];
        foreach ($unprocessedLogs as $unprocessedLog) {
            $logDate = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', reset($unprocessedLog));
            if (false === $logDate) {
                continue;
            }

            $logs[] = new Log($logDate, end($unprocessedLog));
        }

        return $logs;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod;

use DateTimeImmutable;

final class TxtFileLogSynchronizer extends AbstractFileLogSynchronizer
{
    protected function readFromFile(string $filePath): array
    {
        $unprocessedLogs = [];
        $rows = explode("\n", file_get_contents($filePath));
        foreach ($rows as $row) {
            $unprocessedLog = explode(", ", $row);
            $unprocessedLogs[] = [
                'date' => $unprocessedLog[array_key_first($unprocessedLog)],
                'message' => $unprocessedLog[array_key_last($unprocessedLog)]
            ];
        }

        return $unprocessedLogs;
    }

    protected function transform(array $unprocessedLogs): array
    {
        $logs = [];
        foreach ($unprocessedLogs as $unprocessedLog) {
            $logDate = DateTimeImmutable::createFromFormat('d.m.Y H:i', $unprocessedLog['date']);
            if (false === $logDate) {
                continue;
            }

            $logs[] = new Log($logDate, $unprocessedLog['message']);
        }

        return $logs;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod\Test;

use BadMethodCallException;
use DesignPatterns\Behavioral\TemplateMethod\LogRepositoryInterface;

final class FakeLogRepository implements LogRepositoryInterface
{
    public function save(array $logs): void
    {
        if (empty($logs)) {
            throw new BadMethodCallException();
        }
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod\Test;

use BadMethodCallException;
use DesignPatterns\Behavioral\TemplateMethod\CsvFileLogSynchronizer;
use PHPUnit\Framework\TestCase;

final class CsvFileLogSynchronizerTest extends TestCase
{
    public function testCanSynchronizeLogsFromTestFile(): void
    {
        $logSynchronizer = new CsvFileLogSynchronizer(new FakeLogRepository());

        try {
            $logSynchronizer->synchronize('test_logs.csv');
        } catch (BadMethodCallException) {
            self::fail('Synchronize action using test_logs.csv does not work.');
        }

        self::assertTrue(true);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod\Test;

use BadMethodCallException;
use DesignPatterns\Behavioral\TemplateMethod\TxtFileLogSynchronizer;
use PHPUnit\Framework\TestCase;

final class TxtFileLogSynchronizerTest extends TestCase
{
    public function testCanSynchronizeLogsFromTestFile(): void
    {
        $logSynchronizer = new TxtFileLogSynchronizer(new FakeLogRepository());

        try {
            $logSynchronizer->synchronize('test_logs.txt');
        } catch (BadMethodCallException) {
            self::fail('Synchronize action using test_logs.txt does not work.');
        }

        self::assertTrue(true);
    }
}

Template Method – podsumowanie

Metoda Szablonowa to wzorzec, który wbrew pozorom nie pasuje do wielu przypadków. Jest dużo miejsc, gdzie wydaje się że algorytmy niewiele się od siebie różnią i z powodzeniem można go zaimplementować. Często jednak okaże się, że wyciągnięta abstrakcja zrobiona została na siłę i skończy się to na różnego rodzaju obejściach, po to by ciągle ją podtrzymywać. Wzorce takie jak strategia niejednokrotnie okażą się po prostu lepsze.

Nie zmienia to faktu, że wzorzec ten, jak wszystkie inne, pasuje do konkretnego problemu i w niezbyt skomplikowany sposób potrafi go rozwiązać. Sztywny algorytm, który w kilku szczegółowych implementacjach niewiele się od siebie różni.

Używając dziedziczenia bardzo często operuje się na podobnym koncepcie jak metoda szablonowa, ale nie koniecznie zgodnie ze sztuką. Mi podoba się takie ustrukturyzowanie, które pozwala poprawnie zaimplementować cały mechanizm dziedziczenia w zgodzie z dobrymi praktykami.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.