Warstwa Abstrakcji - stanowi izolację pomiędzy kodem programisty a zewnętrznymi modułami, pozwala odseparować system od jego zależności przez co czyni go podatnym na zmiany i aktualizacje. Przywiązując system do innej usługi czynimy go słabym.

Rozszerzanie funkcjonalności bez wprowadzania chaosu post

2015-10-05

Przychodzi pan lub pani z działu marketingu do programisty i mówi... Tak mógłby zacząć się niejeden dobry kawał rodem z największych korporacji IT. Nie ma w tym jednak nic śmiesznego, tak samo jak nie ma w 100% ukończonych aplikacji. Biznes zmienia założenia, jednak czy to oznacza że biznes jest zły? Wprost przeciwnie, dobry biznes musi ewoluować w celu dopasowania do zmieniających się potrzeb rynku. Dobry kod powinien natomiast być na te zmiany przygotowany.

Poprzedni wpis opisywał w jaki sposób z problematycznego i ciężkiego w utrzymaniu kodu przejść do czegoś co da się zrozumieć bez kilkugodzinnej analizy. Jak jednak nie zepsuć tego co udało się nam wypracować? Najpierw kilka założeń. Celem procesu rejestracji, który został opisany w poprzednim wpisie, jest utworzenie konta użytkownika. Podczas tej operacji należy potwierdzić, że właściciel konta jest również właścicielem adresu email użytego w czasie rejestracji. W tym celu należy wysłać do niego unikalny, automatycznie wygenerowany link aktywacyjny.

Oto specyfikacja usługi RegistrationService

class RegistrationServiceSpec extends ObjectBehavior
{
    function let(UserRepository $users, UserFactory $factory)
    {
        $this->beConstructedWith($users, $factory);   
    }

    function it_registers_user(UserRepository $users, UserFactory $factory)
    {
        $inputData = ['username' => 'norzechowicz', 'password' => 'pass123'];
        $user = new User('norzechowicz');

        $factory->createUser($inputData)->willReturn($user);
        $users->add($user)->shouldBeCalled();

        $this->register($inputData);
    }
}

final class RegistrationService
{
    private $users;

    private $userFactory;

    public function __construct(UserRepository $users, UserFactory $factory)
    {
        $this->users = $users;
        $this->userFactory = $factory;
    }

    public function register(array $data)
    {
        $this->users->add($this->userFactory->createUser($data));
    }
}

Rozszerzenie na szybko

Na pierwszy rzut oka mogłoby się wydawać, że nie ma w tym nic trudnego, jedyne, co trzeba zrobić, to wygenerować link aktywacyjny oraz wysłać go do użytkownika. No więc do dzieła, tworzymy interfejs:

interface ActivationTokenGenerator
{
    public function generateFor(User $user);
}

oraz jakaś bardzo prostą implementację:

final class MD5TokenGenerator implements ActivationTokenGenerator
{
    public function generateFor(User $user)
    {
        return md5((string) $user->getEmail());
    }
}

To oczywiście nie jest w żadnym stopniu bezpieczne i nie należy w ten sposób generować tokenu aktywacyjnego. Taki token jest bardzo łatwy do rozszyfrowania przez co aplikacja staje się podatna na atak polegający na aktywacji kont bez dostępu do adresu email użytego w celu utworzenia konta.

Kolejnym elementem układanki jest mechanizm wysyłający email do użytkownika:

interface UserMailer
{
    public function sendActivationEmail(User $user);
}

wraz z odpowiednią implementacją:

final class SymfonyUserMailer implements UserMailer
{
    private $tokenGenerator;

    private $router;

    private $mailer;

    public function __construct(ActivationTokenGenerator $tokenGenerator, Router $router, \Swift_Mailer $mailer)
    {
        $this->tokenGenerator = $tokenGenerator;
        $this->router = $router;
        $this->mailer = $mailer;
    }

    public function sendActivationEmail(User $user)
    {
        $token = $this->tokenGenerator->generate($user);
        $url = $this->router->generate('user_activation_endpoint', ['token' => $token]);

        $message = \Swift_Message::newInstance()
            ->setSubject('Email verification')
            ->setFrom('[email protected]')
            ->setTo((string) $user->getEmail())
            ->setBody(
                sprintf("To verify your email please visit: %s ", $url),
                'text/html'
            );

        $this->mailer->send($message);
    }
}

No i oczywiście trzeba to teraz spiąć z usługą RegistrationService

final class RegistrationService
{
    private $users;

    private $userFactory;

    private $mailer

    public function __construct(UserRepository $users, UserFactory $factory, UserMailer $mailer)
    {
        $this->users = $users;
        $this->userFactory = $factory;
        $this->mailer = $mailer;
    }

    public function register(array $data)
    {
        $user = $this->userFactory->createUser($data);
        $this->users->add($user);
        $this->mailer->sendActivationEmail($user);
    }
}

Na pierwszy rzut oka mogłoby się wydawać, że wszystko gra. Są odwrócone zależności, klasa RegistrationService nadal zajmuje się rejestracją użytkownika, wysyłkę wiadomości deleguje na zewnątrz. Czy jednak na pewno z tym kodem jest wszystko w porządku?

Nie do końca...

Pierwszym symptomem jaki daje się dla tego kodu zauważyć jest nazwa przykładu w specyfikacji. Pierwotnie przykład nazywał się:

it_registers_user

teraz natomiast musiałby nazywać się

it_registers_user_and_sends_activation_email

W tym przypadku widać, że przykład stał się zbyt szczegółowy. Przykład działania procesu domenowego opisuje dodatkowo detal jakim jest wysłanie emaila. Dlaczego detal? Przecież weryfikacja konta to ważny z punktu widzenia biznesu element rejestracji? Tak, sama weryfikacja jest istotna, jednak jej implementacja, czyli wysłanie wiadomości, już nie. Co więcej, idąc tą drogą dołożenie jakiegokolwiek dodatkowego zachowania będzie wydłużać nazwę przykładu do momentu gdy dojdziemy do nazwy, dla której pod koniec czytania zapominamy co było na początku.

Ponadto w tym przykładzie dochodzi do przenikania się warstw aplikacji. Warstwa domeny, do której należy RegistrationService stała się zależna od warstwy infrastruktury, której częścią jest UserMailer. Domena powinna być niezależna, inaczej trudno utrzymać ją w porządku.

Ten sposób nie jest najlepszym przykładem na to jak rozszerzać funkcjonalności w kodzie. Kolejne dodatkowe zachowania wymagać będą kolejnych ingerencji w RegistrationService, które będą jedynie komplikować i zaciemniać kod.

Rozszerzenie przez zdarzenia

Kolejnym pomysłem, na jaki wpadają programiści patrzący z trochę szerszej perspektywy, szczególnie tacy którzy programują w konkretnym frameworku jest event dispatcher/publisher.

Programują w konkretnym frameworku, czyli tworzą kod tak bardzo związany z kodem frameworka, że aplikacja jest praktycznie wbudowana w framework.

Event dispatcher to nic innego jak implementacja wzorca o nazwie Mediator. Jest to też jedna z metod odwrócenia zależności.

Polega na tym, że w miejscu w którym dochodzi do jakiegoś istotnego zdarzenia, wykonywana jest propagacja Eventu. Zdarzenie to jest przekazywane do wcześniej zarejestrowanych handlerów, a te już same decydują co z nim zrobić.

Przykłady takiego kodu można znaleźć w Dokumentacji Symfony

Poniżej kod przedstawiający jak zmieni się usługa RegistrationService po dodaniu event dispatchera.

final class RegistrationService
{
    private $users;

    private $userFactory;

    private $eventDispatcher

    public function __construct(UserRepository $users, UserFactory $factory, EventDispatcher $eventDispatcher)
    {
        $this->users = $users;
        $this->userFactory = $factory;
        $this->eventDispatcher = $eventDispatcher;
    }

    public function register(array $data)
    {
        $user = $this->userFactory->createUser($data);
        $this->users->add($user);
        $this->eventDispatcher->dispatch('user.registration.success', new UserEvent($user));
    }
}

Kod wygląda lepiej, usługa domenowa przestała być zależna od elementu z warstwy infrastruktury, teraz wprawdzie jest zależna od elementu który można by umiejscowić w warstwie aplikacji, jednak to zawsze trochę bliżej domeny :)

Głównym problem takiego podejścia to ogromne zmniejszenie czytelności kodu. W zasadzie, patrząc na powyższy przykład, nie ma żadnej możliwości, aby zgadnąć gdzie szukać czegoś, co obsługuje zdarzenie UserEvent ani nawet czy coś takiego istnieje.

Jeszcze gorzej jest kiedy takich mechanizmów obsługujących to zdarzenie jest kilka i wszystkie muszą wykonać się w określonej kolejności, a jeden z nich może przerwać kolejne. W większości przypadków takie podejście prowadzi do długiego i żmudnego procesu debugowania, gdzie nikt nie pamięta jak to działa oraz nikt do końca nie rozumie istoty działania danej części aplikacji, ale każdy boi się cokolwiek uprościć w obawie przed pominięciem czegoś, co obsługuje to zdarzenie tylko w określonych przypadkach. To z kolei prowadzi do powstawania dużej ilości testów funkcjonalnych, mających na celu pokrycie każdego przypadku brzegowego, co przekłada się na powolne testy i finalnie na to, że mało kto te testy odpala.
Każdy kto pracował w projekcie zbudowanym na systemie zdarzeń rozumie ten ból.

Rozszerzenie przez domknięcie

Inną techniką może być stworzenie prostego domknięcia procesu rejestracji użytkownika. Ten przypadek najlepiej opisać przy pomocy kodu.

Zacznijmy od specyfikacji:

class RegistrationServiceSpec extends ObjectBehavior
{
    function let(UserRepository $users, UserFactory $factory)
    {
        $this->beConstructedWith($users, $factory);   
    }

    function it_executes_closure_after_successful_user_registration(
        UserRepository $users, 
        UserFactory $factory, 
        UserRegistrationClosure $closure
    ) {
        $inputData = ['username' => 'norzechowicz', 'password' => 'pass123'];
        $expectedUser = new User('norzechowicz');

        $factory->createUser($inputData)->willReturn($expectedUser);
        $users->add($expectedUser)->shouldBeCalled();
        $closure->execute($expectedUser)->shouldBeCalled();

        $this->register($inputData, $closure);
    }
}

Jak widać do pierwotnego kodu doszło oczekiwanie wywołania domknięcia z przekazaniem wcześniej utworzonej i zarejestrowanej encji użytkownika.

interface UserRegistrationClosure
{
    public function execute(User $user);
}

Tak prezentuje się interfejs domykający proces rejestracji użytkownika:

RegistrationService zmieni się w następujący sposób:

final class RegistrationService
{
    private $users;

    private $userFactory;

    public function __construct(UserRepository $users, UserFactory $factory)
    {
        $this->users = $users;
        $this->userFactory = $factory;
    }

    public function register(array $data, UserRegistrationClosure $closure = null)
    {
        $user = $this->userFactory->createUser($data);
        $this->users->add($user);

        if (!is_null($closure)) {
            $closure->execute($user);
        }
    }
}

W tym kodzie praktycznie od razu widać co i w jaki sposób rozszerza proces rejestracji użytkownika. Ten sposób tak samo jak zdarzenia nie blokuje możliwości dalszego rozszerzenia procesu rejestracji użytkownika bez modyfikacji klasy RegistrationService

Przyjmijmy, że kolejnym wymaganiem, które dojdzie do tego procesu, będzie przykładowo utworzenie wpisu w jakimś dzienniku zdarzeń, logu. Oto jak można napisać odpowiednie domknięcia.

final class EmailVerificationClosure implements UserRegistrationClosure
{
    private $nextClosure;

    private $mailer;

    public function __construct(UserRegistrationLogClosure $nextClosure, UserMailer $mailer)
    {
        $this->nextClosure = $nextClosure;
        $this->mailer = $mailer;
    }

    public function execute(User $user)
    {
        $this->sendActivationEmail($user);
        $this->nextClosure->execute($user);
    }
}

final class UserRegistrationLogClosure implements UserRegistrationClosure
{
    private $logger;

    public function __construct(Logger $logger)
    {
        $this->logger = $logger;
    }

    public function execute(User $user)
    {
        $this->logger->info(
            sprintf("User account with email \"%s\" was successfully registered.", (string) $user->getEmail())
        );
    }
}

Dzięki temu patrząc na pierwsze domknięcie przekazywane do metody register jesteśmy w stanie określić co dzieje się dalej. Nie ma tu ryzyka, że gdzieś istnieje jakieś niewidoczne domknięcie, które magicznie wykona się tylko w określonej sytuacji. Ryzyko popsucia procesu rejestracji jest więc znacznie mniejsze. Oczywiście powyższy przykład jest bardzo przerysowany, nie ma najmniejszego sensu tworzyć osobnego domknięcia w celu dodania wpisu do logów. Równie dobrze te dwie operacje można by wykonać w pierwszym domknięciu, przykład ten miał tylko pokazać co zrobić gdy EmailVerificationClosure zacznie się zbyt rozrastać.

Można pójść o krok dalej i interfejs UserRegistrationClosure w metodzie register w usłudze RegistrationService zastąpić Callable. Wtedy całość wyglądałaby w sposób następujący:

final class RegistrationService
{
    private $users;

    private $userFactory;

    public function __construct(UserRepository $users, UserFactory $factory)
    {
        $this->users = $users;
        $this->userFactory = $factory;
    }

    public function register(array $data, Callable $closure = null)
    {
        $user = $this->userFactory->createUser($data);
        $this->users->add($user);

        if (!is_null($closure)) {
            $closure($user);
        }
    }
}

Samo wywołanie metody register znacznie się dzięki temu uprości:

$registrationService->register(['username' => 'norzechowicz'], function(User $user) use ($userMailer, $logger) {
    $userMailer->sendActivationEmail($user);
    $logger->info(
       sprintf("User account with email \"%s\" was successfully registered.", (string) $user->getEmail())
    );
});

Obydwa podejścia mają swoje wady i zalety. Jedno wymaga zdefiniowania dodatkowego interfejsu oraz stworzenia jego implementacji. Drugie natomiast może bardzo szybko stać się mało czytelne.
Nic jednak nie stoi na przeszkodzie żeby zacząć od Callable a skończyć na UserRegistrationClosure.

Event dispatcher daje jeszcze jedną możliwość, mianowicie przerwanie wykonywania się kolejnych zdarzeń. Jak w przypadku użycia tego typu domknięć zrealizować coś takiego?
Wystarczy rzucić wyjątek. Dlaczego wyjątek? Ponieważ przerwanie wykonywania zdarzeń nie dzieje się dlatego, że wszystko poszło dobrze. Przerwanie obsługi zdarzenia ma miejsce wtedy kiedy coś pójdzie nie po myśli programisty.

Podsumowanie

Najważniejsze to nie projektować na wyrost. Istnieje spora szansa, że nigdy nie dojdzie do sytuacji gdzie rejestrację użytkownika trzeba będzie poszerzyć o cokolwiek więcej niż wysłanie maila weryfikującego. Kod nie musi być przygotowany na rozszerzenia od razu, często daje to efekt odwrotny do zamierzonego. Nawet jeżeli wiadome, jest że w przyszłości coś trzeba będzie dołożyć i tak prawdopodobnie okaże się, że nie przewidzieliśmy dokładnie jak to zrobić.

Nie powino się nastawiać na to, że napisany kod nie będzie zmieniany. Nie ma się też co nastawiać, że faktycznie będzie istniała konieczność wprowadzenia zmian. Jedyne co trzeba brać pod uwagę, to aby ewentualne zmiany były możliwe.

Dodawanie kolejnych miejsc pozwalających rozszerzyć daną funkcjonalność powinno wynikać z potrzeby a nie z przewidywań (ta zasada nie dotyczy frameworków i bibliotek, a przynajmniej nie trzeba się jej sztywno trzymać).
W zasadzie to o żadnej z przedstawionych powyżej metod nie można powiedzieć, że jest całkiem zła albo całkiem dobra. Wszystko zależy od kontekstu w jakim dane podejście zostanie wykorzystane.


Masz pytanie?
Napisz: [email protected]
Akceptuję

Ten serwis używa plików cookies. Więcej o plikach cookies.