Введение
В рамках предыдущих статей мы описали: область применения, методологические основы, пример архитектуры и структуры. В данной статье, я хотел бы рассказать как описывать процессы, о принципах сбора требований, чем отличаются бизнес требования от функциональных, как перейти от требований — к коду. Рассказать о принципах применения Вариантов Использования (Use Case) и как они нам могут помочь. Разобрать на примерах варианты реализации шаблонов проектирования Interactor и Service Layer.
Примеры приведенные в статье даны с использованием нашего решения LunaPark, оно поможет вам с первыми шагами в описанных подходах.
Отделяем функциональные требования от бизнес требований.
Снова и снова случается так, что многие бизнес-идеи на самом деле не превращаются в конечный, намеченный продукт. Зачастую это происходит из-за неспособности понять разницу между бизнес-требованиями и функциональными требованиями, что в конечном итоге, приводит к несоответствующему сбору требований, ненужной документации, задержкам проекта и крупным проектным сбоям.
Или иногда мы сталкиваемся с ситуациями, в которых, хотя окончательное решение отвечает потребностям клиентов, но каким-то образом бизнес-цели не достигаются.
Поэтому крайне важно разделить бизнес-требования и функциональные требования, до того момента, как вы начнете их определять. Давайте разберем пример.
Предположим, мы пишем приложение для компании по доставке пиццы, и мы решили сделать систему по отслеживанию курьеров. Бизнес требования звучат следующим образом:
"Внедрить веб-систему и систему отслеживания сотрудников на базе мобильных устройств, которая фиксирует курьеров на их маршрутах и повышает эффективность за счет мониторинга активности курьеров, их отсутствия на работе и производительности труда."
Тут можно выделить ряд характерных признаков, которые будут указывать, что это требования от бизнеса:
- бизнес-требования всегда написаны с точки зрения клиента;
- это широкие требования высокого уровня, но все же ориентированные на детали;
- они не являются целями компании, но помогают компании достичь целей;
- отвечают на вопросы «почему» и «что». Что хочет компания получить? И почему ей это нужно.
Функциональные требования — это Действия, которые система должна выполнить, для реализации бизнес-требований. Таким образом, функциональные требования связаны с разрабатываемым решением или программным обеспечением. Сформулируем функциональные требования для вышеуказанного примера:
- система должна отображать долготу и широту сотрудника через GPS/ГЛОНАСС;
- система должна отображать позиции сотрудников на карте;
- система должна позволять менеджерам отправлять уведомления своим подчиненным на местах.
Выделим следующие особенности:
- функциональные требования всегда пишутся с точки зрения системы;
- они более конкретные и подробные;
- именно благодаря выполнению функциональных требований, разрабатывается, эффективное решение, отвечающее потребностям бизнеса и целям клиента;
- отвечают на вопрос «как». Как система решает бизнес требования.
Следует сказать пару слов о нефункциональных требованиях (также известных как «требования к качеству»), которые накладывают ограничения на дизайн или реализацию (например, требования к производительности, безопасности, доступности, надежности). Такие требования отвечают на вопрос «какой» должна быть система.
Разработка — это перевод бизнес требований в функциональные. Прикладное программирование — это реализация функциональных требований, а системное — нефункциональных.
Варианты использования (Use cases)
Реализация функциональных требований является, зачастую, самой сложной в коммерческих системах. В чистой архитектуре функциональные требования реализуются через слой Use Case.
Но для начала, я хочу обратится к первоисточнику. Ивар Якобсон — автор определения Use Case, один из авторов UML, и методологии RUP, в своей статье Use-Case 2.0 The Hub of Software Development выделяет 6 принципов применения Вариантов использования:
- сделайте их простыми через повествование;
- имейте стратегический план, осознайте картину целиком;
- сфокусируйтесь на значении;
- выстраивайте систему по слоям;
- поставляйте систему пошагово;
- удовлетворяйте потребности команды.
Кратко рассмотрим каждый из этих принципов, нам они пригодятся для дальнейшего понимания. Ниже идет мой вольный перевод, с сокращениями и вставками, настоятельно рекомендую ознакомиться и с оригиналом.
Простота через повествование
Повествование — часть нашей культуры; это самый простой и эффективный способ передачи знаний, информации одного человека — другому. Это лучший способ сообщить о том, что должна делать система, и помочь команде сосредоточиться на общих целях.
Варианты использования отражают цели системы. Чтобы понять Вариант Использования, мы рассказываем, повествуем некую историю. История рассказывает о том, как достичь цели и как решить проблемы, возникающие на пути. Варианты использования, как сборник рассказов, предоставляют способ идентифицировать и охватить все разные, но связанные истории простым, всеобъемлющим способом. Это позволяет легко собирать, распространять и понимать требования системы.
Данный принцип коррелирует с партерном Общий язык (Ubiques language) из DDD подхода.
Понимание картины в целом
Независимо от того, какую систему вы разрабатываете, большую, маленькую, программную, аппаратную или бизнес-систему, понимание общей картины очень важно. Без понимания системы в целом вы не сможете принимать правильные решения о том, что включать в систему, что исключать, сколько это будет стоить и какую пользу это принесет.
Ивар Якобсон предлагает задействовать диаграмму вариантов использования, что очень удобно для сбора требований. Если требования собраны и ясны, то лучшим вариантом будет Контекстная карта (Context map) Эрика Эванса. Зачастую, Scrum подход интерпретируют так, что люди не тратят время на стратегический план, считая планирование, дальше чем на две недели, пережитком прошлого. Пропаганда Джеффа Сазерленда обрушилась на waterflow, а люди закончившие двухнедельные курсы подготовки скрам-мастеров, допущенные к управлению проектами, сделали свое дело. Но здравый смысл, осознает важность стратегического планирования. Не нужно делать детальный стратегический план, но он должен быть.
Фокус на значении
Пытаясь понять, как будет использоваться система, всегда важно сосредоточиться на ценности, которую она предоставит своим пользователям и другим заинтересованным сторонам. Ценность формируется только в том случае, когда система используется. Поэтому гораздо лучше сосредоточиться на том, как система будет применяться, чем на длинных списках функций или возможностей, которые она может предложить.
Варианты использования обеспечивают этот фокус, помогая вам сконцентрироваться на том, как система будет задействована конкретным пользователем для достижения его цели. Варианты использования охватывают множество способов применения системы: те, которые успешно достигают целей, и те, которые решают любые возникающие сложности.
Далее автор приводит замечательную схему, на которую следует обратить самое пристальное внимание:
На схеме показан вариант использования, «Снятие наличных в банкомате». Самый простой способ достижения цели описывается в Основном Направлении (Basic flow). Другие случаи описываются как Альтернативные Направления (alternative flow). Эти направления помогают с повествованием, структурируют систему и помогают с написанием тестов.
Послойное построение
Большинство систем требуют большой работы, прежде чем они будут готовы к использованию. У них много требований, большинство из которых зависят от других требований, они должны быть реализованы, прежде чем требования будут выполнены и оценены.
Большая ошибка создать такую систему за раз за один раз. Система должна быть построена из кусочков, каждый из которых имеет четкую ценность для пользователей.
Эти идеи перекликаются с подходами гибкой разработки и с идеями Доменов (Domain).
Пошаговый вывод продукта на рынок
Большинство программных систем развиваются на протяжении многих поколений. Они не производятся за один раз; они построены в виде серии выпусков, каждое из которых построена на предыдущем выпуске. Даже сами релизы часто не выходят за раз, а развиваются через серию промежуточных версий. Каждый шаг предоставляет наглядную, пригодную для использования версию системы. Это тот способ, которым должны быть созданы все системы.
Удовлетворять потребности команды
К сожалению, не существует универсального решения проблем разработки программного обеспечения; разные команды и разные ситуации требуют разных стилей и разных уровней детализации. Независимо от того, какие методы и приемы вы выберете, вы должны убедиться, что они достаточно адаптируемые для удовлетворения текущих потребностей команды.
Эрик Эванс в своей книге призывает не тратить много времени на описания всех процессов через UML. Достаточно использовать любые наглядные схемы. Разным командам, разным проектам требуется разная степень детализации, об это говорит и сам автор UML.
Реализация
В чистой архитектуре Робертом Мартином дается следующее определение Вариантов использования :
These use cases orchestrate the flow of data to and from the entities, and direct those entities to use their Critical Business Rules to achieve the goals of the use case.
Попробуем воплотить эти идеи в код. Давайте вспомним схему из третьего принципа применения Вариантов использования и возьмем ее за основу. Рассмотрим действительно сложный бизнес-процесс: «Приготовление пирога с капустой».
Давайте попробуем его декомпозировать:
- проверить наличие продуктов;
- взять их со склада;
- замесить тесто;
- дать тесту подняться;
- подготовить начинку;
- сделать пирог;
- испечь пирог.
Всю эту последовательность мы реализуем через Интерактор (Interactor), а каждый шаг будет реализован через функцию или Функциональный объект (Functional Object) на Сервисном слое (Service Layer).
Последовательность действий (Interactor)
Я очень рекомендую начинать разработку сложного бизнес-процесса именно с Последовательности действий. Точнее не так, вы должны определить Доменную область, к которой относится бизнес-процесс. Уточнить все требования бизнеса. Определить все Сущности, которые задействованы в процессе. Задокументировать требования и определения каждой Сущности в базе знаний.
Расписать все на бумаге по шагам. Иногда потребуется Диаграмма последовательности (sequence diagram). Ее автор тот же, кто придумал Варианты использования (Use Case) — Ивар Якобсон. Диаграмма была придумана им, когда он разрабатывал систему обслуживания телефонных сетей для компании Эриксон, взяв за основу схему реле. Мне очень нравится эта диаграмма, и термин Sequence, на мой взгляд более выразителен, чем термин Interactor. Но ввиду большей распространенности последнего, будем использовать привычный термин — Interactor.
Небольшая подсказка, когда вы описывайте бизнес-процесс хорошим подспорьем для вас, может стать, основное правило документооборота: «В результате любой хозяйственной деятельности, должен быть составлен документ». К примеру, мы разрабатываем систему скидок. Предоставляя скидку, мы по факту, с точки зрения бизнеса, заключаем договор между компанией и клиентом. В этом договоре должны быть прописаны все условия. То есть в домене DiscountSystem, у вас будет Entites::Contract. Не привязывайте скидку к клиенту, а создайте Сущность Контракт, где описываются правила ее предоставления.
Вернемся к описанию нашего бизнес-процесса, после того, как он стал прозрачен для всех лиц задействованных в его разработке, и все ваши знания зафиксированы. Я рекомендую начать написание кода именно с Последовательности действий.
Шаблон проектирования Последовательности действий отвечает за:
- последовательность выполнения Действий;
- координацию передаваемых данных между Действиями;
- обработку ошибок совершаемых Действиями во время их выполнения;
- возвращение результата совокупности совершенных Действий;
- ВАЖНО: самая главная ответственность этого шаблона проектирования — реализация бизнес логики.
На последней ответственности хотелось бы остановиться подробнее, если у нас имеется какой-то сложный процесс — мы должны описать его так, чтобы было понятно, что происходит не вдаваясь в технические детали. Вы должны описать его настолько выразительно, насколько это позволяет вам ваши навыки программирования. Доверьте этот класс самому опытному члену вашей команды.
Вернемся к пирогу: попробуем описать процесс его приготовления через Interactor.
Реализация
Привожу пример реализации, с нашим решением LunaPark, которое мы представили в предыдущей статье.
module Kitchen
module Sequences
class CookingPieWithСabbage < LunaPark::Interactors::Sequence
TEMPERATURE = Values::Temperature.new(180, unit: :cel)
def call!
Services::CheckProductsAvailability.call list: ingredients
dough = Services::BeatDough.call from: Repository::Products.get(beat_ingredients)
filler = Services::MakeСabbageFiller.call from: Repository::Products.get(filler_ingredients)
pie = Services::MakePie.call dough, with: filler
bake = Services::BakePie.new pie, temp: TEMPERATURE
sleep 5.min until bake.call
pie
end
private
attr_accessor :beat_ingredients, :filler_ingredients
attr_accessor :pie
def ingredients_list
beat_ingredients_list + filler_ingredients_list
end
end
end
end
Как мы видим, метод call!
описывает всю бизнес-логику процесса выпечки пирога. И его удобно использовать для понимания логики приложения.
Также, мы легко можем описать процесс выпечки рыбного пирога, заменив MakeСabbageFiller
на MakeFishFiller
. Тем самым, мы очень быстро меняем бизнес-процесс, без существенных доработок кода. И также, мы можем оставить обе Последовательности одновременно, масштабируя бизнес-кейсы.
Договоренности
- Метод
call!
является обязательным методом, он описывает порядок Действий. - Каждый параметр инициализации может описываться через сеттер или
attr_acessor
:
class Foo < LunaPark::Interactors::Sequence
# ...
private
attr_accessor :bar
end
Foo.call(bar: 42)
- Остальные методы должны быть приватными.
Пример использования
beat_ingredients = [
Entity::Product.new :flour, 500, :gr,
Entity::Product.new :oil, 50, :gr,
Entity::Product.new :salt, 1, :spoon,
Entity::Product.new :milk, 150, :ml,
Entity::Product.new :egg, 1, :unit,
Entity::Product.new :yeast, 1, :spoon
]
filler_ingredients = [
Entity::Product.new :cabbage, 500, :gr,
Entity::Product.new :salt, 1, :spoon,
Entity::Product.new :pepper, 1, :spoon
]
cooking = CookingPieWithСabbage.call(
beat_ingredients: beat_ingredients,
filler_ingredients: filler_ingredients
)
# В случае успеха:
cooking.success? # => true
cooking.fail # => false
cooking.fail_message # => ''
cooking.data # => Entity::Pie
# Если пирог сгорел:
cooking.success? # => false
cooking.fail # => true
cooking.fail_message # => 'The pie burned out'
cooking.data # => nil
Процесс представлен через объект и мы имеем все необходимые методы для его вызова — прошел ли вызов успешно, возникла ли какая-то ошибка в процессе вызова, и если произошла, то какая?
Обработка ошибок
Если сейчас вспомнить третий принцип применения Use Case, обратим внимание на то, что кроме Основного направления, у нас были еще и Альтернативные направления. Это ошибки, которые мы должны обработать. Рассмотрим пример: мы конечно не хотим чтобы события пошли подобным образом, но ничего не можем с этим поделать, суровая реальность такова, что пироги периодически сгорают.
Interactor перехватывает все ошибки унаследованные от класса LunaPark::Errors::Processing
.
Как нам уследить за пирогом? Для этого определим ошибку Burned
в Действие BakePie
.
module Kitchen
module Errors
class Burned < LunaPark::Errors::Processing; end
end
end
И во время выпечки, проверим, что наш пирог не сгорел:
module Kitchen
module Services
class BakePie < LunaPark::Callable
def call
# ...
rescue Errors::Burned, 'The pie burned out' if pie.burned?
# ...
end
end
end
end
В этом случае сработает перехватчик ошибок, и мы сможем разобраться с ними в Эндпоинтах
.
Ошибки, не унаследованные от Processing
, воспринимаются как системные и будут перехвачены на уровне сервера. Если не обозначенные другие условия, то пользователь получит 500 ServerError.
Практика использования
1. Старайтесь описывать все вызовы в методе call!
Не следует реализовывать каждое Действие отдельным методом, это делает код более раздутым. Приходится просматривать весь класс несколько раз, чтобы понять как он работает. Испортим рецепт выпечки пирога:
module Service
class CookingPieWithСabbage < LunaPark::Interactors::Sequence
def call!
check_products_availability
make_cabbage_filler
make_pie
bake
end
private
def check_products_availability
Services::CheckProductsAvailability.call list: ingredients
end
# ...
end
end
Используйте вызов действий прямо в классе. Такой подход с точки зрения ruby может показаться непривычным, так он выглядит более читабельным:
class DrivingStart < LunaPark::Interactors::Sequence
def call!
Service::CheckEngine.call
Service::StartUpTheIgnition.call car, with: key
Service::ChangeGear.call car.gear_box, to: :drive
Service::StepOnTheGas.call car.pedals[:right]
end
end
2. По возможности используйте метод класса call
# good - Обычно, экземпляр класса Действия, редко используется.
# Логично использовать сокращенную запись.
Sequence::RingingToPerson.call(params)
# good - Тем не менее, есть возможность создавать экземпляр объекта Действиe,
# что может быть полезно, когда нам нужно переиспользовать его,
# с учетом внутреннего состояния.
ring = Sequence::RingingToPerson.new(person)
unless ring.success?
ring.call
sleep 5.min
end
3. Не создавайте Функциональные объекты ради типизации кода, смотрите по ситуации
# bad - мы решили делать всю логику в Функциональных объектах, чтобы
# сделать более легкой Последовательность действий.
module Services
class BuildUser < LunaPark::Callable
def initialize(first_name:, last_name:, phone:)
@first_name = first_name
@last_name = last_name
@phone = phone
end
def call
Entity::User.new(
first_name: first_name,
last_name: last_name,
phone: phone
)
end
private
attr_reader :first_name, :last_name, :phone
end
end
module Sequences
class RegisteringUser < LunaPark::Interactors::Sequence
attr_accessor :first_name, :last_name, :phone
def call!
user = Service::BuildUser.call(first_name: first_name, last_name: last_name, phone: phone)
end
end
end
# good - не следует писать отдельный класс, действуем практичнее.
# Хотя при такой реализации следует задуматься о тестировании,
# возможно необходимо вынести метод в Сервисный слой.
module Sequences
class RegisteringUser < LunaPark::Interactors::Sequence
attr_accessor :first_name, :last_name, :phone
def call!
user #...
end
private
def user
@user = Entity::User.new(
first_name: first_name,
last_name: last_name,
phone: phone
)
end
end
end
Сервисный слой (Service Layer)
Порядок действий (Interactor), как мы говорили, описывает бизнес-логику на самом верхнем уровне. Сервисный слой (Service layer) уже раскрывает детали реализации функциональных требований. Если мы говорим о приготовлении пирога, то на уровне Порядка действий (Interactor) мы говорим просто "замешиваем тесто", не вдаваясь в детали как его замесить. Процесс замеса описывается на Сервисном уровне. Вернемся к первоисточнику, большой синей книге:
В прикладной предметной области бывают такие операции, которым нельзя найти естественное место в объекте типа Сущности (Entity) или Объекта-Значения (Value object). Они по своей сути являются не предметами, а видами деятельности. Но поскольку в основе нашей парадигмы моделирования лежит объектный подход, мы попробуем превратить их в объекты.
В этом месте легко совершить распространенную ошибку: отказаться от попытки поместить операцию в подходящий для нее объект, и таким образом, прийти к процедурному программированию. Но если насильно поместить операцию в объект с чуждым ей определением, от этого сам объект утратит чистоту замысла, станет труднее для понимания и рефакторинга. Если в простом объекте реализовать много сложных операций, он может превратиться в непонятно что, занятое непонятно чем. В таких операциях часто участвуют другие объекты предметной области и между ними выполняется согласование для выполнения совместной задачи. Дополнительная ответственность создает цепочки зависимости между объектами, смешивая понятия, которые можно было бы рассматривать независимо.
При выборе места реализации того или иного функционала, всегда пользуйтесь здравым смыслом. Ваша задача — сделать модель более выразительной. Разберем пример, "Нам нужно нарубить дрова" :
module Entities
class Wood
def chop
# ...
end
end
end
Такой метод будет являться ошибкой. Дрова сами себя не нарубят, нам потребуется топор:
module Entities
class Axe
def chop(sacrifice)
# ...
end
end
end
Если мы используем упрощенную бизнес-модель, этого будет достаточно. Но если процесс нужно смоделировать более детально, нам понадобится человек, который будет рубить эти дрова, и возможно, некоторое бревно, которое будет использоваться в качестве подставки для осуществления процесса.
module Entities
class Human
def chop_firewood(wood, axe, chock)
# ...
end
end
end
Как вы уже наверное догадались, это не самая удачная идея. Не все из нас занимаются рубкой дров, это не прямая обязанность человека. Мы часто видим насколько перегружены модели в Ruby on Rails, хранящие в себе подобную логику: получение скидки, добавление товара в корзину, снятие денег к балансу. Эта логика относится не к сущности, а к процессу в котором задействована эта сущность.
module Services
class ChopFirewood
# ...
end
end
После того, как мы разобрались, какую логику мы храним в Службах попробуем реализовать один из них. Чаще всего службы реализуются через методы или функциональные объекты.
Функциональные объекты
Функциональный объект выполняет одно функциональное требование. В самом примитивном виде функциональный объект имеет один единственный публичный метод — call
.
module Serivices
class Sum
def initialize(x, y)
@x = x
@y = y
end
def call
x + y
end
def self.call(x,y)
new(x,y).call
end
private
attr_reader :x, :y
end
end
Такие объекты имеют ряд преимуществ: они лаконичны, их очень просто тестировать. Есть и недостаток, таких объектов может получиться большое количество. Есть несколько способов сгруппировать подобные объекты, мы в части своих проектов делим их по типу:
- Сервисный объект (Service) — объект, создает новый объект;
- Команда (Command) — изменяет текущий объект;
- Вахтер (Guard) — возвращает ошибку если, что-то пошло не так.
Сервисный Объект (Service)
В нашей реализации Service — реализует функциональное требование и всегда возвращает значение.
module KorovaMilkBar
module Services
class FindMilk < LunaPark::Callable
GLASS_SIZE = Values::Unit.wrap '200g'
def initialize(fridge:)
@fridge = fridge
end
def call
fridge.shelfs.find { |shelf| shelf.has?(GLASS_SIZE, of: :milk) }
end
private
attr_reader :fridge
end
end
end
FindMilk.call(fridge: the_red_one) # => #<Glass: ... >
Команда (Command)
В нашей реализации Command — выполняет одно Действие, изменяет объект, в случае успеха возвращает true. По факту, Команда не создает объект, а изменяет существующий.
module KorovaMilkBar
module Commands
class FillGlass < LunaPark::Callable
def initialize(glass, with:)
@glass = glass
@content = with
end
def call
glass << content
true
end
private
attr_reader :fridge
end
end
end
glass = Glass.empty
milk = Milk.new(200, :gr)
glass.empty? # => true
FillGlass.call glass, with: milk # => true
glass.empty? # => false
Вахтер (Guard)
Вахтер, выполняет логическую проверку и в случае провала выдает ошибку обработки. Такой тип объекта никак не влияет на Основное направление, но переключает нас на Альтернативное направление, если что-то пошло не так.
При подаче молока важно убедится, что оно свежее:
module KorovaMilkBar
module Guards
class IsFresh < LunaPark::Callable
def initialize(product)
@products = products
end
def call
products.each do |product|
raise Errors::Rotten, "#{product.title} is not fresh" if product.expiration_date > Date.today
end
nil
end
private
attr_reader :products
end
end
end
Возможно вам покажется удобным разделять функциональные объекты по типу. Вы можете добавить свои, например, Builder — создает объект на основе параметров.
Договоренности
- Метод
call
является единственным обязательным публичным методом. - Метод
initialize
является единственным опциональным публичным методом. - Остальные методы должны быть приватными.
- Логические ошибки должны наследоваться от класса
LunaPark::Errors::Processing
.
Обработка ошибок
Следует разделить 2 типа ошибок, которые могут произойти во время работы того или иного Действия.
Ошибки процесса выполнения
Такие ошибки могут возникать в результате нарушения логики обработки.
Например:
- при создании пользователя email зарезервирован;
- при попытке выпить молоко, оно закончилось;
- другой микросервис отклонил действие (по логической причине, а не потому, что сервис недоступен).
По всей вероятности, об этих ошибках захочет узнать пользователь. Также, вероятно, это те ошибки,
которые мы можем предвидеть.
Такие ошибки должны наследоваться от LunaPark::Errors::Processing
Системный ошибки
Ошибки, которые произошли в результате сбоя системы.
Например:
- не работает БД;
- что-то поделилось на ноль.
По всей вероятности, мы не можем предвидеть эти ошибки и ничего не можем сказать пользователю, кроме того, что все очень плохо, и отправить разработчикам отчет, призывающий к действию. Такие ошибки должны наследоваться от SystemError
Есть еще, ошибки валидации, которые мы рассмотрим подробнее в следующей статье.
Практика использования
1. Используйте переменные, чтобы повысить читаемость
module Fishing
# bad - не информативно
Serivices::Catch.call(fish, rod)
# bad - избыточно
Serivices::Catch.call(fish: fish, rod: rod)
# good - более выразительно
Serivices::Catch.call(fish, with: rod)
module Serivices
class Catch
def initialize(fish, with:)
@fish = fish
@rod = with # внутри класса мы используем переменную
# указывающую на объект.
end
# ...
private
attr_reader :fish, :rod
end
end
end
2. Передавайте объекты, а не параметры
Старайтесь делать инициализатор простым, если обработка параметров не является его целью.
Передавайте объекты, а не параметры.
module Service
# bad - на сервисном уровне мы работаем только с бизнес-логикой. Преобразование
# типов следует вынести на уровень выше, например в форму.
class Foo
def initialize(foo_params:, bar_params:)
@foo = Values::Foo.new(*foo_params)
@bar = Values::Bar.new(*bar_params)
end
# ...
end
Services::Foo.call(foo: {a: 1, b: 2}, bar: 34)
# good - реализуем только бизнес-логику.
class Bar
def initialize(foo:, bar:)
@foo = foo
@bar = bar
# ...
end
end
foo = Values::Foo.new(a: 1, b: 2)
bar = Values::Bar.new(34)
Services::Bar.call(foo: foo, bar: bar)
# good - логичным исключением является реализация шаблона проектирования - Builder.
class BuildFoo
def initialize(param_1:, param_2:)
@param_1 = param_1
@param_1 = param_1
end
def call
Foo.new(
param_1: param_1.foo,
param_2: param_2.bar,
param_3: some_magick
)
end
# ...
end
end
3. Используйте в название Действия — глагол действия и объект воздействия.
# bad
module Services
class Milk; end
class Work; end
class FooBuild; end
class PasswordGenerator; end
end
# good
module Services
class GetMilk; end
class WorkOnTable; end
class BuildFoo; end
class GeneratePassword; end
end
4. По возможности используйте метод класса call
Обычно экземпляр класса Действия, редко используется кроме того, чтобы писать сделать вызов.
# good - Логично использовать сокращенную запись.
Services::BuildFoo.call(params)
# good - Или еще более сокращенную
Services::BuildFoo.(params)
# good - Тем не менее, есть возможность создавать экземпляр объекта Действия,
# что может быть полезно, когда нам нужно переиспользовать его, с учетом
# внутреннего состояния.
ring = Services::RingToPhone.new(phone: neighbour)
10.times do
ring.call
end
5. Обработка ошибок не является задачей сервиса
# bad - оставьте обработку ошибку интеракторам, а сервис легким.
def call
#...
rescue SystemError => e
return false
end
Модули
До этого момента мы рассматривали имплементацию Сервисного слоя как набора функциональных объектов. Но мы легко можем разместить на этом слое методы:
module Services
def sum(a, b)
a + b
end
end
Другая проблема, которая встает перед нами — большое количество сервисных объектов. Вместо, набивших оскомину «rails fat model», мы получим «services fat folder». Есть несколько способов организовать структуру, чтобы уменьшить масштаб трагедии. Эрик Эванс решает это за счет того, что объединяет ряд функций в один класс. Представим, что нам нужно смоделировать бизнес-процессы няни, Арины Родионовны, она может кормить Пушкина и укладывать его спать:
class NoonService
def initialize(arina_radionovna, pushkin)
# ...
end
def to_feed
# ...
end
def to_sleep
# ...
end
end
Такой подход более корректный с точки зрения ООП. Но мы предлагаем от него отказаться, по крайней мере, на начальных этапах. Не очень опытные программисты начинают писать много кода в таком классе, что в конечном счете приводит к увеличению связности. Вместо этого можно использовать модуль, представляя деятельность некоторой абстракцией:
module Services
module Noon
class ToFeed
def call!
# ...
end
end
class << self
# При этом сложные процессы, можно вынести
# в функциональные объекты
def to_feed(arina_radionovna, pushkin)
ToFeed.new(arina_radionovna, pushkin).call
end
# А простые оставить методами, реализованными в модуле
def to_sleep(arina_radionovna, pushkin)
arina_radionovna.tell_story pushkin
pushkin.state = :sleep
end
end
end
end
При делении на модули должна соблюдаться низкая внешняя зависимость (low coupling) при высокой внутренней связности (high cohesion), мы же используем такие модули как Services, или Interactors, это также идет в разрез с идеями чистой архитектуры. Это осознанный выбор, который облегчает восприятие. По имени файла мы понимаем какой шаблон проектирования реализует тот или иной класс, если для опытного программиста это очевидно, то для новичка это не всегда так. После того как ваша команда будет готова, откажитесь от этого излишества.
Процитирую еще небольшой отрывок из большой синей книги:
Выберите такие модули, которые бы рассказывали историю системы и содержали связные наборы понятий. От этого часто сама собой возникает низкая зависимость модулей друг от друга. Но если это не так, найдите способ изменить модель таким образом, чтобы отделить понятия друг от друга, или же поищите пропущенное в модели понятие, которое могло бы стать основой для модуля и тем самым свести элементы модели вместе естественным, осмысленным способом. Добивайтесь низкой зависимости модулей друг от друга в том смысле, чтобы понятия в разных модулях можно было анализировать и воспринимать независимо друг от друга. Доработайте модель до тех пор, пока в ней не возникнут естественные границы в соответствии с высокоуровневыми концепциями предметной области, а соответствующий код не разделится соответствующим образом.
Дайте модулям такие имена, которые войдут в ЕДИНЫЙ ЯЗЫК. Как сами МОДУЛИ, так и их имена должны отражать знание и понимание предметной области.
Тема модулей большая и интересная, но в полном объеме явно выходит за тему данной статьи. В следующий раз мы поговорим с вами о Репозиториях и Адаптерах. Мы открыли уютный телеграмм канал, где хотелось бы делиться материалами на тему DDD. Будем рады вашим вопросам и обратной связи.
- https://www.netsolutions.com/insights/business-and-functional-requirements-what-is-the-difference-and-why-should-you-care/
- http://magazines.russ.ru/nz/2007/54/gi3.html
- https://www.microtool.de/en/knowledge-base/how-use-case-2-0-works/
- https://www.researchgate.net/publication/220059381_Use_cases_-_Yesterday_today_and_tomorrow
- Проблемно-ориентированное проектирование, Эрик Дж. Эванс
Комментарии (6)
g6uru Автор
04.06.2019 17:32Тут важно достичь выразительности, чтобы читая код был понятен процесс, который он выражает. Степень выразительности лучше определить внутри команды. Но я бы тоже вынес многострочку в отдельный метод -) Возможно в Сервисный слой.
ApeCoder
05.06.2019 08:41Для меня это противоположность Ubiquotous Language в DDD
class DrivingStart < LunaPark::Interactors::Sequence
def call!
Service::CheckEngine.call
Service::StartUpTheIgnition.call car, with: key
Service::ChangeGear.call car.gear_box, to: :drive
Service::StepOnTheGas.call car.pedals[:right]
end
end
В моей мысленной картине мира двигатель часть машины и никаких сервисов нет и я думаю как-то так:
car.engine.check car.ignintion.startUp with:key car.gear.change to: :drive car.pedals.gas.press
Вопрос — зачем эти прослойки?
Вернее
сервис ответов на вопросы:: задать вопрос.сказать
сервис ответов на вопросы:: зачем.сказать
сервис ответов на вопросы:: эти.сказать
сервис ответов на вопросы:: прослойки.сказатьg6uru Автор
05.06.2019 10:06Services::
иInteractors::
— это лишняя абстракция, опытным разработчикам она не нужна. Я использую ее, здесь, в статьях и мы используем в части проектов, для наглядности, чтобы показать к какому шаблону проектирования относитсяCheckEngine
. Об этом я упомянул в последней части статьи.
.
pedals.gas.press
— это один из возможных вариантов если он удовлетворяет вашу команду, используйте его. Мне, лично, не нравится в этом подходе, что метод относится к субъекту, а не к объекту. Дрова — нарубитесь, хлеб нарежься, масло намажься.
Если у нас подходящий домен, можно сделать:
racer.press car.pedals.gas
Но это только в подходящем домене. Машина для гонщика и машина для инженера обслуживания, это две разные модели одной и той же реальной сущности.
ApeCoder
05.06.2019 13:52что метод относится к субъекту, а не к объекту.
Субъект — это тот, кто делает, объект — это тот над чем делают. Так как pedals это то, чем делают и объект на на нем, то метод относится у меня к объекту, а у вас к субъекту.
Машина для гонщика и машина для инженера обслуживания, это две разные модели одной и той же реальной сущности.
Interface segregation principle?
g6uru Автор
05.06.2019 14:19Да, извиняюсь, перепутал значения терминов субъект и объект.
Да, Interface segregation principle, была статья как-раз неплохая на эту тему
https://medium.com/roonyx/solid-ruby-ad046727ec26
И там как раз пример приводится:
class Driver def drive @car.open @car.start_engine end end
Но если бы у нас была сущность Human, то я бы не стал вносить метод
drive
в нее:
module Entities class Human # ... end end module Services #не все мы водим машины, и лучше это вынести в отдельный сервис module HandlingVehicle def self.drive(racer, car) # ... end end end
oxidmod
Справедливо, если действия состоят из одной строки. Иначе отдельные методы с хорошими названиями позволяют более легко читать основной метод