Главная » Хабрахабр » Поваренная книга разработчика: DDD-рецепты (4-я часть, Структуры)

Поваренная книга разработчика: DDD-рецепты (4-я часть, Структуры)

Перейдем от теории к практике, к написанию кода. Итак, мы уже определились с областью применения, методологией и архитектурой. Но прежде чем приступить к ним, изучим структурные паттерны — ValueObject и Entity. Хотелось бы начать с шаблонов проектирования, которые описывают бизнес логику — Service и Interactor. В дальнейших статьях разберем все паттерны, необходимые для разработки с использованием Вариативной архитектуры. Разрабатывать мы будем на языке ruby. Все наработки, являющиеся приложениями к данному циклу статей, соберем в отдельный фреймворк.

Blacjack & hockers

И мы уже подобрали подходящее название — LunaPark.
Текущие наработки выложенны на Github.
Разобрав все шаблоны, соберем один полноценный микросервис.

Была готовая команда ruby-разработчиков. Была необходимость в рефакторинге сложного корпоративного приложения, написанного на Ruby on Rails. Не смотря на то, что выбор языка, в основном, был обусловлен нашей специализацией, он оказался достаточно удачным. Методология Domain Driven Development прекрасно подходила для этих задач, но готового решения на используемом языке не было. И поэтому больше других подходит для моделирования реальных объектов. Среди всех языков, что принято использовать для web-приложений, ruby, на мой взгляд, является самым выразительным. Это не только мое мнение.

Then you have the new-comers like Ruby. That is the Java world. 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. 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). 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. 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. (A few infrastructure pieces would probably have to be filled in.) If Ruby usage ever starts going in that direction, I think it could provide an excellent platform for DDD.

Eric Evans 2006

В интернете можно найти попытки приспособить для этого Rails, но все они выглядят ужасно. К сожалению, за прошедшие 13 лет ничего особо не изменилось. Смотреть без слез, как кто-то пытается изобразить на основе AсtiveRecord реализацию паттерна Репозиторий, очень тяжело. Фреймворк Rails тяжелый, медленный и не соответствует принципам SOLID. Попробовали Grape, идея с авто-документированием показалась удачной, но в остальном он был заброшенным и мы быстро отказались от идеи его использования. Мы решили взять на вооружение какой-нибудь микрофреймворк и доработать его до наших потребностей. Мы до сих пор продолжаем его использовать для REST Контроллеров и Эндпоинтов. И почти сразу стали использовать другое решение — Sinatra.

REST ?

У нее есть свои плюсы и минусы, полное перечисление которых выходит за рамки данной статьи. Если вы разрабатывали web-приложения, то уже имеете представление о технологии. А преимуществом будет его понятность — технология ясна как back-end разработчикам, так и разработчикам front-end'a.
Но может тогда не ориентироваться на REST, а реализовать свое решение http + json? Но для нас, как разработчиков корпоративных приложений, самым главным недостатком будет то, что REST (это понятно даже из названия) отражает не процесс, а его состояние. Гораздо больше, чем если вы предоставите привычный REST.
Будем считать использование REST компромиссным решением. Если даже вам удасться разработать свой сервисный API, то предоставляя его описание третьим лицам вы получите много вопросов. Так что REST не должен вообще беспокоить, если остались сомнения на его счет. Мы используем JSON для лаконичности и jsonapi стандарт, чтобы не тратить время разработчиков на священные войны по поводу формата запросов.
В дальнейшем, когда мы будем разбирать Endpoint, мы увидим, что для того, чтобы избавится от rest, достаточно переписать всего один класс.

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

Новые сотрудники, не имевшие дело с практиками DDD и чистой архитектурой, не могли понять код и его предназначение. Тут и возникли основные трудности. Если бы я сам увидел этот код впервые до того как прочитал Эванса, я бы воспринял его как legacy, over-engineering.

Наброски этой документации показались удачными и было решено выложить их на Хабре. Чтобы побороть это препятствие было принято решение написать документацию (guideline), описывающую философию используемых подходов. Абстрактные классы, которые повторялись из проекта в проект, было решено вынести в отдельный gem.

Шест — это по сути палка, очень примитивный инструмент, один из первых, который попал человеку в руки. legacy-way
Если вспомнить какой-нибудь классический фильм про боевые искусства, то там будет крутой парень, который очень ловко обращается с шестом. Мы выделили 4 основных принципа: Но в руках мастера он становится грозным оружием.
Можно потратить время на создание пистолета, который не стреляет тебе в ногу, а можно потратить время на обучение технике стрельбы.

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

На моем ноутбуке Linux долго не приживался, рано или поздно он ломался и мне постоянно приходилось его переустанавливать. Схожую философию можно проследить например у ОС ArchLinux — The Arch Way. Но потратив один раз 2-3 дня на установку Arch я разобрался с тем как моя ОС работает. Это вызывало ряд проблем, иногда серьезных вроде срыва deadline по работе. Мои заметки помогли мне устанавливать ее на новые ПК за пару часов. После этого она стала работать стабильнее, без сбоев. А обильная документация помогала мне решать новые задачи.

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

Frame — рамка, Work — работа. Сложно назвать LunaPark фреймворком в привычном смысле. Единственная рамка, которую мы декларируем, это та, которая подсказывает класс, в котором должна быть описана та или иная логика. Мы же призываем не ограничивать себя рамками. Это скорее набор инструментов с объемной инструкцией к ним.
Каждый класс — абстрактный и имеет три уровня:

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, будет сделан отдельный репозиторий, который покажет как все "прикрутить" для быстрого старта вашего проекта. К окончанию цикла все они будут пригодны к использованию на проекте.

Не стоит воспринимать данные статьи как документацию к фреймворку. Фреймворк является прежде всего приложением к документации.

Итак, перейдем к делу.

— Какого роста твоя подруга?
— 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) 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 — это не объект, а слой.

Разъеснение от Мартина

They are not simply data objects. My view of Entities is that they contain Application Independent Business rules. They may hold references to data objects; but their purpose is to implement business rule methods that can be used by many different applications.

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. Gateways return 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 невероятно прост, вы можете посмотреть его код, он дает нам только одну вещь — легкую инициализацию.

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

Как можно догадаться из названия, данная Реализация позволяет делать древовидные структуры.

В прошлой статье мы проводили аналогию между "крутилкой" стиральной машины и архитектурой. Давайте удовлетворим мою страсть к крупногабаритной бытовой технике. А сейчас мы опишем такой важный бизнес-объект как холодильник:

Refregerator

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 такой подход будет непривычен. Сама сущность остается неизменной. Но если присмотреться поглубже — это не так. Со стороны может показаться, что мы вообще используем ООП-язык для чего-то другого. Автомобиль доехать до работы, гостиница забронироваться, милый котик получить нового подписчика? Разве окно может открыться само по себе? Что-то происходит в реальном мире, а мы отражаем это у себя. Это все внешние воздействия. И тем самым поддерживаем ее в актуальном состоянии, достаточном для наших бизнес задач. По каждому запросу мы вносим изменения в свою модель. Как это сделать, мы рассмотрим в следующей статье. Стоит разделять состояние модели и процессы, вызывающие изменения этого состояния.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] ВИЧ – методы лечения от первых лекарств до сегодняшнего дня

Прежде, чем приступить к изложению материала, хотелось бы сказать несколько слов о себе: участник сообществ по борьбе с отрицанием ВИЧ („ВИЧ/СПИД диссидентством“): в 2016-2018 годах „ВИЧ/СПИД диссиденты и их дети“, с 2018 года – „ВИЧ/СПИД отрицание и альтернативная медицина“. Это ...

Изюминки прошедшей Moscow Python Conf++ 2019: трансформация в площадку для общения

Самыми горячими темами Moscow Python Conf++ оказались асинхронная разработка, а также сопоставление Python, его лучших практик и инструментария с аналогами из других языков, и его место в ландшафте современной разработки. Плюс мы пригласили выступить Бенджамина Петерсона, одного из разработчиков CPython, ...