Введение
Итак, мы уже определились с областью применения, методологией и архитектурой. Перейдем от теории к практике, к написанию кода. Хотелось бы начать с шаблонов проектирования, которые описывают бизнес логику — Service и Interactor. Но прежде чем приступить к ним, изучим структурные паттерны — ValueObject и Entity. Разрабатывать мы будем на языке ruby. В дальнейших статьях разберем все паттерны, необходимые для разработки с использованием Вариативной архитектуры. Все наработки, являющиеся приложениями к данному циклу статей, соберем в отдельный фреймворк.
И мы уже подобрали подходящее название — LunaPark.
Текущие наработки выложенны на Github.
Разобрав все шаблоны, соберем один полноценный микросервис.
Так исторически сложилось
Была необходимость в рефакторинге сложного корпоративного приложения, написанного на Ruby on Rails. Была готовая команда ruby-разработчиков. Методология Domain Driven Development прекрасно подходила для этих задач, но готового решения на используемом языке не было. Не смотря на то, что выбор языка, в основном, был обусловлен нашей специализацией, он оказался достаточно удачным. Среди всех языков, что принято использовать для web-приложений, ruby, на мой взгляд, является самым выразительным. И поэтому больше других подходит для моделирования реальных объектов. Это не только мое мнение.
That is the Java world. Then you have the new-comers like Ruby. Ruby has a very expressive syntax, and at this basic level it should be a very good language for DDD (although I haven't heard of much actual use of it in those sorts of applications yet). Rails has generated a lot of excitement because it finally seems to make creation of Web UIs as easy as UIs were back in the early 1990s, before the Web. Right now, this capability has mostly been applied to building some of the vast number of Web applications which don't have much domain richness behind them, since even these have been painfully difficult in the past. But my hope is that, as the UI implementation part of the problem is reduced, that people will see this as an opportunity to focus more of their attention on the domain. If Ruby usage ever starts going in that direction, I think it could provide an excellent platform for DDD. (A few infrastructure pieces would probably have to be filled in.)
Eric Evans 2006
К сожалению, за прошедшие 13 лет ничего особо не изменилось. В интернете можно найти попытки приспособить для этого Rails, но все они выглядят ужасно. Фреймворк Rails тяжелый, медленный и не соответствует принципам SOLID. Смотреть без слез, как кто-то пытается изобразить на основе AсtiveRecord реализацию паттерна Репозиторий, очень тяжело. Мы решили взять на вооружение какой-нибудь микрофреймворк и доработать его до наших потребностей. Попробовали Grape, идея с авто-документированием показалась удачной, но в остальном он был заброшенным и мы быстро отказались от идеи его использования. И почти сразу стали использовать другое решение — Sinatra. Мы до сих пор продолжаем его использовать для REST Контроллеров и Эндпоинтов.
Если вы разрабатывали web-приложения, то уже имеете представление о технологии. У нее есть свои плюсы и минусы, полное перечисление которых выходит за рамки данной статьи. Но для нас, как разработчиков корпоративных приложений, самым главным недостатком будет то, что REST (это понятно даже из названия) отражает не процесс, а его состояние. А преимуществом будет его понятность — технология ясна как back-end разработчикам, так и разработчикам front-end'a.
Но может тогда не ориентироваться на REST, а реализовать свое решение http + json? Если даже вам удасться разработать свой сервисный API, то предоставляя его описание третьим лицам вы получите много вопросов. Гораздо больше, чем если вы предоставите привычный REST.
Будем считать использование REST компромиссным решением. Мы используем JSON для лаконичности и jsonapi стандарт, чтобы не тратить время разработчиков на священные войны по поводу формата запросов.
В дальнейшем, когда мы будем разбирать Endpoint, мы увидим, что для того, чтобы избавится от rest, достаточно переписать всего один класс. Так что REST не должен вообще беспокоить, если остались сомнения на его счет.
В ходе написания нескольких микросервисов у нас появились наработки — набор абстрактных классов. Каждый такой класс можно написать за пол часа, его код легко понять, если знать для чего этот код предназначен.
Тут и возникли основные трудности. Новые сотрудники, не имевшие дело с практиками DDD и чистой архитектурой, не могли понять код и его предназначение. Если бы я сам увидел этот код впервые до того как прочитал Эванса, я бы воспринял его как legacy, over-engineering.
Чтобы побороть это препятствие было принято решение написать документацию (guideline), описывающую философию используемых подходов. Наброски этой документации показались удачными и было решено выложить их на Хабре. Абстрактные классы, которые повторялись из проекта в проект, было решено вынести в отдельный gem.
Философия
Если вспомнить какой-нибудь классический фильм про боевые искусства, то там будет крутой парень, который очень ловко обращается с шестом. Шест — это по сути палка, очень примитивный инструмент, один из первых, который попал человеку в руки. Но в руках мастера он становится грозным оружием.
Можно потратить время на создание пистолета, который не стреляет тебе в ногу, а можно потратить время на обучение технике стрельбы. Мы выделили 4 основных принципа:
- Нужно делать сложные вещи простыми.
- Знания важнее технологии. Документация понятнее человеку чем код, не следует подменять одно другим.
- Прагматичность важнее догматизма. Стандарты должны подсказывать путь, а не устанавливать ограничительные рамки.
- Структурность в архитектуре, гибкость в выборе решений.
Схожую философию можно проследить например у ОС ArchLinux — The Arch Way. На моем ноутбуке Linux долго не приживался, рано или поздно он ломался и мне постоянно приходилось его переустанавливать. Это вызывало ряд проблем, иногда серьезных вроде срыва deadline по работе. Но потратив один раз 2-3 дня на установку Arch я разобрался с тем как моя ОС работает. После этого она стала работать стабильнее, без сбоев. Мои заметки помогли мне устанавливать ее на новые ПК за пару часов. А обильная документация помогала мне решать новые задачи.
Фреймворк имеет абсолютно высокоуровневый характер. Классы, которые его описывают, отвечают за структуру приложения. Для взаимодействия с базами данных, реализации http протокола и других низкоуровневых вещей используются сторонние решения. Нам хотелось бы, чтобы программист при возникновении вопроса мог подсмотреть в код и понять как тот или иной класс работает, а документация позволила бы понять как ими управлять. Понимание устройства двигателя не позволит вам водить автомобиль.
Фреймворк
Сложно назвать LunaPark фреймворком в привычном смысле. Frame — рамка, Work — работа. Мы же призываем не ограничивать себя рамками. Единственная рамка, которую мы декларируем, это та, которая подсказывает класс, в котором должна быть описана та или иная логика. Это скорее набор инструментов с объемной инструкцией к ним.
Каждый класс — абстрактный и имеет три уровня:
module LunaPark # Фреймворк
module Forms # Паттерн
class Single # Реализация/вариант
end
end
end
Если вы хотите реализовать форму, которая создает один элемент, вы наследуетесь от данного класса:
module Forms
class Create < LunaPark::Forms::Single
Если несколько элементов, воспользуемся другой Реализацией.
module Forms
class Create < LunaPark::Forms::Multiple
На данный момент не все наработки приведены в идеальный порядок и gem находится в состоянии альфа-версии. Мы будем приводить его поэтапно, согласованно с выходом статей. Т.е. если вы видите статью про ValueObject
и Entity
, то эти два шаблона уже реализованы. К окончанию цикла все они будут пригодны к использованию на проекте. Поскольку сам по себе фреймворк малополезен без связки с sinatra roda, будет сделан отдельный репозиторий, который покажет как все "прикрутить" для быстрого старта вашего проекта.
Фреймворк является прежде всего приложением к документации. Не стоит воспринимать данные статьи как документацию к фреймворку.
Итак, перейдем к делу.
Объект-Значение (Value)
— Какого роста твоя подруга?
— 151
— Ты стал встречаться со статуей свободы?
Примерно такой разговор мог бы произойти в штате Индиана. Рост человека это не просто число, но еще и единица измерения. Не всегда атрибуты объекта можно описать только примитивами (Integer, String, Boolean и т.п.), иногда требуются их комбинации:
- Деньги это не просто число, это число (сумма) + валюта.
- Дата состоит из числа, месяца и года.
- Чтобы измерить вес нам недостаточно одного числа, требуется еще и единица измерения.
- Номер паспорта состоит из серии и, собственно, из номера.
С другой стороны это не всегда комбинация, возможно это некое расширение примитива.
Телефонный номер зачастую воспринимается как число. С другой стороны, вряд ли у него должен быть метод сложения или деления. Возможно, есть метод, который будет выдавать код страны и метод, определяющий код города. Возможно, будет некий декоративный метод, который представит его не просто строкой чисел 79001231212
, а читаемой строкой: 7-900-123-12-12
.
Если исходить из догм, то бесспорно — да. Если подходить к этой дилемме со стороны здравого смысла, то когда мы решим позвонить по этому номеру, то передадим телефону сам объект:
phone.call Values::PhoneNumber.new(79001231212)
А если мы решили его представить в виде строки, то это явно сделано для человека. Так почему бы нам не сделать эту строку для человека сразу читаемой?
Values::PhoneNumber.new(79001231212).to_s
Представим, что мы создаем сайт онлайн-казино "Три топора" и реализуем карточные игры. Нам понадобится класс 'игральная карта'.
module Values
class PlayingCard < Lunapark::Values::Compound
attr_reader :suit, :rank
end
end
Итак, у нашего класса есть два атрибута только для чтения:
- suit — масть карты
- rank — достоинство карты
Эти атрибуты задаются только при создании карты и не могут изменятся при ее использовании. Вы конечно можете взять игральную карту и перечеркнуть 8, написать Q, но это недопустимо. В приличном обществе вас, скорее всего, пристрелят. Невозможность менять атрибуты после создания объекта определяет первое свойство Объекта-значения — иммутабельность.
Вторым важным свойством Объекта-Значения будет то, как мы их сравниваем.
module Values
RSpec.describe PlayingCard do
let(:card) { described_class.new suit: :clubs, rank: 10 }
let(:other) { described_class.new suit: :clubs, rank: 10 }
it 'should be eql' do
expect(card).to eq other
end
end
end
Такой тест не пройдет, так как они будут сравниваться по адресу. Чтобы тест прошел, мы должны сравнивать Value-Obects по значению, для этого допишем метод сравнения:
def ==(other)
suit == other.suit &&
rank == other.rank
end
Теперь наш тест пройдет. Мы также можем дописать методы, которые отвечают за сравнение, но как нам сравнить 10 и K? Как вы уже, наверное, догадались, мы тоже их представим в виде Объектов-Значений. Ок, значит теперь мы должны будем инициировать десятку трефа так:
ten = Values::Rank.new('10')
clubs = Values::Suits.new(:clubs)
ten_clubs = Values::PlayingCards.new(rank: ten, clubs: clubs)
Три строчки это достаточно много для ruby. Для того, чтобы обойти это ограничение, мы введем третье свойство Объекта-Значения — оборачиваемость. Пусть у нас появится специальный метод класса .wrap
, который может принимать значения различного типа и преобразовывать их в нужный.
class PlayingCard < Lunapark::Values::Compound
def self.wrap(obj)
case obj.is_a? self.class # Если мы получили объект класса PlayingCard
obj # то мы его и вернем
case obj.is_a? Hash # Если мы получили хэш, то создадим на его основе
new(obj) # Новую игральную карту
case obj.is_a String # Если мы получили строку, то последний символ будет
new rank: obj[0..-2], suit:[-1] # мастью, остальные - достоинством карты.
else # если тип не совпадает с ожидаемым
raise ArgumentError # выдаем ошибку.
end
end
def initialize(suit:, rank:) # Еще модифицируем инициализатор класса
@suit = Suit.wrap(suit) # Это позволит нам оборачивать значения
@rank = Rank.wrap(rank)
end
end
Такой подход дает большое преимущество:
ten = Values::Rank.new('10')
clubs = Values::Suits.new(:clubs)
from_values = Values::PlayingCard.wrap rank: ten, suit: clubs
from_hash = Values::PlayingCard.wrap rank: '10', suit: :clubs
from_obj = Values::PlayingCard.wrap from_values
from_str = Values::PlayingCard.wrap '10C' # тут хотелось бы использовать симол треф из utf кодировки, но хабр, их обрезает.
Все эти карты будут равны между собой. Если метод wrap
разрастается хорошей практикой, будет вынесение его в отдельный класс. С точки зрения догматического подхода отдельный класс так же будет обязательным.
Хм, а как насчет места в колоде? Как узнать, является ли данная карта козырем? Это не игральная карта. Это Значение игральной карты. Это именно та надпись 10, которую вы ведите на углу картона.
К Объекту-Значению нужно относится также, как и к примитиву, который почему-то не реализовали в ruby. Отсюда возникает последнее свойство — Объект-Значение не привязан ни к какому домену.
Рекомендации
Среди всего многообразия методов и инструментов, используемых в каждый момент каждого процесса, всегда есть один метод и инструмент, который работает быстрее и лучше остальных.
Фредерик Тейлор 1914
Арифметические операции должны возвращать новый объект
# GOOD
class Money < LunaPark::Values::Compound
def +(other)
other = self.class.wrap(other)
raise ArgumentError unless same_currency? other
self.class.new(
amount: amount + other.amount,
currency: currency
)
end
end
Атрибуты Объекта-Значения могу быть только примитивами или другими Объектами-значения
# GOOD
class Weight < LunaPark::Values::Compound
def intialize(value:, unit:)
@value = value
@unit = Unit.wrap(unit)
end
end
# BAD
class PlaingCard < LunaPark::Value
def initialize(rank:, suit:, deck:)
...
@deck = Entity::Deck.wrap(deck) # зависимость от сущности
end
end
Простые операции держите внутри методов класса
# GOOD
class Weight < LunaPark::Values::Compound
def >(other)
value > other.convert_to(unit).value
end
end
Если операция "конвертация" большая, то возможно есть смысл вынести ее в отдельный класс
# UGLY
class Weight < LunaPark::Values::Compound
def convert_to(unit)
unit = Unit.wrap(unit)
case { self.unit.to_sym => unit.to_sym }
when { :kg => :ft }
Weight.new(value: 2.2046 * value, unit.to_sym)
when
# ...
end
end
end
# GOOD
#./lib/values/weight/converter.rb
class Weight
class Converter < LunaPark::Services::Simple
def initialize(weight, to:)
...
end
end
end
#./lib/values/weight.rb
class Weight < LunaPark::Values::Compound
def convert_to(unit)
Converter.call! self, to: unit
end
end
Такое вынесение логики в отдельный Сервис возможно только при условии того, что Сервис изолирован: он не использует данные ни с каких внешних источников. Этот сервис должен быть ограничен контекстом самого Объекта-Значения
Объект значение не может ничего знать о доменной логике
Предположим, что мы пишем интернет магазин, и у нас есть рейтинг товаров. Чтобы его получить, необходимо сделать запрос в БД через Репозиторий.
# DEADLY BAD
class Rate < LunaPark::Values::Single
def top?(10)
Repository::Rates.top(first: 10).include? self
end
end
Сущность (Entity)
Класс Сущность отвечает за какой-то реальный объект. Это может быть договор, стул, агент недвижимости, пирог, утюг, кот, холодильник — всё что угодно. Любой объект, который может вам понадобиться для моделирования ваших бизнес-процессов, — это Сущность.
Понятие Сущности по Эвансу и по Мартину отличаются. С точки зрения Эванса, сущность — это объект, характеризующийся чем-то, что подчеркивает ее индивидуальность.
Если объект определяется уникальным индивидуальным существованием, а не набором атрибутов, это свойство следует с читать главным при определении объекта в модели. Определение класса должно быть простым и строиться вокруг непрерывности и уникальности цикла существования объекта. Найдите способ различать каждый объект независимо от его формы или истории существования. С особым вниманием отнеситесь к техническим требованиям, связанным с сопоставлением объектов по их атрибутам. Задайте операцию, которая бы обязательно давала неповторимый результат для каждого такого объекта, — возможно, для этого с объектом придется ассоциировать некий символ с гарантированной уникальностью. Такое средство идентификации может иметь внешнее происхождение, но это может быть и произвольный идентификатор, сгенерированный системой для ее собственного удобства. Однако такое средство должно соответствовать правилам различения объектов в модели. В модели должно даваться точное определение, что такое одинаковые объекты.
С точки зрения Мартина, Entity — это не объект, а слой. Этот слой объединят как объект, так и бизнес-логику по его изменению.
My view of Entities is that they contain Application Independent Business rules. They are not simply data objects. They may hold references to data objects; but their purpose is to implement business rule methods that can be used by many different applications.
Gateways return Entities. The implementation (below the line) fetches the data from the database, and uses it to construct data structures which are then passed to the Entities. This can be done either with containment or inheritance.
For example:
public class MyEntity { private MyDataStructure data;}
or
public class MyEntity extends MyDataStructure {...}
And remember, we are all pirates by nature; and the rules I'm talking about here are really more like guidelines...
Мы под Сущностью будем иметь в виду только структуру. В простейшем варианте класс Entity будет выглядеть так:
module Entities
class MeatBag < LunaPark::Entities::Simple
attr_accessor :id, :name, :hegiht, :weight, :birthday
end
end
Мутабельный объект, описывающий структуры бизнес модели, может содержать примитивные типы и Значения.
Класс LunaPark::Entites::Simple
невероятно прост, вы можете посмотреть его код, он дает нам только одну вещь — легкую инициализацию.
module LunaPark
module Entities
class Simple
def initialize(params)
set_attributes params
end
private
def set_attributes(hash)
hash.each { |k, v| send(:"#{k}=", v) }
end
end
end
end
Вы можете написать:
john_doe = Entity::MeatBag.new(
id: 42,
name: 'John Doe',
height: '180cm',
weight: '80kg',
birthday: '01-01-1970'
)
Как вы уже наверное догадались вес, рост и дату рождения мы хотим обернуть в Объекты-значения.
module Entities
class MeatBag < LunaPark::Entites::Simple
attr_accessor :id, :name
attr_reader :heiht, :wight, :birthday
def height=(height)
@height = Values::Height.wrap(height)
end
def weight=(height)
@height = Values::Weight.wrap(weight)
end
def birthday=(day)
@birthday = Date.parse(day)
end
end
end
Чтобы не тратить время на подобные конструкторы, у нас подготовлена более сложная Реализация LunaPark::Entites::Nested
:
module Entities
class MeatBag < LunaPark::Entities::Nested
attr :id
attr :name
attr :heiht, Values::Height, :wrap
attr :weight, Values::Weight, :wrap
attr :birthday, Values::Date, :parse
end
end
Как можно догадаться из названия, данная Реализация позволяет делать древовидные структуры.
Давайте удовлетворим мою страсть к крупногабаритной бытовой технике. В прошлой статье мы проводили аналогию между "крутилкой" стиральной машины и архитектурой. А сейчас мы опишем такой важный бизнес-объект как холодильник:
class Refregerator < LunaPark::Entites::Nested
attr :id,
attr :brand
attr :title
namespace :fridge do
namespace :door do
attr :upper, Shelf, :wrap
attr :lower, Shelf, :wrap
end
attr :upper, Shelf, :wrap
attr :lower, Shelf, :wrap
end
namespace :main do
namespace :door do
attr :first, Shelf, :wrap
attr :second, Shelf, :wrap
attr :third, Shelf, :wrap
end
namespace :boxes do
attr :left, Box, :wrap
attr :right, Box, :wrap
end
attr :first, Shelf, :wrap
attr :second, Shelf, :wrap
attr :third, Shelf, :wrap
attr :fourth, Shelf, :wrap
end
attr :last_open_at, comparable: false
end
Такой подход избавляет нас от создания ненужных Сущностей, таких как дверь от холодильника. Без холодильника она должна быть частью холодильника. Такой подход удобен для составления сравнительно больших документов, например заявка на покупку страховки.
У класса LunaPark::Entites::Nested
есть еще 2 важных свойства:
Сравнимость:
module Entites
class User < LunaPark::Entites::Nested
attr :email
attr :registred_at
end
end
u1 = Entites::User.new(email: 'john.doe@mail.com', registred_at: Time.now)
u2 = Entites::User.new(email: 'john.doe@mail.com', registred_at: Time.now)
u1 == u2 # => false
Два указанных пользователя не эквивалентны, т.к. они были созданы в разное время и поэтому значение атрибута registred_at
будет отличаться. Но если мы вычеркнем атрибут из списка сравниваемых:
module Entites
class User < LunaPark::Entites::Nested
attr :email
attr :registred_at, comparable: false
end
end
то получим два сопоставимых объекта.
Эта Реализация так же обладает свойством оборачиваемости — мы можем использовать метод класса`wrap
Entites::User.wrap(email: 'john.doe@mail.com', registred_at: Time.now)
Вы можете использовать в качестве Entity — Hash, OpenStruct или любой понравившийся вам gem, который поможет вам реализовать структуру вашей сущности.
Сущность — это модель бизнес объекта, оставьте ее простой. Если какое-то свойство не используется вашим бизнесом, не описывайте его.
Изменения сущности
Как вы заметили, класс Сущность не имеет никаких методов собственного изменения. Все изменения делаются из вне. Объект-значения тоже иммутабелен. Все те функции, которые в нем присутствуют, по большому счету декорируют сущность или создают новые объекты. Сама сущность остается неизменной. Для разработчика Ruby on Rails такой подход будет непривычен. Со стороны может показаться, что мы вообще используем ООП-язык для чего-то другого. Но если присмотреться поглубже — это не так. Разве окно может открыться само по себе? Автомобиль доехать до работы, гостиница забронироваться, милый котик получить нового подписчика? Это все внешние воздействия. Что-то происходит в реальном мире, а мы отражаем это у себя. По каждому запросу мы вносим изменения в свою модель. И тем самым поддерживаем ее в актуальном состоянии, достаточном для наших бизнес задач. Стоит разделять состояние модели и процессы, вызывающие изменения этого состояния. Как это сделать, мы рассмотрим в следующей статье.
Автор: g6uru