Encja powinna być zawsze poprawnym obiektem
Bardzo często w projektach z użyciem Doctrine, encja wygląda w ten sposób, że zrobione jest mapowanie odpowiednich pól, oraz do każdego pola utworzone są gettery oraz settery. Dodatkowo do każdego pola mamy odpowiednie adnotacje walidacji, a formularze walidowane są na encji. Czy to na pewno jest dobre podejście?
<?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** * Class User * @package App\Entity * * @ORM\Table(name="users") * @ORM\Entity(repositoryClass="App\Repository\User\UserRepository") * @UniqueEntity(fields={"email"}) */ class User { /** * @var int * @ORM\Column(type="integer") * @ORM\Id */ private $id; /** * @var string * @ORM\Column(type="string", length=64) * @Assert\Length(min="8", max="4096") */ private $password; /** * @var string * @ORM\Column(type="string", length=80, unique=true) * @Assert\Email() */ private $email; /** * User constructor. */ public function __construct() { } /** * @return int */ public function getId(): int { return $this->id; } /** * @return string */ public function getPassword(): string { return $this->password; } /** * @param string $password */ public function setPassword(string $password): void { $this->password = $password; } /** * @return string */ public function getEmail(): string { return $this->email; } /** * @param string $email */ public function setEmail(string $email): void { $this->email = $email; } }
<?php namespace App\Form; use App\Entity\User; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** * Class UserType * @package App */ final class UserType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('email', EmailType::class) ->add('password', RepeatedType::class, [ 'type' => PasswordType::class ]) ; } /** * {@inheritdoc} */ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => User::class ]); } }
Powyższy kod pokazuje jak w większości projektów wygląda implementacja encji doctrinowych.
Encja to nie struktura danych
W podanym przykładzie zaprezentowany obiekt w zasadzie pełni podobną funkcję jak tablica. Nie posiada żadnych zachowań, tylko zwykłe przechowywanie, ustawianie i pobieranie wartości. Do zachowań można byłoby tutaj zaliczyć zmianę hasła, więc w zasadzie setPassword jest w porządku, jedynie może lepsza byłaby nazwa changePassword. Zakładamy, że email jest ustawiany raz podczas rejestracji i nie można go później zmienić. Więc setter dla pola email jest zbędny.
Walidacja
Encja powinna być zawsze poprawnym obiektem, aby nie było sytuacji, gdzie nieprawidłowe dane zostaną zapisane w bazie danych. W związku z tym walidacja przesłanych danych powinna się zawierać w pomocniczym obiekcie np. DTO (Data Transfer Object).
Dodatkowo jeśli chcemy używać formularzy symfonowych z encjami, musimy modyfikować type hinty, tak aby były wstanie przyjąć nieprawidłowe dane w celu ich sprawdzenia i wyrzucenia odpowiedniego błędu. Co stanowi kolejny powód, aby walidacja odbywała się na innym obiekcie.
Podsumowując, adnotacje walidacji trzymamy w innym obiekcie, z formularzy korzystamy przypisując jako data_class ten obiekt, a nie encję. Encja jest tworzona z tego obiektu dopiero po jego zwalidowaniu, dzięki temu mamy pewność, że w każdym momencie działania aplikacji jest ona poprawna.
Konstruktory
Z racji, że encja powinna być zawsze poprawnym obiektem, nie powinna być ona tworzona przez utworzenie obiektu korzystając z konstuktora, a następnie uzupełnienie odpowiednich pól korzystając z setterów. Konstruktor powinien przyjmować takie parametry, aby można było stworzyć poprawny obiekt tj. zgodny z narzuconymi ograniczeniami. W php nie mamy możliwości przeciążania metod, więc dla tworzenia obiektów w różny sposób, na podstawie różnych parametrów dobrze jest utworzyć tak zwane named constructors tj. statyczne metody zwracające obiekt klasy w jakiej się zawierają.
Jak to powinno wyglądać?
<?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Ramsey\Uuid\Uuid; use App\User\Command\RegisterUserCommand; /** * Class User * @package App\Entity * * @ORM\Table(name="users") * @ORM\Entity(repositoryClass="App\Repository\User\UserRepository") */ class User { /** * @var string * @ORM\Column(type="string") * @ORM\Id */ private $id; /** * @var string * @ORM\Column(type="string", length=64) */ private $password; /** * @var string * @ORM\Column(type="string", length=80, unique=true) */ private $email; /** * User constructor. * @param string $password * @param string $email */ public function __construct(string $password, string $email) { $this->id = Uuid::uuid4(); $this->password = $password; $this->email = $email; } /** * @param RegisterUserCommand $registerUserCommand * @return User */ public static function fromRegisterUserCommand(RegisterUserCommand $registerUserCommand): User { return new self($registerUserCommand->email, $registerUserCommand->password); } /** * @return int */ public function getId(): int { return $this->id; } /** * @return string */ public function getPassword(): string { return $this->password; } /** * @param string $password */ public function changePassword(string $password): void { $this->password = $password; } /** * @return string */ public function getEmail(): string { return $this->email; } }
<?php namespace App\Form; use App\Entity\User; use App\User\Command\RegisterUserCommand; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PasswordType; use Symfony\Component\Form\Extension\Core\Type\RepeatedType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** * Class UserType * @package App */ final class RegisterUserType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('email', EmailType::class) ->add('password', RepeatedType::class, [ 'type' => PasswordType::class ]) ; } /** * {@inheritdoc} */ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => RegisterUserCommand::class ]); } }
<?php namespace App\User\Command; use Symfony\Component\Validator\Constraints as Assert; use App\Common\Validator\Constraint\UniqueField\UniqueField; /** * Class RegisterUserCommand * @package App\User\Command */ final class RegisterUserCommand { /** * @var string * * @Assert\Email() * @UniqueField(entityClass="App\Entity\User\User", field="email") */ public $email; /** * @var string * * @Assert\Length(min="8", max="4096") */ public $password; }
Z encji wyleciały wszystkie adnotacje walidacji. Pojawiło się użycie uuid zamiast auto increment, co jest konieczne do tego, aby encja była poprawnym obiektem w każdym momencie działania aplikacji. W przypadku używania auto increment, id, które musi być unikalne, przyznawane jest dopiero podczas zapisywania rekordu w bazie danych, więc w trakcie samego utworzenia obiektu jest on niepoprawny, gdyż nie zawiera żadnego id. Tutaj warto dodać, że w myśl stworzenia dopracowanego modelu, powinno się zadbać również o zapewnienie, że obiekt będzie zawierał tylko poprawne dane, czyli dodatkowo powinno być sprawdzane czy email jest faktycznie poprawnym adresem oraz czy hasło jest odpowiedniej długości.
W formularzu zmieniło się tylko ustawienie opcji data_class. Obecnie walidacja odbywa się na klasie RegisterUserCommand. Tak jak było podane wcześniej, może być to jakiś DTO, w tym przykładzie zaprezentowane jest jednak użycie komendy.
Do klasy RegisterUserCommand przeniesione zostały adnotacje ograniczeń. Warto tutaj zwrócić uwagę na adnotację UniqueField. Z racji, że RegisterUserCommand nie jest encją doctrinową nie można zastosować Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity, więc w celu zwalidowania unikalności adresu email należy napisać własne ograniczenie i walidator.
use Symfony\Component\Validator\Constraint; /** * Class UniqueField * @package Gdata\CoreBundle\Validator\Constraint\UniqueField * * @Annotation */ class UniqueField extends Constraint { /** * @var string */ public $message = 'This value is already used.'; /** * @var string */ public $entityClass; /** * @var string */ public $field; /** * @return string[] */ public function getRequiredOptions(): array { return ['entityClass', 'field']; } /** * @return string */ public function getTargets(): string { return self::PROPERTY_CONSTRAINT; } /** * @return string */ public function validatedBy(): string { return get_class($this).'Validator'; } }
use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; /** * Class UniqueFieldValidator * @package Gdata\CoreBundle\Validator\Constraint\UniqueField */ class UniqueFieldValidator extends ConstraintValidator { /** * @var EntityManagerInterface */ private $entityManager; /** * UniqueFieldValidator constructor. * @param EntityManagerInterface $entityManager */ public function __construct(EntityManagerInterface $entityManager) { $this->entityManager = $entityManager; } /** * @param mixed $value * @param Constraint $constraint */ public function validate($value, Constraint $constraint): void { $entityRepository = $this->entityManager->getRepository($constraint->entityClass); if (!is_scalar($constraint->field)) { throw new \InvalidArgumentException('"field" parameter should be any scalar type'); } $searchResults = $entityRepository->findBy([ $constraint->field => $value ]); if (count($searchResults) > 0) { $this->context->buildViolation($constraint->message) ->addViolation(); } } }
Dzięki, mam nadzieję, że będzie więcej o DDD w Symfony. Sam chcę się wdrożyć w temat, lecz wciąż za mało czasu…
Prawdopodobnie za jakiś czas będzie coś więcej, aczkolwiek na nadmiar czasu również narzekać nie mogę 😀
Fajny art, ale czemu ta nowa klasa ma Command w nazwie? To nie jest chyba wzorzec Command?
Przykłady wziąłem z systemu, który rozwijam z użyciem CQS (Command Query Separation). W zasadzie to nie jest taki stricte wzorzec Command, ale razem z CommandHandlerem spełnia to samo zadanie co wzorzec Command. W tym wypadku RegisterUserCommand to tylko struktura danych.
Jak w tym przypadku modyfikować np. jedno pole? Załóżmy, że mamy przy encji User pole $isActive. W jaki sposób najlepiej je modyfikować np. z poziomu formularza Symfony? Rozumiem, że również przez DTO, ponieważ w Encji nie mamy setterów ani getterów.
Można zrobić w encji metodę activate(), gdzie po prostu aktywujesz użytkownika.
W podanym przykładzie nie widzę jak robisz szyfrowanie hasła. Pytam o Twoje podejście, ponieważ Symfonowy PasswordEncoder aby wygenerować zaszyfrowane hasło potrzebuje instancji klasy Usera natomiast masz password w konstruktorze. Czy przy tworzeniu klasy User podajesz mu pustego stringa a potem nadpisujesz po wygenerowaniu zaszyfrowanego hasła czy masz jakiś inny sposób na to?
W projekcie, z którego są te przykłady mam to rozwiązane tak:
$user = User::fromRegisterUserCommand($registerUserCommand);
$password = $this->userPasswordEncoder->encodePassword($user, $registerUserCommand->password);
$user->setPassword($password);
A jak poradzić sobie z zagnieżdżeniem formularzy i relacjami? Wszystkie przykłady są oparte o płaską strukturę co nijak ma się do rzeczywistości.
Jaki z tym problem, bo nie bardzo rozumiem? Przykłady są bardzo proste oczywiście, bo to jest krótki artykuł, a nie książka. Command to po prostu DTO, walidujesz otrzymane dane na tym obiekcie, a dalej możesz uzupełniać kilka encji przekazując odpowiednie rzeczy z tego DTO. Oczywiście kwestia w jaki sposób jest aplikacja napisana, bo jeśli stosujesz podejście DDD to raczej wszystko powinno przejść przez Aggregate Root.
Problem jest taki, że tworzysz masę dodatkowej roboty, którę robi za Ciebie data object mapper komponentu form. Wyobraź sobie, że masz DTO z 10 relacjami, gdzie musisz sprawdzić, czy użytkownik dodał jakiegoś taga, dodał video, zmienił opis video i wszystko w jednym formularzu. Symfony załatwia to przez Collections i mapowanie encji oraz samo rozpoznaje zmiay wprowadzone przez użytkownika. W DTO musisz stworzyć cały komponent do zarzadzania zmianami w relacjach. Każdy przykład chwalący DTO operuje a płaskich relacjach, bo własnie z tymi złożonymi jest największy problem.
Dodatkowo nie da się tutaj w prosty sposób walidować unikalności encji w bazie danych.
Ot sztuka dla sztuki.
Owszem jest to sztuka dla sztuki tylko jak masz CRUDa z małą ilością logiki biznesowej. Jeśli to jest CRUD to jak najbardziej anemiczny model i walidacja na encji jest dopuszczalna, nie będę tutaj się sprzeczał na ten temat.
Natomiast jak wchodzisz w jakieś koncepcje typu DDD, CQRS czy Event Sourcing to nie ma mowy o takim podejściu jak mówisz i poleganiu na tej magii frameworka. Przygotowanie takiego rozwiązania jest owszem trudniejsze, bardziej złożone, ale w dłuższej perspektywie masz z tego korzyści w postaci możliwości skalowania rozwiązania w przypadku CQRS, w przypadku DDD masz odpowiednią separację logiki biznesowej od infrastruktury itd.
Wszystko zależy od projektu, a jeśli masz mnóstwo różnych relacji to może jednak coś jest nie tak i wypadałoby wydzielić osobne elementy.