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.