Pull to refresh

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

Reading time 13 min
Views 13K

Введение


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


Blacjack & hockers


И мы уже подобрали подходящее название — 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 Контроллеров и Эндпоинтов.


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.


Философия


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


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

Tags:
Hubs:
+14
Comments 18
Comments Comments 18

Articles