Danyloff

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


  • ·

· ·
·

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

Введение

В различных проектах я оказывался затопленным моделями Eloquent, которые превратились просто в своего рода файлы конфигурации. Каждая модель имела свою долю методов доступа, таких как getFirstnameAttribute(), мутаторы setFullnameAttribute(), методы отношений comments(), области запросов scopeActive(), события модели static::saving(function($model) {…}), и т.д. Не говоря уже о переменных $fillable, $guarded, $visible, $hidden, $appends и даже некоторых, предоставленных пакетами. Моим решением, когда сложность возрастала, было извлечение некоторых функций в отдельные traits. Например, у меня был трейт UserRepository со всеми областями запросов внутри и трейт UserPresenter со всеми методами доступа внутри. Это помогало выжить немного дольше, но мне не нужно говорить вам, что это не делает чудес в долгосрочной перспективе. Более того, в среде, основанной на Domain-Driven, это полностью затмевает семантику объектов моделей, представляя их как набор технических рисунков.

Теперь, когда мы внедрили Eloquent в DDD, мы не магическим образом удалили все эти ограничения. На самом деле, они стали еще более опасными теперь, когда ставится на кон фокус на домен. Мы рассмотрим каждый из этих вызовов и посмотрим, как их преодолеть, если мы сможем их преодолеть. Некоторые жертвы придется принести.

Важно помнить, насколько важно достичь баланса, сохраняя фокус на домене (правило A), при этом оставаясь верным фреймворку и, в частности, активным записям (правило B). Когда ни одно из них не может быть выполнено вместе, мы приоритезируем правило B и останемся верными Laravel.

Неявно “немаппированные” атрибуты

Беспокойная вещь в моделях Eloquent заключается в том, что даже если они полностью пусты, они уже могут многое сделать. Например, можете ли вы сказать мне, какие атрибуты имеет следующий класс?

class Customer extends \Illuminate\Database\Eloquent\Model
{
    //
}

Конечно, нет. Может не быть никаких атрибутов. А может быть сто. Это очень удобно, когда их сто, но не очень удобно в слое домена, где атрибуты объекта должны быть явными, чтобы мы могли рассуждать о них. Что же нам делать?

  • Следует ли нам все равно добавить их в свойства класса и игнорировать $attributes в Eloquent? Без свойства $attributes мы упускаем большую часть возможностей, предоставляемых Eloquent. Мы могли бы также использовать POPO (Plain Old PHP Objects) и мапперы данных.
  • Мы могли бы, по крайней мере, инициализировать эти атрибуты в конструкторе? Мы могли бы, но переопределение конструктора с использованием аргументов приведет к сбоям в большинстве функций Eloquent, так как большинство статических вызовов проксируются на пустую модель с использованием new static, например, магический метод __callStatic.
  • А что насчет использования $fillable и $visible, чтобы сделать их явными? Они на самом деле здесь по причине, и использование их для других целей кажется взломом фреймворка.

Хорошо, допустим, мы принимаем неявные атрибуты. На самом деле здесь есть более глубокая проблема. А что насчет случаев, когда атрибуты домена не соответствуют атрибутам в нашей базе данных? Где-то должно происходить отображение, верно?

  • Как мы справляемся с этим сейчас? Наше свойство $attributes отражает только колонки нашей таблицы. Затем мы используем аксессоры и мутаторы для создания некоторых атрибутов домена на основе некоторых атрибутов базы данных.
class Cargo extends Model
{
    public function getTrackingIdAttribute()
    {
        return $this->id;
    }
    
    public function setTrackingIdAttribute($id)
    {
        $this->id = $id;
    }
}
  • Почему мы не можем сделать так в DDD? Следствием этого будет то, что доменная модель будет знать о базе данных – и возможно обойдет инварианты из наших репозиториев. Доменная модель фактически становится отображателем данных.
  • Решения нет?! 😡 Использование Eloquent в пределах слоя домена, к сожалению, не может удовлетворить все наши ограничения. В этот раз нам придется выбрать сторону: команда DDD или команда Laravel? Правило A или B?

Если вас это как-то успокаивает, то так мы всегда работали с Eloquent. Попытка бороться с этим или использовать пакет, который использует более подходящую для DDD версию Eloquent, только вынудит нас и будущих разработчиков выходить из зоны комфорта. Любая реализация, которая пытается бороться с этим аспектом моделей Eloquent (например, извлекая мутаторы и аксессоры в трейт на уровне приложения), увеличит уровень сложности и дискомфорта для наших программистов Laravel.

Поскольку мы приняли решение остаться верными Laravel, мы примем на себя эту ношу и будем стараться минимизировать отвлечение, которое это производит на домен.

Объекты значения (Value Objects)

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

class Cargo extends Model
{
    public function getDeliverySpecificationAttribute()
    {
        return new DeliverySpecification($this->deadline, $this->destination);
    }

    public function setDeliverySpecificationAttribute($deliverySpecification)
    {
        $this->deadline = $deliverySpecification->getDeadline();
        $this->destination = $deliverySpecification->getDestination();
    }
}

По крайней мере, наши дорогие Value Objects не должны расширять Eloquent и могут иметь роскошь иметь свой собственный конструктор и свойства.

Методы отношения

Еще одна интересная задача. В чем проблема использования традиционного следующего подхода?

class DeliveryHistory extends Model
{
    public function handlingEvents()
    {
        return $this->hasMany(HandlingEvent::class);
    }
}

Мы полностью обходим узкое место, которое предоставляют репозитории. Можно создать и сохранить новое событие обработки без использования интерфейса репозитория HandlingEventRepository, просто используя $deliveryHistory->handlingEvents()->create($databaseData).

Прежде всего, это одна из моих любимых функций Eloquent, и я не хотел бы ее терять. Она кэширует для нас отношения, может выполнять предзагрузку и многое другое. Кроме того, я не вижу необходимости использовать репозиторий каждый раз при получении отношения. Наконец, я буду оспаривать использование репозиториев в целом в следующем разделе.

Однако, если вы действительно хотите использовать репозитории в качестве границ базы данных при использовании методов отношений, мой совет будет использовать эти методы отношений только для доступа к отношениям, а не для их изменения. Всякий раз, когда мы хотим создать, обновить или удалить отношение, мы обращаемся к соответствующему репозиторию. Обратите внимание, что ничто не мешает реализации репозиториев полностью использовать эти методы отношений, например, $model->relationship()->attach().

Репозитории

Перед публикацией этой статьи я сформулировал список рекомендаций и запретов при использовании Eloquent внутри слоя домена. Однако, прежде чем опубликовать его, я решил протестировать свои ограничения на небольшом приложении-заготовке с довольно сложным набором требований, которые могут оправдать использование DDD.

Приложение позволяет пользователям написать начало истории с двумя возможными выборами в конце. Затем другие пользователи могут написать следующую часть истории для каждого из выборов. В результате получается история, похожая на двоичное дерево. Чтобы истории были интересными и увлекательными, приложение имеет набор правил, например, “Пользователи могут поставить лайк или дизлайк любой странице, которую они прочитали. Инициатор истории может исправить страницу, которая имеет отрицательную оценку и более 10 просмотров”.

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

Затем, когда я столкнулся с такими вещами в своем контроллере…

public function show($id)
{
    return view('page.show', [
        'page' => $this->repository->find($id),
    ]);
}

… вместо …

public function show(Page $page)
{
    return view('page.show', compact('page'));
}

… С меня хватит!

Если мы используем Eloquent внутри нашего слоя домена, то неизбежно наш домен уже много знает о нашей базе данных. Слишком много для чистой реализации DDD, это точно. На этом этапе мы не хотим полностью отделять нашу базу данных от нашего домена, но мы хотим минимизировать ее влияние, что Eloquent уже очень хорошо делает.

Я не говорю, что репозитории не должны использоваться вообще внутри слоя домена, но скорее, что мы не должны использовать их по умолчанию без причины. Лично я удалил все интерфейсы репозиториев, классы, привязки и инъекции, и это было хорошо!

Примечание по модулям приложения: Когда я удалил реализации репозиториев, я оказался с пустыми модулями приложения – поскольку мое приложение было недостаточно сложным, чтобы иметь сторонние границы и т. д. В результате я удалил все их папки и буду создавать их заново, когда они мне понадобятся в будущем. Можно, вероятно, разнести папки, такие как Providers, Mails, Policies и т. д. в соответствующие модули приложения, но я считаю это слишком запутанным при возвращении к коду. Я предпочитаю оставаться верным стандартной структуре папок Laravel в этом вопросе.

Query scopes

Локальные области запросов очень полезны для разделения наших запросов на несколько методов, которые можно объединять в цепочку. У моделей Eloquent обычно много таких методов, что со временем может отвлечь нас от нашей модели домена.

Если модель не имеет соответствующего репозитория, я бы просто использовал локальные области действия на этой модели, чтобы сохранить ясность. Однако, когда количество локальных областей действия начинает быть опасно большим, это может быть сигналом для создания репозитория или другого механизма, который извлечет эту логику в другое место. Например, если модель Customer использует много областей действия запросов в качестве фильтров, мы можем извлечь их в объект CustomerFilters, который затем используется в слое приложения.

В основном, используйте их, пока они не доставят вам кошмары, затем будьте креативны.

Presenter responsibility

Поскольку мы разрешили использование accessors и mutators внутри моделей домена, мы можем в теории использовать их для отображения данных, которые нужны только для нашего фронт-энда, например, для объединения имени и фамилии в полное имя и т.д.

Если новое свойство имеет смысл в слое домена и универсальном языке, то я думаю, что мы должны избегать добавления аксессора для него. Мы уже согласились с тем, что Eloquent неизбежно позволит базовые знания в наш слой домена, я бы предпочел избежать добавления некоторых знаний интерфейса. Кроме того, есть несколько вещей, которые мы можем сделать, чтобы справиться с задачей presenter responsibility:

  • Объекты презентера могут быть добавлены в соответствующие модули приложения.
  • Использование интерфейса API (начиная с Laravel 5.5) позволяет определять ресурсы Eloquent, в которых можно задать способ отображения моделей во внешнем мире.
  • Иногда быстрый расчет в контроллере достаточен.

Валидация и авторизация

При реализации правил и модерации в приложении для совместного написания историй, упомянутом выше, я сначала попытался вставить все это в слой домена. В конце концов, это бизнес-требования. Но то, что могло бы легко быть сделано с помощью класса Policy или Request, потребовало гораздо больше усилий и сложности в рамках слоя домена для менее гибкой реализации.

Поэтому, когда речь идет о валидации и авторизации, я думаю, что имеет смысл оставаться верным фреймворку и группировать всю эту логику в одном знакомом месте.

Boot и события модели

Последнее, но не менее важное – статический метод boot() и прослушиватели событий модели, также называемые Магия! ✨

Я люблю такие реализации и часто использую их в своих проектах Laravel или при создании пакетов. Однако, чем больше я возвращаюсь к таким проектам, тем больше я сожалею, что сделал такие вещи. Несмотря на то, что на первый взгляд это выглядит очень привлекательно, когда вам просто нужно вызвать $lead->update() и он позаботится о синхронизации лидов с вашей внешней CRM, записи действия, отправке уведомления менеджеру по продажам и сварит вам чашку чая, с ростом сложности, магия быстро становится неподдерживаемой.

Это еще более критично с учетом того, что Eloquent находится в слое домена. Если нам действительно нужно, чтобы некоторые события модели запускали какую-то бизнес-логику, то мы могли бы рассмотреть некоторые обходные пути, например, создание объекта Observer, который находится в слое приложения.

Заметьте, что это относится и к магическим методам boot() из трейтов внешних пакетов. Они очень полезны, чтобы помочь нам начать работу с тем или иным пакетом, но мы должны быть осторожны, чтобы не затопить наши приложения скрытыми потоками выполнения — в непредсказуемом порядке.

Заключение

Использование активных записей (Active Record) в слое домена – это вызов, который сопровождается жертвами, с которыми многие чистые DDD-приверженцы не могут смириться.

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

Несмотря на то, что полученная реализация далека от достижения всех ценностей Domain-Driven Design, я обнаружил, что извлечение моих моделей в отдельное пространство имен помогло мне стать более креативным и сократить детализацию моих классов.

Написание этой статьи также помогло мне выделить два различных аспекта моделей Eloquent:

  • Конфигурации и атрибуты (которые теперь представляют верхнюю половину моих классов)
class Story {
    protected $with = ['pages'];
    protected $guarded = [];

    public function getFirstPageAttribute() { ... }
    public function pages() { ... }
    public function scopePopular() { ... }
    
    // ...
}
  • Доменная логика (которая теперь находится в нижней половине моих классов)
class Story {
    // ...
    
    public function containsPage($page) { ... }
    public function computeDeeperLevel() { ... }
    public function readBy($user) { ... }
    public function averageVoteFrom($user) { ... }
}

Часть с конфигурациями и атрибутами может показаться слишком сложной для нашего уровня домена, но на мой взгляд, это не более запутанно, чем иметь множество геттеров и сеттеров, сгруппированных в наших классах. Как упоминалось ранее, мы просто используем другую программную парадигму, которая имеет свой собственный способ определения атрибутов.

Источник


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

Шаблон Identity Field с примерами на PHP

Шаблон Identity Field - это один из шаблонов проектирования, который используется для создания уникальных идентификаторов для объектов в базе данных. Этот...