Сегодня попался рельсовый код, в котором используются монады, сервисы и прочее. Решил сделать обзор с объяснением того, что в коде не нравится и что можно исправить.
Данный разбор основан только на личном опыте и избегает попытку написать самый идеальный код на свете. К сожалению пошарить ссылку на код не могу, потому что автор попросил опубликовать анонимно.
Главная операция, которая вызывается из контроллера выглядит следующим образом:
module Excursion
module Services
class JoinUser < Service
def initialize(params, record: ,
input_validator: Users::Validators::NewUser.new,
user_exists_check: Users::Services::CheckExists,
user_creator: Users::Services::Create
)
@params = params
@input_validator = input_validator
@record = record
@user_creator = user_creator
@user_exists_check = user_exists_check
end
def call
validated_params = yield input_validate
yield business_validate(validated_params.to_h)
save(validated_params.to_h)
end
private
attr_reader :params, :record, :input_validator, :user_creator, :user_exists_check
def input_validate
input_validator.call(params[:group]).to_monad
end
def business_validate(validated_params)
return Failure(:already_exists) if record.users.where(validated_params).exists?
Success()
end
def save(validated_params)
user_check_result = user_exists_check.new(validated_params[:email]).call
if user_check_result == Success(:user_not_exists)
user = yield user_creator.new(validated_params).call
record.users << user
return Success(:created)
end
record.users << user_check_result.value!
Success(:created)
end
end
end
end
Два сервиса зависимостей, которые используются в этом примере выглядят так:
module Users
module Service
class CheckExists < Service
def initialize(email, model: User)
@email = email
@model = model
end
def call
return Success(model.find_by(email: email)) if model.where(email: email).exists?
Success(:user_not_exists)
end
private
attr_reader :email, :model
end
end
end
module Users
module Services
class Create < Service
def initialize(params,
input_validator: Users::Validators::NewUser.new,
model: User
)
@params = params
@input_validator = input_validator
@model = model
end
def call
yield input_validate
save
end
private
attr_reader :params, :model, :input_validator
def input_validate
input_validator.call(params).to_monad
end
def save
Try() { model.create!(params) }
end
end
end
end
Убрал общие слова из именования объектов, сервисов и данных. Как пример, в изначальном варианте есть строчка save(validated_params.to_h)
(Excursion::Services::JoinUser#call
), как оказалось - это запись пользователя на экскурсию, т.е. с точки зрения кода - это сохранение чего-то в базе, но с точки зрения логики это совершенно другая операция. Если использовать общее именование - придется каждый раз думать зачем этот код, а если использовать описание процесса - появляется контекст, в котором понять реализацию проще, чем по реализации понять контекст. Поэтому переименовал этот шаг в enroll
.
В изначальном примере можно увидеть 2 глобальные проблемы:
- Зависимость от кучи зависимостей
- Оба сервиса реализуют логику работы с базой данных (проверка существования пользователя и пользователя сохранение в базе).
Большой соблазн сделать 2 отдельных сервиса и положить эту логику туда, как сделал автор оригинального примера. К сожалению, можно заметить, что такой подход приводит к усложнению бизнес логики, яркий пример - две строчки из Excursion::Services::JoinUser#save
:
def save(validated_params)
user_check_result = user_exists_check.new(validated_params[:email]).call
# вот тут приходится делать страшную проверку с использованием монады
#
# так же, сама монада самоисключающий случай и вне контекста логики не понятно
# почему отсутствие пользователя успех
if user_check_result == Success(:user_not_exists)
# в данном случае приходится использовать DO notations во вложенном методе,
# таким образом появляется вложенность, что приводит к усложению data flow
user = yield user_creator.new(validated_params).call
Получается, что автор лишний раз обернул операцию с базой данных и тем самым раздул собственный код. Поэтому, кажется, что подобные методы должны находиться в моделе/репозитории. Так же, судя по неймингу, приходим к проблеме контекста, так как названия сервисов является описанием работы кода, а не описанием бизнес логики.
В примере много общих слов, которые увеличивают конгнетивную нагрузку на человека, который этот код читает. Я говорю о record
, user_exists_check
, validated_params
, business_validate
, input_validate
, params
без контекста не понятно, что это такое, потому что терминология общая. В качестве примера: прочтите название двух сервисов и попробуйте ответить себе, что каждый из них делает:
Excursion::Services::JoinUser
Excursion::Services::EnrollUser
В оригинальном примере можно заметить, что конструктор каждого из классов представляет из себя микс из зависимостей и переменных.
def initialize(params, record:, # данные
input_validator: Users::Validators::NewUserContract.new, # зависимость
user_exists_check: Users::Services::CheckExists, # зависимость
user_creator: Users::Services::Create # зависимость
)
def initialize(email, model: User)
@email = email # данные
@model = model # зависимость
end
def initialize(params, # данные
input_validator: Users::Validators::NewUserContract.new, # зависимость
model: User # зависимость
)
В этом нет проблем до тех пор, пок не придется тестировать код и использовать тяжелособирающиеся зависимости (например rom). Так же, каждый вызов сервиса приведет к новой аллокации объектов, но это не так критично. Намного критичнее завязка локики на внутренний стейт объекта. Во первых, сервис перестает быть чистым(мутация данных приведет к багам), во вторых, поддержка кода становится сложнее, так как аргументы могут подсказать детали реализации. В качестве примера сравните два определения функций:
def save(validated_params)
def save(record, validated_params)
В первом случае первая мысль приходящая в голову - параметры сохраняются в базе и создается новый объект модели. Во втором случае понимаешь, что сохраняются данные для конкретной записи. Проблема в том, что это не выдуманный пример. Если посмотреть на оригинал - save
действительно использует record
который является внутренним стейтом объекта. Но на самом деле record
- это просито инстанс модели excursion
.
Распространный подход в рельсе: цепочки методов в моделе, которые сложно тестировать. Я говорю о случаях record.users.where(payload).exists?
и подобных ему. Проблема заключается в том, что для такого кода сложно написать юнит тест, поэтому приходиться либо писать сложный мок, либо использовать интеграционное тестирование (на любой тест ходить в базу). В каждом из случаев возникают проблемы: моки сложно поддерживать и понять. А походы в базу увеличивают время прохождения тестов на минуты и даже часы.
В итоге, после рефакторинга, основанного на идеях описанных выше, получился код, в котором видно какие бизнес шаги происходят (валидация данных, проверка пользователя на участие в экскурсии, запись пользователя на экскурсию). Данный код не идеальны, но даже сейчас он позволяет отказаться от избыточности сервисов, добавляет бизнес контекст и может быть покрыт юнит тестами без сложной подготовки данных.
module Excursion
module Services
class EnrollUser < Core::Service
attr_reader :validator
def initialize(validator: Users::Validators::NewUserContract.new)
@validator = validator
end
def call(excursion, params)
paylaod = yield validate_raw_data(params)
yield attender_of_excursion?(excursion, payload)
user = yield find_or_create_by_email(payload[:email])
Success(excursion.enroll(user))
end
def attender_of_excursion?(excursion, payload)
excursion.attender?(payload) ? Failure(:attender_of_excursion) : Success()
end
def find_or_create_by_email(payload)
Try() { User.find_or_create(payload) }.to_result
end
end
end
end
class Excursion
def attender?(payload)
users.where(payload).exists?
end
def enroll(user)
self.users << user
self
end
end
class User
def self.find_or_create(payload)
find_by_email(payload[:email]) || create!(payload)
end
end
- спасибо @likeath, в последнем варианте
User
должен инжектиться в сервис, т.е. конструктор сервиса должен выглядеть так:
module Excursion
module Services
class EnrollUser < Core::Service
attr_reader :model, :validator
def initialize(model: User, validator: Users::Validators::NewUserContract.new)
@model = model
@validator = validator
end
Мне лично не понятно, почему
attender_of_excursion?
выдаёт Failure, и в каком именно случае. При разных применениях можно считать "успехом" тот факт, что кто-то attends (при погрузке участников в автобус, например) или наоборот (повторный enroll).