Статьи

DDD для разработчиков Rails. Часть 1: Многоуровневая Архитектура.

Что такое DDD

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

Книга

Эрик Эванс (Eric Evans), автор Domain Driven Design, придумал набор практик и терминологии, помогающих справиться со сложностью домена. Его книга является обязательной для прочтения для каждого разработчика, работающего над корпоративными приложениями, и я очень рекомендую это

DDD и Rails

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

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

Многоуровневая архитектура

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

Прежде всего, термин «многоуровневая архитектура» означает, что вы разбиваете приложение на слои:

  • Пользовательский интерфейс. Отвечает за показ информации пользователю и обработку ввода пользователя.
  • Уровень приложений. Этот слой должен быть тонким и не должен содержать никакой доменной логики. Он может иметь функциональные возможности, которые ценны для бизнеса, но не привязаны к конкретному домену Это включает в себя создание отчетов, отправку уведомлений по электронной почте и т. Д.
  • Доменный уровень. Отвечает за описание бизнес-процессов. Абстрактные доменные понятия (включая сущности, бизнес-правила) должны содержаться в этом слое. В отличие от настойчивости, отправка сообщений здесь не относится.
  • Уровень инфраструктуры. Отвечает за постоянство, обмен сообщениями, доставку электронной почты и т. Д.

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

Многоуровневая архитектура и рельсы

Теперь давайте посмотрим на наиболее распространенные нарушения многоуровневой архитектуры, которые я вижу в типичных приложениях Rails:

  • Доменные объекты сериализуются в JSON или XML. На мой взгляд, нет большой разницы между представлением объекта как фрагмента HTML и представлением его как фрагмента JSON. Оба предназначены для использования внешними системами, оба являются частью уровня пользовательского интерфейса. Поэтому каждый раз, когда вы переопределяете метод as_json вы нарушаете основную идею многоуровневой архитектуры — вы меняете направление своих зависимостей между слоями. Ваши доменные объекты начинают осознавать пользовательский интерфейс.
  • Контроллеры содержат большие куски бизнес-логики. Обычно это несколько обращений к уровню домена, а затем сохранение изменений с использованием низкоуровневых методов, таких как update_attributes . Поэтому в следующий раз, когда вы зайдете и update_attributes внутри контроллера, остановитесь и подумайте еще раз: скорее всего, вы делаете это неправильно. К счастью, большинство разработчиков Rails уже поняли, что такого рода контроллеры сложно поддерживать. Я считаю, что нет оправдания для этого — даже для небольших приложений.
  • Доменные объекты выполняют все виды инфраструктурных задач. Как сообщество мы договорились не писать жирные контроллеры, но столкнулись с другой проблемой — объектами домена «Швейцарский армейский нож». Такие объекты, помимо описания бизнеса, также могут подключаться к удаленному сервису, создавать PDF или отправлять электронную почту. Если вы сталкиваетесь с таким объектом, его нужно разделить как минимум на два: один отвечает за знание предметной области, а другой — за выполнение задач, связанных с инфраструктурой.
  • Доменные объекты слишком много знают о базе данных. Если вы расширяете свои доменные объекты из ActiveRecord, вы связываете доменный уровень с уровнем инфраструктуры. Ваши доменные объекты играют две роли одновременно. Такая связь делает их практически невозможными для тестирования в изоляции.

Большинство из этих проблем можно исправить. Давайте посмотрим на все из них, чтобы увидеть, что можно сделать.

Создайте отдельный класс для сериализации JSON

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

Представьте себе контроллер с действием, которое выглядит так:

def update
p = Person.find(params[:id])
if p.update_bank_information params[:bank_information]
render :json => p.as_json
else
render :json => «some kind of error»
end
end

Если вам нужно настроить сериализацию JSON класса Person, не делайте этого внутри класса Person. Создайте отдельный модуль (например, PersonJsonSerializer, PersonJsonifier), который будет отвечать за него.

module PersonJsonSerializer
def self.as_json person
if person.errors.present?
person.as_json(:root => «root-attrs»)
else
{:errors => person.errors.full_messages}
end
end
end

Теперь ваш контроллер будет выглядеть так:

def update
p = Person.find(params[:id])
p.update_bank_information params[:bank_information]
render :json => PersonJsonSerializer.as_json(p)
end

Чего мы достигли, переместив сериализацию JSON в отдельный класс?

  • Наш домен остается абстрактным без каких-либо знаний об интерфейсе пользователя.
  • Мы разделили две обязанности: быть человеком и сериализовать его в JSON. Если бы мы этого не сделали, принцип единой ответственности был бы нарушен.
  • Кроме того, мы упростили тестирование нашего контроллера. Вам даже не нужен объект реального человека для тестирования сериализатора. Просто заглушите это. Кроме того, вам не нужно проверять две ветви этого оператора if в ваших функциональных тестах. Вы можете просто заглушить сериализатор. Таким образом, вместо двух тестов у нас есть только один.

Контроллеры не содержат никакой логики

Короче говоря, контроллеры не должны содержать никакой логики, кроме анализа пользовательского ввода или отображения правильных шаблонов. Если у вас есть элемент бизнес-логики внутри контроллера, переместите его на уровень домена. Существует недоразумение, что уровень домена состоит только из постоянных объектов. А когда у вас есть сложные операции с несколькими объектами, вам нужно организовать их внутри контроллера. Это просто неправильно. Если ни одна из ваших сущностей не подходит для этой функции, создайте класс обслуживания (или модуль) и поместите его туда.

Представьте, у нас есть такая акция:

def sell_book
@book = Book.find(params[:id])
if book.sold?
book.errors.add :base, «The book is already sold»
else
book.sell
end
end

Лучший способ сделать это:

def sell_book
@book = Book.find(params[:id])
BookSellingService.sell_book(@book)
end

Мы достигли:

  • Контроллер легче тестировать. Когда раньше нам нужно было провести два теста, после того, как наше изменение было сделано, нам нужен только один.
  • В дополнение к тому, что наш контроллер легче тестировать, мы сделали часть бизнес-логики явной, а также более легкой для тестирования.

Объекты домена не должны ничего знать об инфраструктуре

Короче говоря, доменный слой должен быть абстрактным. Это означает, что все зависимости от каких-либо внешних сервисов не принадлежат. Представьте, что мы разрабатываем движок для блогов, и одним из требований является отправка твита при каждой публикации. То, что считается «ОК», — это обрабатывать его в after_create :

class Post < ActiveRecord::Base
has_many :comments
after_create :send_tweet
def send_tweet
twitter = Twitter.login(username, password)
twitter.send_tweet generate_tweet_from_subject(subject)
end
end

Несмотря на то, что это всего лишь несколько строк кода и они не очень похожи, это большое дело. Во-первых, вам придется отключить службу Twitter во всех модульных тестах, а это не то, что вы обычно хотите тестировать. Во-вторых, это нарушает принцип единой ответственности, так как хранение информации о публикации — это не то же самое, что отправка уведомлений в Twitter. Отправка такого рода уведомлений — скорее побочный эффект, дополнительная услуга, которая не является частью основного домена. Наконец, Twitter может быть недоступен, и синхронный доступ к нему в любом случае не очень хорошая идея.

Есть несколько способов отделения нашей модели от инфраструктуры. Одним из них является наблюдение класса Post:

class Post < ActiveRecord::Base
has_many :comments
end
class TwitterNotification < ActiveRecord::Observer
observe :post
def after_create post
twitter = Twitter.login(username, password)
twitter.send_tweet generate_tweet_from_subject(post.subject)
end
end

Еще лучший способ сделать это — переместить все обязанности по генерации твитов из наблюдателя в класс TwitterService:

class TwitterNotification < ActiveRecord::Observer
observe :post
def after_create post
TwitterService.send_tweet post.subject
end
end
class TwitterService
def self.send_tweet subject
twitter = Twitter.login(username, password)
twitter.send_tweet generate_tweet_from_subject(subject)
end
end

Таким образом, извлечение этой ответственности из класса Post помогло нам достичь следующего:

  • Тестировать класс Post проще, так как вам не нужно заглушать Twitter в каждом тесте.
  • Тестирование класса TwitterService также просто. Нам даже не нужен экземпляр Post для этого.
  • Мы сделали границы нашего приложения явными. У нас есть основной домен и интеграция с Twitter: два совершенно разных домена и, как следствие, два класса, отделенных друг от друга наблюдателем.
  • Наличие объекта, отвечающего за интеграцию с Twitter, позволяет нам изменять его функциональность (например, делать его асинхронным), не затрагивая основной домен.

Изолировать ActiveRecord

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

Подход DDD заключается в выделении отдельного слоя, ответственного за постоянство. Например:

class PostsRepository
def find_by_id id
end
def new_posts_of_author author
end
def save post
end
end

Наличие отдельного объекта, отвечающего за постоянство, значительно упрощает тестирование и позволяет предоставлять альтернативные реализации. Например, обычной практикой является реализация SQLPostsRepository и InMemoryPostsRepository. Таким образом, вы можете использовать первый раз для интеграционных тестов, а второй — для юнит-тестов. Когда ваш домен не связан с ActiveRecord, реализация репозиториев — это путь. Однако, это не даст вам много, когда все ваши доменные объекты расширяют ActiveRecord :: Base. Таким образом, я использую компромиссный вариант шаблона репозитория: поместите все связанные с сохранением методы в отдельный модуль и просто расширьте его.

module PostsRepository
def new_posts_of_author author
end
end
class Post < ActiveRecord::Base
extend PostsRepository
end
Post.new_posts_of_author «Jim»

Наличие отдельного модуля, включающего всю логику, связанную с постоянством, дает нам следующие преимущества:

  • Это обеспечивает разделение проблем. Post является бизнес-объектом, а PostsRepository отвечает за постоянство.
  • Это делает насмешку над постоянным слоем.
  • Обеспечение реализации репозитория в памяти также становится простым: Post.extend InMemoryPostsRepository

Резюме

Решить сложность домена сложно. Чем больше растет ваше приложение, тем труднее оно становится. Глушение всего вместе прекрасно работает для небольших приложений, но распадается на части, когда вам приходится иметь дело с большими приложениями. Простое применение принципов многоуровневой архитектуры может очень помочь.

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