Co to jest obiekt wartości (value object) i skąd się wziął.
We wpisie skupię się bardziej na zastosowaniu obiektów wartości w Symfony, niż na przedstawieniu dokładnie wszystkich i cech, zalet i wad. Jeżeli temat jest Ci zupełnie obcy i nie rozwieję wszystkich twoich wątpliwości, to warto rozejrzeć się po internecie. Jest mnóstwo świetnych materiałów na ten temat, kilka postaram się podlinkować na koniec. Wzorzec obiektu wartości (ang. Value object), w skrócie VO, wywodzi się z metodologii Domain Driven Development zdefiniowanej przez Erica Evansa. Jest jednak bardzo uniwersalny i można go z powodzeniem wykorzystywać bez całego bagażu DDD i czerpać z korzyści jakie ze sobą niesie.
Value object jest wykorzystywany do reprezentowania obiektów które nie posiadają żadnej specjalnej tożsamości ( w przeciwieństwie do Encji), a w związku z tym żadnego wyróżnionego identyfikatora. Przykładem takiego obiektu może być adres składający się z ulicy, numeru domu, kodu pocztowego i miasta. Żeby sprawdzić czy dwa adresy są takie same, nie potrzebujemy żadnego pola typu id, wystarczy że porównamy wszystkie jego elementy składowe. VO wykorzystujemy do reprezentowania wartości które są składowymi encji, które jednak mają swoje własne reguły bądź agregują kilka pól jak wspomniany adres czy obiekt reprezentujący pieniądze składający się z wartości i waluty.
Cechy VO
W moim przypadku najlepiej idzie mi 'załapywanie’ różnych konceptów i idei w oparciu o przykłady, zacznijmy więc od przykładu:
class Money
{
private int $value;
private string $currency;
public function __construct(int $value, string $currency)
{
if (strlen($currency) !== 3) {
throw new \InvalidArgumentException('Invalid currency.');
}
$this->value = $value;
$this->currency = $currency;
}
public function value(): int
{
return $this->value;
}
public function currency(): string
{
return $this->currency;
}
public function toString(): string
{
return $this->value.$this->currency;
}
public function isEqual(Money $otherMoney): bool
{
return $this->currency === $otherMoney->currency()
&& $this->value === $otherMoney->value();
}
public function formatMoney(): string
{
return sprintf('%01.2f %s', ($this->value / 100), $this->currency);
}
}
Co można powiedzieć o tym obiekcie? Zapewne jedną z pierwszych rzeczy które mogą rzucić się w oczy jest brak setterów, ani żadnych innych metod modyfikujących stan obiektu po jego utworzeniu. Stan pozwala nam określić jedynie konstruktor. Wynika to z tego że VO powinny być niemutowalne (ang. immutable). Dzięki mamy temu mamy pewność, że nikt w innym miejscu kodu nie zmodyfikuje nam wartości obiektu w wyniku czego otrzymamy nagle wartości z kosmosu. Co jeżeli potrzebujemy zmodyfikować wartość przechowywaną w obiekcie? Tworzymy po prostu nową wersję obiektu.
Obiekty wartości powinny być również samowalidujące. Walidacja powinna odbywać się w momencie tworzenia obiektu. Jeżeli obiekt utworzy się prawidłowo bez zgłoszenia wyjątku, możemy bezpiecznie przekazywać go dalej i zawsze spodziewać się po nim przewidywalnego zachowania zgodnego z regułami zdefiniowanego typu. Daję nam to pewność że nie utworzymy np. daty 32 grudnia.
Trzecim elementem wartym omówienia jest porównywanie VO. Jak wspomniałem wcześniej, obiekty wartości nie posiadają identyfikatora za pomocą którego można by sprawdzać ich równość. Klasy reprezentujące VO posiadają na ogół zdefiniowane metody pozwalające porównać je z innym VO tego samego typu za pomocą wartości atrybutów obiektów (patrz metoda isEqual)
VO mogą a nawet powinny również posiadać metody określające inne zachowania pasujące do danego typu. Jeżeli musisz wykonać zadanie typu np. sformatowanie adresu do odpowiedniej postaci, to zamiast wyrzucać tą metodę do oddzielnego serwisu, warto rozważyć umieszczenie jej wewnątrz klasy obiektu wartości.
Wykorzystanie VO z Doctrine
Czas przejść do sedna. Jak wykorzystać VO w aplikacji napisanej w Symfony. Na szczęście nie jest to szczególnie trudne, choć wymaga od nas trochę dodatkowej pracy. Po pierwsze trzeba się zastanowić jak przechowywać takie obiekty w bazie danych. Jak wspominałem wcześniej, VO najczęściej pełnią rolę atrybutów w Encjach. Doctrine na szczęście jest na tą sytuację gotowy i oferuje nam typ 'Embeddable’. https://www.doctrine-project.org/projects/doctrine-orm/en/2.9/tutorials/embeddables.html. Możemy dodać odpowiednie adnotacje do naszej klasy VO:
/** * Class Money * @ORM\Embeddable */ class Money { /** * @ORM\Column(type="integer") */ private int $value; /** * @ORM\Column(type="string") */ private string $currency; ... }
Jak widać, dla klasy dodajemy adnotację definiującą ją jako tym Embeddable zamiast Entity. Kolumny mapujemy tak samo jak w zwykłej encji. Następnie możemy użyć tego typu w encji:
/**
* Class Product
* @ORM\Entity
*/
class Product
{
...
/**
* @ORM\Embedded(class="App\ValueObject\Money", columnPrefix=false)
*/
private $price;
...
}
Musimy pamiętać o tym, że teraz Encja zawiera w sobie obiekt a nie tylko pola typów prostych. Ma to wpływ na to jak korzystamy z tego pola np. w konfiguracji, czy przy budowaniu zapytań:
$productRepository->findOneBy(['price.currency' => 'PLN']);
security:
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email.email
W drugim przykładzie mamy VO Email z polem email. To VO jest częścią encji User. Następnie konfigurujemy Symfony aby używała tego pola do wyszukiwania użytkowników przez moduł security w pliku security.yaml.
Wykorzystanie VO z Symfony Forms
Integracja obiektów wartości z formularzami Symfony jest chyba najbardziej pracochłonna. Wymaga zdefiniowania FormType’a reprezentującego nasz VO, oraz zaimplementowania dla niego mechanizmu data mapper. https://symfony.com/doc/current/form/data_mappers.html. Wynika to przede wszystkim z braku setterów i getterów z których normalnie formularze korzystają. Przykładzik dla VO reprezentującego Email:
use App\ValueObject\Email; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\DataMapperInterface; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class EmailType extends AbstractType implements DataMapperInterface { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('email', \Symfony\Component\Form\Extension\Core\Type\EmailType::class) ->setDataMapper($this); } public function mapDataToForms($viewData, iterable $forms) { if (null === $viewData) { return; } $forms = iterator_to_array($forms); $forms['email']->setData($viewData->toString()); } public function mapFormsToData(iterable $forms, &$viewData) { $forms = iterator_to_array($forms); $viewData = new Email($forms['email']->getData()); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults( [ 'empty_data' => null, ] ); } }
Rozłóżmy go na czynniki pierwsze. Po pierwsze implementujemy interfejs DataMapperInterface. Zawiera on dwie metody mapDataToForms oraz mapFormsToData. Będą one odpowiedzialne za tłumaczenie naszego obiektu na pola w formularzu oraz pól formularza na nasz obiekt.
W mapDataToForms ustawiamy odpowiednie pola w formularzu na odpowiednie wartości z naszego VO. Parametr viewData będzie przechowywał nasz obiekt wartości:
$forms['email']->setData($viewData->toString());
Jeżeli obiekt jeszcze nie istnieje to po prostu wychodzimy z metody i zostawiamy pola puste:
if (null === $viewData) {
return;
}
W drugiej metodzie mapFormsToData tworzymy nasz VO na podstawie pól formularza. Warto zwrócić uwagę, że tym razem parametr $viewData jest przekazywany przez referencję. https://www.php.net/manual/en/language.references.pass.php. Nasza modyfikacja jest więc widoczna poza metodą i nie zwracamy z niej żadnego wyniku:
$viewData = new Email($forms['email']->getData());
Możemy teraz użyć naszego VO i zdefiniowanego FormType’a w formularzu:
use App\Form\EmailType; ... $user = new User(); $form = $this->createFormBuilder($user) ->add('email', EmailType::class) ->add('submit', SubmitType::class) ->getForm();
Ostatnia rzecz na którą należy zwrócić uwagę, to że nasz nowy typ pola formularza jest typu compound. Pole compound oczekuje, że trafią do niego dane w postaci tablicy. Musimy więc odpowiednio wyrenderować nasz formularz, aby dane przeznaczone dla naszego VO tworzyły tablicę:
{{ form_start(form) }} ... {{ form_row(form.email.email) }} ... {{ form_end(form) }}
I to już prawie wszystko. Na koniec zostawiam linki do których warto zajrzeć aby pogłębić swoją wiedzę w temacie.
a jak to będzie z choiceType gdzie choices to ValueObjects ?
Z tego co widzę w dokumentacji nie powinno być tu większej różnicy jak dla dowolnego innego obiektu https://symfony.com/doc/current/reference/forms/types/choice.html#advanced-example-with-objects