Мои предыдущие статьи о DDD для разработчиков Rails
В первой части я говорил об использовании многоуровневой архитектуры для решения проблемы сложности домена. Я показал несколько типичных нарушений многоуровневой архитектуры и дал несколько советов о том, как их исправить.
Во второй части я начал говорить о строительных блоках доменного дизайна. Я написал о важном различии между сущностями и объектами значения. Я также дал несколько советов о том, как реализовать Value Objects в Rails.
сводные показатели
На этот раз я хотел бы перейти к другому строительному блоку Domain Driven Design. Я хотел бы поговорить об агрегатах.
Мы все сталкивались с такой ситуацией раньше:
Вы начинаете с красиво оформленных групп объектов. Все объекты имеют четкие обязанности, и все взаимодействия между ними являются явными. Затем необходимо учитывать дополнительные требования, такие как транзакции, интеграция с внешними системами, генерация событий. Удовлетворить их всех и не соединить все объекты — нетривиальная задача. Обычно это происходит, когда ловушки базы данных, условные проверки и удаленные вызовы добавляются на специальной основе. Результат — больше связей между объектами. Следовательно, границы групп объектов становятся размытыми, а применение инвариантов становится более сложным. Вспомните все случаи, когда вы думали: «Может быть, мне нужно перезагрузить этот объект?» Это указывает на то, что ваши объекты взаимосвязаны, и вы не можете с уверенностью рассуждать о своем коде. Вместо этого вы просто угадываете.
Определение агрегатов — хорошее средство для описанной ситуации.
- «Агрегат — это кластер связанных объектов, которые рассматриваются как единое целое с целью изменения данных». (См. Ресурсы )
- Агрегат состоит из нескольких Сущностей и Объектов Значения, один из которых выбран как корень Агрегата.
- Все внешние ссылки ограничены рутом. Объекты за пределами Агрегата могут содержать ссылки только на корень.
- Доступ к другим членам Агрегата происходит через корень. Следовательно, никто (кроме Агрегата) не должен содержать ссылки на эти объекты.
- Поскольку все внешние объекты могут содержать ссылку только на корень, применение инвариантов становится проще.
- Агрегаты помогают уменьшить количество двунаправленных ассоциаций между объектами в системе, поскольку вы можете хранить ссылки только на корень. Это значительно упрощает дизайн и уменьшает количество невидимых изменений в графе объектов.
пример
Это может звучать слишком абстрактно, поэтому я хотел бы показать вам пример. Я собираюсь моделировать онлайн книжный магазин. Главной обязанностью модели станет продажа и доставка книг. Надеемся, что этот пример внесет некоторую ясность в определение агрегатов и продемонстрирует, как они могут быть реализованы в Rails.
эскиз
Это эскиз, иллюстрирующий все классы, которые будут формировать модель.
Как видите, у меня есть:
- Объекты: Заказ, Товар, Пользователь, Книга, Оплата
- Объекты значения: Адрес
- Услуги: Сервис отгрузки, Сервис оплаты
- Просмотр: OrderPresenter
Определение совокупных границ
Теперь, когда я закончу с эскизами, я могу установить общие границы и выбрать корни.
Есть несколько практических правил:
- Сущности, формирующие отношения родитель-ребенок, скорее всего, должны образовывать совокупность. В этом случае родительский класс становится корневым.
- Объекты, которые семантически близки друг к другу, также являются хорошими кандидатами для формирования Агрегата. Например, Книга и Оплата не имеют очевидных связей друг с другом. Иметь их внутри агрегата неудобно. С другой стороны, Порядок и Предмет тесно связаны. Таким образом, мы должны рассмотреть возможность размещения их в совокупности.
- Если две сущности должны быть изменены внутри транзакции, они должны быть частью одного и того же агрегата.
Замечания:
Эти правила должны помочь вам начать работу. После вашего первого наброска вы должны посмотреть на все инварианты, которые необходимо сохранить, и завершить свои совокупные границы на их основе.
Как вы уже догадались, Порядок и Предмет образуют Совокупность. Какие еще сущности мы должны включить? Включать Книгу не имеет особого смысла, потому что я легко могу представить клиентов, использующих Книгу без заказа (например, вам может потребоваться отобразить список всех доступных книг в магазине).
Включение пользователя в один агрегат с заказом также не лучшая идея. Представьте, что если пользователь станет пользователем root, вам придется получить доступ ко всем заказам пользователя через самого пользователя. Кроме того, обновление двух заказов одного и того же пользователя одновременно будет непростым делом. Очевидно, что ни Книга, ни Пользователь не должны быть частью Агрегата.
Ситуация с классом Payment отличается. Концептуально оплата является важной частью заказа. Кроме того, вы не можете одновременно оплатить заказ и изменить его. Решено, что Оплата становится частью Агрегата.
Обновленный эскиз с определенной границей:
Реализация
Давайте начнем с кода. Сначала давайте определим классы Book и User:
class Book | |
include DataMapper::Resource | |
property :id, Serial | |
property :title, String | |
property :author, String | |
property :price, Decimal | |
end | |
class User | |
include DataMapper::Resource | |
property :id, Serial | |
property :name, String | |
property :address_country, String | |
property :address_city, String | |
validates_presence_of :name | |
def address= address | |
self.address_country = address.country | |
self.address_city = address.city | |
end | |
def address | |
Address.new(address_country, address_city) | |
end | |
end | |
class Address < Struct.new(:country, :city) | |
end |
Здесь нет ничего действительно интересного. Есть две сущности (книга и пользователь) и один объект значения (адрес). Это становится интересным, когда мы реализуем классы Order и Item:
class Order | |
include DataMapper::Resource | |
property :id, Serial, key: true | |
property :status, Enum[:new, :ready, :paid, :shipped, :closed, :canceled] | |
belongs_to :buyer, ‘User’ | |
has n, :items | |
has 1, :payment | |
property :shipping_address_country, String | |
property :shipping_address_city, String | |
def shipping_address= address | |
self.shipping_address_country = address.country | |
self.shipping_address_city = address.city | |
end | |
def shipping_address | |
Address.new(shipping_address_country, shipping_address_city) | |
end | |
def self.make buyer | |
create buyer: buyer, shipping_address: buyer.address, status: :new | |
end | |
def self.get buyer, id | |
#… | |
end | |
def self.all_orders buyer | |
#… | |
end | |
def self.active_orders buyer | |
#… | |
end | |
def make_item book, quantity | |
ensure_status :new | |
amount = book.price * quantity | |
items.create book: book, quantity: quantity, amount: amount | |
end | |
def make_payment masked_card | |
ensure_status :ready | |
self.payment = Payment.new(masked_card: masked_card, amount: total_amount) | |
self.status = :paid | |
save | |
end | |
def mark_as_ready | |
#… | |
end | |
def mark_as_shipped | |
#… | |
end | |
def total_amount | |
#… | |
end | |
private | |
def ensure_status required_status | |
raise InvalidOrderStatus.new(self) if status != required_status | |
end | |
end | |
class Item | |
include DataMapper::Resource | |
property :id, Serial | |
belongs_to :book | |
property :quantity, Integer | |
property :amount, Decimal | |
end | |
class Payment | |
include DataMapper::Resource | |
property :id, Serial | |
property :masked_card, String | |
property :amount, Decimal | |
property :created_at, DateTime | |
property :updated_at, DateTime | |
end |
Вот так может выглядеть создание заказа:
buyer = current_user | |
order = Order.make buyer | |
#At this moment: | |
#order.shipping_address == buyers_address | |
#order.status == :new | |
order.make_item book1, 2 | |
order.make_item book2, 3 | |
order.mark_as_ready |
Чтение того же порядка из базы данных может выглядеть так:
order = Order.get(buyer, params[:id]) |
Я хотел бы отметить несколько важных вещей:
- Все товары и платежи создаются через экземпляр Order.
- Я не обновляю атрибуты заказа напрямую.
- Я не использую методы DataMapper напрямую. Я написал несколько методов, чтобы максимально отделить нашу модель от DataMapper.
- Нет двунаправленных ассоциаций. Каждый заказ знает о своих товарах, но у предметов нет ссылок на их заказы. Почему? Потому что им не нужно. Порядок является корнем; следовательно, любой клиент может получить товар только через свой заказ. Это означает, что порядок элемента всегда будет известен. * Двунаправленные ассоциации — плохая практика, установленная в сообществе Rails. Если вы можете избежать этого, пожалуйста, сделайте это. *
- Пользователь не знает о своих заказах. Помимо того, что это ненужная двунаправленная ассоциация, это также значительно усложняет тестирование.
Не веришь мне? Посмотрите на эту строку кода:
@all_orders = current_user.orders.active |
Это выглядит так просто. Некоторые люди могут даже сказать, что это лучше, чем этот:
@all_orders = Order.active_orders(user) |
Однако, если вы попытаетесь заглушить это в своем тесте, вы можете написать что-то похожее на это:
order = … | |
stub(orders = Object.new).active {[order]} | |
stub(user = Object.new).orders {orders} | |
stub(UserSession).current_user {user} |
Теперь сравните его с Order.active_orders(user)
:
order = … | |
stub(UserSession).current_user {user} | |
stub(Order).active_orders(user){[order]} |
Второй тест намного легче читать и понимать. Кроме того, в больших приложениях такие модели, как User, имеют тенденцию к росту. Через несколько лет вы можете получить класс User с множеством заказов, акций, друзей, списков пожеланий и т. Д.
Доступ к нашей модели в представлении
Пожалуйста, не заблуждайтесь. Тот факт, что Заказ является корнем Агрегата, не означает, что я не могу получить доступ к его оплате или товарам. Конечно я могу. Единственное правило — не хранить ссылки на эти объекты. Например, мне, наверное, нужен докладчик. Поскольку докладчики хранят ссылки на объекты, которые они представляют, создание ItemPresenter или PaymentPresenter является нарушением границы Aggregate. Вместо этого мы можем создать OrderPresenter и передать ему экземпляр Order. OrderPresenter может получить доступ к элементам заказа или оплате через сам заказ.
class OrderPresenter < Struct.new(:order) | |
def render_items | |
order.items.map do |item| | |
item_row item | |
end.join(«») | |
end | |
private | |
def item_row item | |
«<div>#{item.book.title} — #{item.quantity} — #{item.amount}</div>» | |
end | |
end |
Инварианты
Помните, что агрегаты не только об ограничении доступа. Они также определяют границы инвариантов и транзакций. Существует распространенное мнение, что эти инварианты должны быть соблюдены корнем. Также принято делать root ответственным за управление всеми транзакциями. Хотя иногда это может иметь смысл, есть много ситуаций, когда это не так. Просто для примера рассмотрим класс PaymentService:
module PaymentService | |
def self.process order, credit_card | |
with_transaction do | |
order.make_payment mask_card(credit_card) | |
make_remote_call credit_card, order.payment | |
end | |
end | |
… | |
end |
PaymentService имеет несколько обязанностей. Во-первых, он маскирует кредитную карту. Затем он обновляет базу данных, чтобы пометить заказ как оплаченный. После этого он делает удаленный вызов какой-либо внешней службе, которая обрабатывает транзакции по кредитным картам. Кроме того, он оборачивает все в транзакцию. PaymentService обеспечивает важный инвариант, что обновление статуса заказа и выполнение удаленного вызова должны выполняться вместе. Альтернативой было бы возложить на Заказ ответственность за вызов внешних служб. Результатом будет нарушение принципа единой ответственности и несколько неприятных зависимостей Заказа от внешних платежных систем.
Завершение
Иногда инварианты необходимо применять не к отдельным объектам, а к кластерам объектов. Определение Агрегатов и ограничение доступа для Агрегатов — это механизм, который делает возможным выполнение всех инвариантов.