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.

Wzorce projektowe w praktyce - rejestracja użytkownika post

2015-09-24

Obecnie praktycznie każdy system posiada mniej lub bardziej rozbudowany mechanizm rejestracji kont użytkowników. Nie inaczej było też w przypadku, w którym jeden z programistów poprosił o pomoc na irc. Problem przedstawiał się następująco: programista tworzył mechanizm rejestracji użytkownika, dane kont przechowywał w bazie danych, do obsługi bazy postanowił skorzystać z Doctrine natomiast testy jednostkowe pisał przy użyciu PHPSpec.

Dobór narzędzi bardzo standardowy jak na obecne czasy i za razem bardzo trafiony, gdyż PHPSpec praktycznie od razu powiedział mu, że coś robi źle. Problem w tym, że on tego przekazu nie potrafił zrozumieć i myślał, że popełnił błąd w specyfikacji.

Po kilku zdaniach wstępu pokazał specyfikację usługi rejestrującej użytkownika, w zasadzie tylko jednego scenariusza tej specyfikacji, mianowicie "poprawna rejestracja nowego konta".

class RegistrationServiceSpec extends ObjectBehavior
{
    function it_registers_user(Registry $doctrine, EntityManager $entityManager, Form $form)
    {
        $doctrine->getEntityManager()->willReturn($entityManager);

        $form->getData()->willReturn(['username' => 'norzechowicz', 'password' => 'pass123']);

        $entityManager->persist(Argument::type('Custom\Entity\User'))->shouldBeCalled();
        $entityManager->flush()->shouldBeCalled();

        $this->register($form)->shouldReturn(true);
    }
}

Ten kod nie działa.

Powyższą specyfikację spełniać miał poniższy kawałek kodu.

class RegistrationService
{
    public function register(Form $form)
    {
        $data = $form->getData();
        $user = new User($data['username']);
        $user->setPassword($data['password']);

        $this->doctrine->getEntityManager()->getConnection()->beginTransaction();

        try{
            $this->doctrine->getEntityManager()->persist($user);
            $this->doctrine->getEntityManager()->flush();
            $this->doctrine->getEntityManager()->commit();
        } catch(\Exception $e){
            $this->doctrine->getEntityManager()->rollback();
            $this->doctrine->getEntityManager()->close();

            return false;
        }

        return true;
    }
}

Ten kod nie działa.

W powyższym przykładzie złamano chyba wszystkie możliwe reguły SOLID. Testy jednostkowe oczywiście nie przeszły, PHPSpec zaczął krzyczeć, że metoda EntityManager::getConnection() jest wykonywana na mocku $entityManager jednak nie jest to spodziewane zachowanie.

Nie mockuj nie swojego kodu!

Błąd wynikał z tego, że mock klasy EntityManager nie miał zdefiniowanego zachowania dla wywołania metody getConnection(). Można by to naprawić dodając w specu $entityManager->getConnection()->shouldBeCalled() jednak to spowodowałoby błąd mówiący o tym, że nastąpiła próba wywołania metody beginTransaction() na czymś, co obiektem nie jest lub jest nullem. shouldBeCalled można by zastąpić willReturn($connection) a następnie zmockować zachowanie samego obiektu Connection dodając mu wywołanie metody beginTransaction jednak to znacznie skomplikowałoby specyfikację. Co więcej dalej brakowałoby opisów wywołań metod takich jak EntityManager::persist(), EntityManager::flush() oraz EntityManager::commit().

Celem specyfikacji jest określenie w jaki sposób ma się dany obiekt zachowywać.

W tym konkretnym przypadku zachowanie obiektu RegistrationService jest bardzo proste.

Usługa RegistrationService ma zarejestrować użytkownika na podstawie przekazanych danych.

Mockując zachowania klas takich jak EntityManager czy Connection specyfikacja komplikuje się, co prowadzi w rezultacie do zmniejszenia jej czytelności. W powyższym przypadku, zanim w kodzie doszłoby do faktycznego problemu, który ma zostać opisany, najpierw trzeba by napisać sporo całkowicie zbędnego kodu, aby przygotować mocki do działania.

Zmniejszenie czytelności nie jest jednak największym problemem w przypadku mockowania zachowań zewnętrznych bibliotek. Mockując kod nie należący do nas, w zasadzie generujemy zbędne testy dające fałszywe poczucie bezpieczeństwa. Jeżeli w zewnętrznej bibliotece dojdzie do jakichkolwiek zmian, przykładowo podczas którejś z aktualizacji test jednostkowy, który powstał z specyfikacji, nie wyłapie ewentualnych regresji. W końcu mocki określiły jakie metody w jakiej kolejności mają się wykonać, nasz kod spełnia specyfikację, jednak nie gwarantuje, że to w ogóle działa. Przecież mogło dojść do zmiany, która wymusza inną kolejność metod lub wywołanie metod dodatkowych. O tym dowiedzielibyśmy się dopiero w testach funkcjonalnych lub manualnie po deployu, czego oczywiście nie chcemy.

Repository Pattern

Pierwszy z pomocą przychodzi wzorzec Repozytorium. W skrócie wzorzec ten wprowadza byt o nazwie repozytorium, który jest niczym innym jak kolekcją konkretnych obiektów, w tym przypadku kolekcję użytkowników. Rejestracja jest w końcu niczym innym jak dodaniem użytkownika do kolekcji już istniejących użytkowników.

Interfejs takiej kolekcji przedstawiać się będzie następująco

interface UserRepository
{
    function add(User $user);
}

Jego implementacja przy użyciu Doctrine ORM może wyglądać na przykład tak:

class DoctrineUserRepository implements UserRepository
{
    private $doctrine; 

    public function __construct(Registry $doctrine)
    {
        $this->doctrine = $doctrine;
    }

    function add(User $user)
    {
        $this->doctrine->getEntityManager()->getConnection()->beginTransaction();

        try{
            $this->doctrine->getEntityManager()->persist($user);
            $this->doctrine->getEntityManager()->flush();
            $this->doctrine->getEntityManager()->commit();
        } catch(\Exception $e){
            $this->doctrine->getEntityManager()->rollback();
            $this->doctrine->getEntityManager()->close();

            throw $e;
        }
    }
}

Powyższy kod jest bezpośrednio wyciągnięty z RegistrationService. Doctrine ORM sam w sobie dostarcza repozytoria.

Mając repozytorium oraz jego implementację, specyfikacja z początku tego wpisu przedstawiać się będzie następująco:

class RegistrationServiceSpec extends ObjectBehavior
{
    function it_registers_user(UserRepository $users, Form $form)
    {
        // co mamy na wejściu
        $form->getData()->willReturn(['username' => 'norzechowicz', 'password' => 'pass123']);

        // co powinno się stać
        $users->add(Argument::type('Custom\Entity\User'))->shouldBeCalled();

        // kiedy zrobimy 
        $this->register($form)->shouldReturn(true);
    }
}

Widać różnicę, prawda?

Tylko co z Doctrine? Dlaczego nie testujemy czy repozytorium działa? Cóż, pierwszy powód jest taki, że Doctrine sam w sobie posiada ogromną ilość testów jednostkowych, akceptacyjnych, integracyjnych i funkcjonalnych. Nie potrzebujemy dodatkowo w naszej aplikacji testować czy Doctrine działa.

No dobra, ale skąd wiadomo czy Doctrine jest dobrze użyty? W końcu ktoś mógł popełnić błąd i przykładowo w złej kolejności wykonać flush, persist i commit.

Oczywiście, jednak taką pewność uzyskamy dzięki testom akceptacyjnym lub funkcjonalnym, które nie są tematem tego wpisu (o tym kiedy indziej). Na ten moment, patrząc z poziomu testów jednostkowych (specyfikacje są testami jednostkowymi) nie piszemy testów dla DoctrineUserRepository. Nawet gdybyśmy napisali DoctrineUserRepositorySpec i tak nie uzyskamy gwarancji, że to co zrobiliśmy działa. W tym celu musielibyśmy stworzyć test integracyjny sprawdzający czy w bazie faktycznie został utworzony konkretny rekord.

Factory Pattern

Pierwszy problem rozwiązany, jednak dalej metoda register nie do końca robi to co powinna. public function register(Form $form) ta zależność od komponentu Form jest całkowicie zbędna, wręcz szkodliwa. W końcu bez tego komponentu też powinno się dać stworzyć nowego użytkownika. Interfejsem nie koniecznie musi być formularz, może to być na przykład komenda w konsoli czy jakiś endpoint w API. Gdyby typ Form został jako argument, usługa byłaby związana z protokołem HTTP i komponentem Symfony2 Form.

Patrząc do wnętrza tej metody również na pierwszy rzut oka widać, że jest tam czegoś za dużo.

class RegistrationService
{
    private $users;

    public function __construct(UserRepository $users)
    {
        $this->users = $users;
    }

    public function register(Form $form)
    {
        $data = $form->getData();
        $user = new User($data['username']);
        $user->setPassword($data['password']);

        try {
            $this->users->add($user);
        } catch (\Exception $e) {
            return false;
        }

        return true;
    }
}

W tym prostym przykładzie użytkownik to tylko username jednak to nigdy nie jest tak proste. Nawet jeżeli pierwotne założenie mówi, że podczas rejestracji podajemy tylko username i password to i tak za jakiś czas przyjdzie do programisty ktoś z marketingu i powie, że potrzebują jeszcze adresu email i płci, żeby lepiej targetować reklamy.
Co więcej, hasło trzeba dodatkowo zabezpieczyć, w tym celu należy wygenerować hash na podstawie hasła. Obecna konstrukcja uniemożliwia rozszerzenie tych funkcjonalności bez modyfikacji kodu RegistrationService, jest to niezgodne z Open Closed Principle, zasadą, która mówi że kod powinien być otwarty na rozszerzenia ale zamknięty na zmiany.

Tutaj z pomocą przychodzi wzorzec fabryki. Jak nietrudno się domyślić fabryka zajmuje się konstrukcją obiektów.

Istnieją dwie popularne implementacje wzorca fabryki.

Static Factory Method

class User
{
    private $username;

    private $password;

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

    public static function create(array $data)
    {
        $user = new User;
        $user->username = $data['username'];
        $user->password = $data['password'];

        return $user;
    }
}

Korzysta się z tego w następujący sposób:

$user = User::create(['username' => 'norzechowicz', 'password' => 'pass123']);

Polecam poczytać również Named Constructors in PHP napisane przez @mathiasverraes

Factory Method


interface UserFactory { public function createUser(array $data); } class SecuredPasswordUserFactory implements UserFactory { public function createUser(array $data) { $user = new User($data['username']); // md5 ma tylko pokazać, że tu gdzieś możemy wygenerować hash dla hasła $user->setPassword(md5($data['password'])); return $user; } }

Pierwszy rodzaj fabryki lepiej sprawdza się w przypadku Value Objects. W naszym przypadku, kiedy konstruowana jest encja lepiej skorzystać z osobnej klasy.

W tym konkretnym przypadku, poza utworzeniem użytkownika, powinniśmy jeszcze wygenerować hash z podanego hasła.

md5 oczywiście nie jest tym czego powinno się tutaj używać, poprawnie byłoby stworzyć interfejs PasswordEncoder oraz odpowiednią implementację, przykładowo BCryptPasswordEncoder.

Oto jak po implementacji wzorca fabryki będzie wyglądać specyfikacja RegistrationServiceSpec

class RegistrationServiceSpec extends ObjectBehavior
{
    function it_registers_user(UserRepository $users, UserFactory $factory)
    {
        // co mamy na wejściu
        $inputData = ['username' => 'norzechowicz', 'password' => 'pass123'];

        // co powinno się stać
        $factory->createUser($inputData)->willReturn(new User('norzechowicz'));
        $users->add(Argument::type('Custom\Entity\User'))->shouldBeCalled();

        // kiedy zrobimy 
        $this->register($inputData)->shouldReturn(true);
    }
}

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

class RegistrationService
{
    private $users;

    private $userFactory;

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

    public function register(array $data)
    {
        $user = $this->userFactory->createUser($data);

        try {
            $this->users->add($user);
        } catch (\Exception $e) {
            return false;
        }

        return true;
    }
}

W ten sposób usługa RegistrationService zajmuje się dokładnie tym czym powinna się zajmować, mianowicie na podstawie otrzymanych danych koordynuje utworzenie nowego użytkownika oraz dodaje go do kolekcji już istniejących użytkowników.

Na sam koniec zostaje jeszcze kwestia zwracania wartości true/false przez metodę register.

Generalnie jest to niepotrzebne, a wręcz niepoprawne zachowanie. W sytuacji krytycznej, tzn. gdy podczas dodawania użytkownika dojdzie do błędu, exception jest odpowiednią reakcją. Poza tym w ten sposób pytamy usługę RegistrationService czy zarejestrowała użytkownika, podczas gdy powinniśmy jej po prostu powiedzieć żeby to zrobiła. Tell Dont Ask Principle

Podsumowując, specyfikacja oraz sam SUS (Subject Under Specification) finalnie może wyglądać w ten sposób:

class RegistrationServiceSpec extends ObjectBehavior
{
    function it_registers_user(UserRepository $users, UserFactory $factory)
    {
        // co mamy na wejściu
        $inputData = ['username' => 'norzechowicz', 'password' => 'pass123'];

        // co powinno się stać
        $factory->createUser($inputData)->willReturn(new User('norzechowicz'));
        $users->add(Argument::type('Custom\Entity\User'))->shouldBeCalled();

        // kiedy zrobimy 
        $this->register($inputData);
    }
}

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));
    }
}
Masz pytanie?
Napisz: kontakt@zawarstwaabstrakcji.pl

Feedback

Podobał Ci się ten wpis? Bardzo mnie to cieszy. Nie ma większej motywacji niż zadowolony odbiorca. Gdybyś uznał, że treść którą tworzę jest wartościowa byłbym wdzięczny za zostawienie recenzji na Facebooku. Taka recenzja nie tylko zmotywuje mnie do dalszej pracy ale pozwoli również zyskać większy zasięg. Zasięg, który pozwoli mi dotrzeć do większej grupy odbiorców.
Możesz też polubić fanpage, na którym staram się w miarę regularnie publikować krótsze ale nie mniej wartościowe treści.

Akceptuję

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