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ć interfejsPasswordEncoder
oraz odpowiednią implementację, przykładowoBCryptPasswordEncoder
.
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));
}
}