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 jakąś jego częścią. Można iść w parametryzowane klasy, czy metody, ale często skończy się to niestety na powielaniu 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 jeśli mówi się 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. 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, ale 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. 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

Poniższy 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 – na przykład 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;

class Log
{
    private \DateTimeImmutable $date;

    private string $message;

    public function __construct(\DateTimeImmutable $date, string $message)
    {
        $this->date = $date;
        $this->message = $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 FILE_PATH = __DIR__ . '/synchronization/files/';

    protected LogRepositoryInterface $logRepository;

    public function __construct(LogRepositoryInterface $logRepository)
    {
        $this->logRepository = $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;

class CsvFileLogSynchronizer extends AbstractFileLogSynchronizer
{
    public function __construct(LogRepositoryInterface $logRepository)
    {
        parent::__construct($logRepository);
    }

    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 ($logDate === false) {
                continue;
            }

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

        return $logs;
    }
}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod;

class TxtFileLogSynchronizer extends AbstractFileLogSynchronizer
{
    public function __construct(LogRepositoryInterface $logRepository)
    {
        parent::__construct($logRepository);
    }

    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 ($logDate === false) {
                continue;
            }

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

        return $logs;
    }
}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod\Test;

use DesignPatterns\Behavioral\TemplateMethod\LogRepositoryInterface;

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 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 $exception) {
            $this->assertFalse(true, 'Synchronize action using test_logs.csv does not work.');
        }

        $this->assertTrue(true);
    }
}

<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\TemplateMethod\Test;

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 $exception) {
            $this->assertFalse(true, 'Synchronize action using test_logs.txt does not work.');
        }

        $this->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ć. Z drugiej strony wzorce takie jak strategia niejednokrotnie okażą się po prostu lepsze.

Nie zmienia to faktu, że wzorzec ten 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.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.