Что не так с сервисами в этих ваших RoR

Начать стоит с того, что писать статьи о фреймворках идея не очень хорошая, в виду того, что фреймворки постоянно меняются, а статьи нет. Но, судя по тому, что за те четыре версии (2, 3, 4 и 5) с которыми я работал ситуация не поменялась, можно писать. Более того, в виду некоторых практик, применяемых при разработке RoR, есть подозрение, что они не будут исправлены никогда.

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

При просмотре трендов, касающихся сложности кода в RoR приложениях, можно увидеть следующее:
изначально, ни о каких практиках речи не шло, был MVC и каждый размазывал свою логику по приложению. Ближе к 2010 году появился тренд (а на самом деле еще в 2006 (например) гласящий: Fat Models and Skinny Controllers и люди повально начали переносить логику из контроллеров в модели, отсюда модели по пару тысяч строк и это не предел, другой вопрос, что статьи не говорили напрямую “тяните все в модели”, но кого волнует. Не смотря на все проблемы этого подхода, он прожил своих года два-три, но не решил главную проблему — код все еще был сосредоточен в неподходящем компоненте. Исходя из парадигмы MVC, модель — “Предоставляет знания: данные и методы работы с этими данными; Реагирует на запросы, изменяя своё состояние;”, ничего о бизнес-логике тут нет. Ближе к 2012 году начали появляться статьи о том, что самым верным подходом является “Skinny everything”, который все еще держится и не планирует умирать (а первые ласточки еще были в 2008 году, вот подробнее 2011). Одной из первых крутых статей по этому поводу появилась в блоге CodeClimate (еще бы). В ней же были слова о пагубной привычки скрывать сложность кода за concern’ами. Одним из путей управления сложностей, описываемых в статье, является Service Object. Исходя из описания, сервисные объекты необходимы для инкапсуляции сложной логики.

На самом деле, одним из первых идею использования сервис-подобных объектов поднял великий и ужасный дядющка Боб еще аж в 2011, но почему-то тогда большинство на это забило, а зря.

Прежде чем начать, необходимо дать дефиницию сервиса как такового, с чем, на самом деле, проблемы. Учитывая что текст не претендует на академичность, я попробую обойтись своим определением выглядящим как-то так: сервис — это черный ящик, реализующий какую-то бизнес-логику, который принимает на вход данные, как-то их обрабатывает и отдает в обратно в обработанном виде. Как правило, вы можете еще делать какие-либо сайд-эффекты вроде сохранения или обновления записей, но в идеале и этого быть не должно.

Если переходить уже к самим сервисам, то тут есть много моментов, которые, как мне кажется, необходимо прояснить.

1. Относитесь к вашим сервисам как к потенциально выносимому коду. Это может быть как гем, так и эти ваши микросервисы, все что душе угодно. Отсюда следуют следующие практики:
 — Зависимости вашего сервиса должны быть явными == инъектироваться в сервис. Обратный вариант — когда где-то в вашем сервисе вызывает класс вида ClassWhichImplementsSpecialSomethig — начинается поиск по всем неймспейсам. Если коротко — “ежик внутрь” (когда вы инжектите зависимости) лучше чем “ежик наружу” (когда вы бегаете по всему проекту пытаясь найти подходящий класс). Если совсем коротко — Dependency Injection, о нем будет как-нибудь в другой раз.
 — Никаких скоупов внутри сервиса, опять же, никто вам в микросервис не будет давать объект объект со скоупом, скорее всего это будет просто мапа, которую вы обернете в объект для подходящей работы. Итого, если вы пишите: group.employees.unonboarded вы допускаете сразу две ошибки, а именно — теперь ваш код знает, что группа и работники как-то связаны и что для этого сервиса вам нужны не все работники, а какие-то особенные. Итого:

def initialize(group)
@group = group
@employees = group.employees.unonboarded
end

плохо,

def initialize(group, unonboarded_employees)
@group = group
@employees = unonboarded_employees
end

хорошо.

Не забывайте, что дополнительным плюсом этого подхода является упрощение тестирования, в виду того, что вам не надо стабать ваши скоупы.

2. Ваш сервис должен иметь только один метод call за небольшим исключением. Итак, почему call? Одно из самых красивых объяснений этому я читал такое — ваш сервис ведь следует single responsibility принципу? — да — значит должен быть только метод, который этот сервис вызывает. Почему не написать что-то более говорящее вместо call? — Скорее всего, потому что вам это не надо, если у вас есть класс Notifier, то называть методь notify не очень хорошо из за тавтологии (notifier.notify), в то время как call говорит о том, что ваш объект просто надо вызвать (ср: notifier.call).
Не стоит забывать, что еще одним объектом в мире руби, который отвечает на методы call является прок (ну или лямбды, но мы-то знаем). Поэтому, если вы все сделали правильно, вы сможете легко подменять ваши сервисы простыми лямбдами безо всяких double и прочего. Так же вы можете вызывать переданный метод вида method(:p).call(“111”).

3. Ваш сервис должен работать без RoR вообще (разве что с activesupport, потому что его легко заменить в нужных местах). Этот пункт скорее следствие из первого и частично покрыт вторым, но является хорошей метрикой того, что вы все делаете правильно.

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

5. Различайте сервисы и библиотеки. Тут грань не очень понятна, но, скорее всего, если ваш код не реализует бизнес-логику — это библиотека и ее место в lib.

6. Помогайте людям, которые возможно будут пользоваться вашим сервисом или будут его расширять, если разработчику надо переопределить какой-то метод в своей реализации — подскажите, что именно он должен возвращать, если в случае Java вам подскажут интерфейсы, то в динамических языках почти вся надежда на вас самих.

7. Нейминг важен всегда, особенно здесь. Так как сервис является частью бизнес-логики, называться он должен соответсвующе бизнес-задачи, которую он выполняет. Как всегда, без примера не обойтись: Registrations::PlanService — плохо, какой-то сервис который как-то работает с планами да еще и в неймспейсе регистраций, слишком абстрактно, никакой бизнес-задачи, да и зачем еще раз говорить, что это сервис? Registrations::PlanSelector — уже лучше, мы теперь уже оперируем сервисом, который выбирает подходящие планы для регистрации, но если посмотреть что делает этот класс, то еще более крутым вариантом будет являться Registrations::GroupPlansSelector (а то и SelectGroupPlan), потому как по факту, он находит план при регистрации для группы, но и это далеко не идеал. Выбор хорошего имени всегда является сложной, но важной задачей (кому-то ведь придется с этим работать), так что отдайте пару минут на придумывание хорошего имени сервису, возможно потом вам работать с гемом plan-service.

Ну и не стоит забывать про штуки типа SOLID, KISS и так далее.

P.S. И, как всегда, никакого состояния: данные пришли, сервис отработал, отдал результаты и умер.