Danyloff

Как использовать Value Object в Laravel и почему вы обязаны это делать


  • ·

· ·
·

Небольшой простой объект, как деньги или диапазон дат, равенство которых не основано на идентичности

Мартин Фаулер

Если вы еще не используете Value Object в своем Laravel проекте то скорее всего атрибуты вашей модели выглядят бесвязно, а их использование ничем не отличается от работы с обычным массивом. И благо если вы скрыли манипуляции с такими моделями в сервисе.

Тем кто не знаком с данным концептом, предлагаю сначала прочитать статью ниже

Давайте для примера расмотрим типичную модель User без объектов значений

/**
 * @property int $id
 * @property string $name
 * @property float $balance_amount
 * @property string $balance_currency
 * @property string $email
 * @property string $password
 */
final class User extends Model
{
    protected $table = 'users';
}

Что мы здесь видим(иногда разработчики пренебрегают использованием аннотаций из-за чего приходиться проваливаться в миграции или использовать клиент для базы данных чтобы узнать схему таблицы)?

  • $id — Идентификатор пользователя
  • $name — Имя пользователя
  • $balance_amount — Сумма баланса
  • $balance_currency — Валюта баланса
  • $email — имейл пользователя
  • $password — Пароль

Что не так в данной модели? Атрибуты модели выглядят разрозненными, когда должны представлять концептуально целое(напр. Баланс). Проблемы возникают когда требуется валидировать либо изменять такие атрибуты.

Давай попробуем все же обойтись без объектов значений и провалидируем данные прямо в модели.

Создадим к примеру именнованный конструктор в той же модели User

class User extends Model
{
    protected $table = 'users';

    public static function create(
        string $name,
        float $balanceAmount,
        string $balanceCurrency,
        string $email,
        string $password,
    ): static {
        if ($balanceAmount < 0) {
            throw new \InvalidArgumentException('Invalid balance amount');
        }
        if (! in_array($balanceCurrency, ['EUR', 'USD'])) {
            throw new \InvalidArgumentException('Invalid balance currency');
        }
        if (! in_array($lang, ['en', 'ua'])) {
            throw new \InvalidArgumentException('Invalid lang');
        }
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Email');
        }
        if (! in_array($role, ['user', 'admin'])) {
            throw new \InvalidArgumentException('Invalid role');
        }
        if (strlen($password) < 6) {
            throw new \InvalidArgumentException('Invalid role');
        }
    }
}

Выглядит ужасно, не правда ли?! Конечно можно вынести валидацию в другой класс, и вообще логику создания модели в сервис либо в фабрику но, куда бы вы не вынесли, данный код будет одинаково ужасен. Вы можете предложить(а мне частенько это предлагали) воспользовать средствами валидатора Laravel. Если вы об этом подумали значит вы мыслите что вашим приложением будут пользоваться только через HTTP(Браузер, Postman etc.). Это не так! Частенько разработчикам требуется создавать модели из других сервисов, либо через artisan либо еще где то. По этому давайте я покажу как можно сделать по другому.

Для начала создание интерфейс для общих методов в объектах значениях.

interface ValueObject
{
    /**
     * The method returns true if two objects are equal
     */
    public function equals(object $value): bool;
}

В интерфейс ValueObject добавлен метод equals т.к. объекты значения должны иметь возможность сравниваться как неделимые значения.

Теперь создадим первый объект значение для баланса.

final class Balance implements ValueObject
{
    public function __construct(
        private float $amount,
        private string $currency
    ) {
        if (! $this->validateAmount($amount)) {
            throw new \InvalidArgumentException('Amount less then 0');
        }

        if (! $this->validateCurrency($currency))  {
            throw new \InvalidArgumentException('Invalid currency');
        }
    }
    
    private function validateAmount(float $amount): bool
    {
        return ! ($amount < 0);
    }
    
    private function validateCurrency(string $currency): bool
    {
        return in_array($currency, ['USD', 'EUR']);
    }
    
    public function changeAmount(float $amount): self
    {
        return new self($amount, $this->currency);
    }

    public function getAmount(): float
    {
        return $this->amount;
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

    public function equals(object $value): bool
    {
        return $value instanceof self
            && $this->getAmount() === $value->getAmount()
            && $this->getCurrency() === $value->getCurrency();
    }
}

В данном классе мы реализовали геттеры getAmount и getCurrency, changeAmount, и добавили методы для валидацию входящих значений. Объекты этого класса будут полностью иммутабельны, как того рекомендуют авторы данного концепта.

Создадим объект значение для Email. Объект значение для одного аттрибута?! Именно так, я покажу зачем это!

final class Email implements ValueObject
{
    public function __construct(private string $email)
    {
        if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Invalid email');
        }
    }

    public function getDomain(): string
    {
        return substr(strrchr((string) $this, '@'), 1);
    }

    public function __toString(): string
    {
        return $this->email;
    }

    public function equals(object $value): bool
    {
        return $value instanceof self && (string) $this === (string) $value;
    }
}

В данном классе мы реализовали магический метод __toString для получения примитива чтобы не создавать метод getEmail, еще один дополнительный метод getDomain для того чтобы показать как можно расширять функциональность каждого объекта значения по отдельности. Так же этот прием соблюдает принцип GRASP’а.

Ответственность должна быть назначена тому, кто владеет максимумом необходимой информации для исполнения

Информационный эксперт

Интеграция Value Object в Laravel модель

Есть несколько вариантов как интегрировать Value Object в Laravel модель. Расмотрим тот который предпочитаю я, через отдельный класс Cast.

Введите команду

php artisan make:cast BalanceCast

Данная команда создаст файл

<?php

namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class BalanceCast implements CastsAttributes
{
    /**
     * Cast the given value.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return mixed
     */
    public function get($model, string $key, $value, array $attributes)
    {
        return $value;
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return mixed
     */
    public function set($model, string $key, $value, array $attributes)
    {
        return $value;
    }
}

Заполним методы чтобы сообщить как Eloquent’у наложить ваш Value Object в базе данных.

class BalanceCast implements CastsAttributes
{
    /**
     * Cast the given value.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return mixed
     */
    public function get($model, string $key, $value, array $attributes)
    {
        return new Balance(
            $attributes['balance_amount'], 
            $attributes['balance_currency']
        );
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return mixed
     */
    public function set($model, string $key, $value, array $attributes)
    {
        if ($value instanceof Balance) {
            return [
                'balance_amount' => $value->getAmount(),
                'balance_currency' => $value->getCurrency()
            ];
        }
        
        throw new \InvalidArgumentException('Value is not instance Balance');
    }
}

В методе get мы создаем объект Balance из колонок базы данных. В методе set мы вытягиваем данные из объекта Balance и записываем их в базу данных. Вроде бы все просто.

Теперь зарегистрируем наш Cast в модели User.

/**
 * @property int $id
 * @property string $name
 * @property Balance $balance
 * @property string $email
 * @property string $password
 */
class User extends Model
{
    protected $casts = [
        'balance' => BalanceCast::class
    ];
}

В данном коде мы добавили ключ balance в свойство $casts, указали для ключа balance соответствующий Cast(тот который мы создали ранее). Теперь вы можете использовать свойство balance в моделе User.

$user = new User;
$balance = new Balance(2.0, 'USD');
$user->balance = $balance;

Для Email думаю вы уже сами сможете сделать Cast и добавить в модель.

Заключение

Value Object очень интересный концепт но, не рекомендую слишком увлекаться и вешать на каждое свойство по объекту. Всё же некоторые примитивные и простые свойства должны оставаться такими же примитивными и простыми. За дополнительно информацией обращайтесь в документацию Laravel, не забывайте мой блог и читайте книги! Удачи!


Так же интересно

Атрибуты в PHP

Атрибуты в PHP - это новая функциональность, добавленная в версию PHP 8.0, которая позволяет добавлять метаданные к классам, методам, свойствам и константам....

Согласование Laravel и DDD (часть 2)

В предыдущей статье мы пришли к выводу, что при реализации DDD с помощью Laravel, сам фреймворк должен стать нашей новой парадигмой программирования, чтобы...