Игорь Александров
jetrockets.pro
github.com/jetrockets
• Пишу на Rails c 2008 года 

• Начал с Rails 2.2

• Пришел в Ruby из SmallTalk

• Последние 5 лет слышу “Ruby/Rails мертв”
Not the Rails Way…
Ruby on Rails разработчики валят с проекта
George Catlin, 1832
after_save…
after_commit on:…
accepts_nested_attributes_for…
validates_presence_of…
Пример…
Приложение –
цепочка сервисов
Запрос => [*] => [*] => Ответ
Приложение –
цепочка функций
class CreateDocument
…
end
class CreateDocument
def call
…
end
end
class CreateDocument
def call(document, params)
…
end
end
class CreateDocument
def call(document, params)
document.attributes = params
document.save!
end
end
class CreateDocument
def call(document, params)
document.attributes = params
if document.valid?
rename_document(document)
end
document.save!
end
end
• Единый интерфейс сервисов

• Частично избавились от колбеков

• Тестирование функциональных объектов – проще

• Композиция сервисов
Что стало лучше?
Проблемы
• Валидации остались в моделях:

document.save(validate: false)
# OR
document.from_crm = true
document.save
• Композиция сервисов получалась не всегда,
получалось дублирование

• Тестирование не стало проще из-за
невозможности управлять зависимостями

• Не получалось сделать композицию объектов
(Domain)
Form Object
Reform
http://trailblazer.to/gems/reform/
class CreateDocument
def call(document, params)
form = CreateDocumentForm.new(document)
if form.validate(params)
document = form.sync
rename_document(document)
end
document.save!
end
end
class CreateDocument
def call(document, params)
form = CreateDocumentForm.new(document)
if form.validate(params)
rename_document(document)
end
document.save!
end
end
class CreateDocument
def initialize(form_class:, rename_document:)
…
end
def call(document, params)
…
end
end
class CreateDocument
attr_reader :form_class
attr_reader :rename_document
def initialize(form_class:, rename_document:)
@form_class = form_class
@rename_document = rename_document
end
def call(document, params)
…
end
end
class CreateDocument
def call(document, params)
form = form_class.new(document)
if form.validate(params)
document = form.sync
rename_document(document)
document.save!
end
document
end
end
# Initialize
command = CreateDocument.new(
form_class: Document::CreateDocumentForm,
rename_document: Document::RenameDocument.new
)
# Use several times
command.call(passport, params[:passport])
command.call(visa, params[:visa])
# создание документа от заёмщика
command = CreateDocument.new(
form_class: Document::CreateDocumentForm,
rename_document: Document::RenameDocument.new
)
# создания документа из CRM
command = CreateDocument.new(
form_class: Crm::Document::CreateDocumentForm.new,
rename_document: Document::RenameDocument.new
)
class CreateDocument
attr_reader :form
attr_reader :rename_document
attr_reader :upload_document_to_perfect_audit
…
end
Увеличение зависимостей
# Document::CreateDocumentForm
form_class
# Document::RenameDocument
rename_document(document)
# Document::PerfectAuditUploader
upload_document_to_perfect_audit(document)
class CreateDocument
attr_reader :form_class
attr_reader :rename_document
attr_reader :upload_document_to_perfect_audit
attr_reader :update_expiration_dates
attr_reader :convert_document_to_pdf
attr_reader :create_affiliations
attr_reader :update_cpl_records
attr_reader :update_application_status
# hundreds of other stuff here
…
end
dry-rb
dry-rb
18 библиотек
Каждая библиотека служит своей цели
• IoC Container

• Позволяет реализовать Dependency inversion
principle

• Thread safe
dry-container
dry-container
# app/containers/global_container.rb
module GlobalContainer
extend Dry::Container::Mixin
namespace('services') do
register('create_document') do
Document::CreateDocument.new(
self[‘forms.create_document_form_class']
)
end
end
namespace('forms') do
register('create_document_form_class') do
Document::DocumentForm
end
end
end
dry-container
# создание документа от заёмщика
command = GlobalContainer[‘services.create_document’]
command.form # Document::CreateDocumentForm
command.class # Document::CreateDocument
# создания документа из CRM
command = GlobalContainer[‘crm.services.create_document’]
command.form # Crm::Document::CreateDocumentForm
command.class # Document::CreateDocument
dry-container
# создание документа от заёмщика
command = GlobalContainer[‘services.create_document’]
command.form # Document::CreateDocumentForm
command.class # Document::CreateDocument
# создания документа из CRM
command = GlobalContainer[‘crm.services.create_document’]
command.form # Crm::Document::CreateDocumentForm
command.class # Document::CreateDocument
dry-monads
• Монада – контейнер, в котором есть результат с
объектом

• Either монада (классическая) – это Result монада в dry-rb

• [паттерн] позволяет делать chaining

• Синтаксический сахар
Maybe
# corporate_action/charts_mapper.rb
class CorporateAction::ChartsMapper
include Dry::Monads::Maybe::Mixin
def fetch_mapping(corporate_action, fund, entry)
Maybe(
# some calculation that may return nil
)
end
end
# fund/calculate_share_price.rb
mapping = mapper.fetch_mapping(action, fund, entry)
mapping.bind do |m|
calculate_price_by_valuation_amount(fund, m))
end.or(issue_price_of_account(account)))
Result
class CreateDocument
def call(document, params)
form = form_class.new(document)
if form.validate(params)
document = form.sync
rename_document(document)
document.save!
end
document
end
end
Result
class CreateDocument
def call(document, params)
form = form_class.new(document)
if form.validate(params)
document = form.sync
rename_document(document)
document.save!
else
return false
end
document
end
end
Result
class CreateDocument
include Dry::Monads::Result::Mixin
def call(document, params)
form = form_class.new(document)
if form.validate(params)
…
document.save!
else
return Failure(form)
end
Success(document)
rescue => e
Failure(e)
end
end
Try
class CreateDocument
include Dry::Monads::Result::Mixin
include Dry::Monads::Try::Mixin
…
def call(document, params)
form = form_class.new(document)
if form.validate(params)
Try do
document.save!
…
document
end.to_either
else
Failure(form)
end
end
end
dry-matcher
• pattern matching

• Синтаксический сахар

• Есть встроенный Result Matcher

• Можно написать свой
dry-matcher
class CreateDocument
include Dry::Matcher.for(:call,
with: Dry::Matcher::ResultMatcher)
…
def call(document, params)
form = form_class.new(document)
if form.validate(params)
Try do
document.save!
…
document
end.to_either
else
Failure(form)
end
end
end
dry-matcher
command = GlobalContainer[‘services.create_document’]
command.call(document, params[:document]) do |m|
m.success do |document|
render json: {
document: DocumentRepresenter.new.call(document)
}
end
m.failure do |form|
render_failure_json(
422, document: form.react_errors_hash
)
end
end
dry-matcher (custom)
class CreateDocument
include Dry::Matcher.for(:call, with: TripleMatcher)
…
def call(document, params)
form = form_class.new(document)
if form.validate(params)
…
document.save!
[:success, document]
else
[:failure, :validation, form]
end
rescue => e
[:failure, e]
end
end
dry-matcher (custom)
command = GlobalContainer[‘services.create_document’]
command.call(document, params[:document]) do |m|
m.success do |document|
…
end
m.failure :validation do |form|
render_failure_json(
422, document: form.react_errors_hash
)
end
m.failure do |e|
render_failure_json(500, document: e.message)
end
end
• Валидации основаны на логике предикатов

• Работают с любыми типами входных данных

• Можно валидировать формы, HTTP параметры, JSON
документы, конфигурацию из YAML

• Расширяемость

• Широко используются в других библиотеках
dry-validation & dry-types
dry-initializer
• param и option

• default values

• type constraints

• private/protected readers
dry-initializer
class CreateDocument
attr_reader :form_class
attr_reader :rename_document
def initialize(form_class:, rename_document:)
@form_class = form_class
@rename_document = rename_document
end
def call(document, params)
…
end
end
dry-initializer
class CreateDocument
extend Dry::Initializer
param :form_class
param :rename_document
def initialize(form_class:, rename_document:)
@form_class = form_class
@rename_document = rename_document
end
def call(document, params)
…
end
end
RSpec
describe Document::CreateDocument do
subject(:command) do
GlobalContainer[
‘services.create_document_command'
]
end
…
end
Что мы тестируем?
RSpec / Stubs
require ‘dry/container/stub'
describe "#call" do
before(:all) do
GlobalContainer.enable_stubs!
end
…
GlobalContainer.stub(
‘support.services.exception_notifier’,
ExceptionRaiser
)
end
Итоги
Плюсы
• тестирование функциональных объектов

• простое расширение логики

• композиция – основа моделирования процессов

• масштабирование и слабая связанность
компонентов приложения
Минусы
• имитация функций объектами

• концептуально множатся сущности (которые не
являются сущностями по факту)

• IoC с доступом по строкам

• можно написать монадический метод и вернуть не
монадическое значение
Спасибо
github.com/igor-alexandrov
facebook.com/igor.alexandrov
twitter.com/igor_alexandrov
Not the Rails Way

Not the Rails Way