Паттерн Service Object в Rails
Введение
Фреймворк для разработки Web-приложений Ruby On Rails построен на архитектуре MVC, которая хорошо показывает себя в небольших проектах.
Однако, при увеличении сложности и размера проекта, возникает ряд проблем, которые мешают рефакторить, поддерживать и отлаживать исходный код.
Проблема 1. Контроллеры и модели разрастаются. Количество строк кода на один файл может вырасти до нескольких тысяч.
Проблема 2. Непонятно, где искать код, в модели или в контроллере.
Проблема 3. Невозможно или затруднительно протестировать отдельные участки кода контроллера, как в ручную через консоль, так и через автоматические тесты.
Решение состоит в том, чтобы использовать паттерн “Service Object”. В данной статье будет использоваться перевод “Сервис”.
Описание паттерна
Паттерн “Сервис” подразумевает создание специальных классов, отвечающих за логику в проекте. Вместо того, чтобы реализовывать сложную логику внутри контроллера или модели, вынесите код в сервис.
Пример
Рассмотрим контроллер, в котором есть метод index
для получения списка пользователей:
Если в таком контроллере будет реализовано много методов API, каждый из которых имеет сложную логику, то класс станет большим, нечитаемым и трудно тестируемым.
Можно разбить index
на несколько методов, но это не решит всех проблем. Лучшим решением будет вынести логику в специальный класс, оставив в контроллере работу с параметрами и создание ответа.
Такой подход решает срезу несколько проблем:
- Код контроллеров и моделей не будет разрастаться при усложнении логики. Если сервис сам становится перегруженным, его можно разбить на несколько других сервисов.
- Отсутствует проблема выбора, в какое место поместить код, в контроллер или модель. Ответ один — в сервис. Теперь каждая сущность ответственна за свою собственную задачу. Контроллер решает, что делать. Сервис решает, как делать. Модель предоставляет удобный интерфейс для общения с базой данных.
- Исходный код сервиса легко использовать повторно, покрывать автоматическими тестами и тестировать вручную через консоль.
Таким же образом внутри сервисов можно скрыть обращение к внешним API и SDK, чтобы их сложности не мешали концентрироваться на реализации бизнес-логики.
Основные принципы
Сервис должен быть объектом
Сервис должен быть экземпляром класса. Это позволит передать в конструктор входные параметры и сохранить их в свойства объекта, чтобы в дальнейшем не передавать их из метода в метод:
Данный принцип не означает, что все параметры нужно передавать в конструктор. Передавайте в конструктор информацию о контексте исполнения, которая вряд ли будет изменяться с течением времени в рамках выполнения одного запроса, например — текущий пользователь:
Сервис должен выполнять только одно действие
В сервисе должен быть только один публичный метод для выполнения строго определенного действия.
Если сервис выполняет несколько действий, то значит он:
- Подвержен разрастанию, хотя призван решить данную проблему
- Имеет несколько причин для изменения, из-за чего могут начаться конфликты при слиянии исходного кода, если над проектом работает несколько человек
- И другие проблемы, которые могут быть вызваны несоблюдением принципа единственной ответственности (SOLID)
Именование
Публичный метод сервиса можно именовать по-разному, и это дело вкуса. Главное, чтобы именование было едино по всему проекту. Встречаются различные названия: “execute”, “perform”, “call”. Я придерживаюсь названия “call” по простой причине: при отличной смысловой нагрузке оно самое короткое.
Сервисы так же можно именовать по-разному. Один из стилей подразумевает объединение названия действия с суффиксом “Service”, например: “UserCreationService”, “NotificationSendingService”. Другой стиль говорит именовать сервисы существительными: “UserCreator”, “NotificationSender”. Выберите один из них и придерживайтесь его в своём проекте.
Возвращаемое значение
Изучая в сети примеры использования сервисных объектов я увидел несколько вариантов, в каком виде можно возвращать данные из сервиса:
- Логическое
true
илиfalse;
- Код результата;
false
в случае неудачи и данные в случае успеха;- Объект ActiveRecord;
- Объект результата, включающий в себя всё вышеперечисленное.
Возвращение логического результата делает невозможным получение каких-либо других данных из сервиса. При таком подходе вы обязаны иметь ссылки на все необходимые данные еще до вызова сервиса. Это особенно неудобно при создании новых записей в базе данных — приходится заранее создавать экземпляр модели и передавать его в сервис.
При возвращении кода результата можно легко обрабатывать различные ошибочные ситуации, однако имеется проблема, как и в первом случае — невозможность получить другие данные из сервиса.
Возвращение false
в случае ошибки и данных в случае успеха делает затруднительным понять, в связи с чем произошла ошибка.
Если всегда возвращать ActiveRecord, то невозможно написать сервис без участия ActiveRecord. Придется смешивать концепции и в каких-то случаях возвращать не ActiveRecord.
Для меня лучшим способом является возвращение специального объекта результата, который хранит в себе логический индикатор успеха, код ошибки, если она произошла, и данные. В таком случае исходного кода получается немного больше, чем во всех остальных вариантах, однако мы получаем большой контроль, а так же все сервисы работают единообразно.
Совет: в качестве кодов ошибок используйте строки или символы, чтобы иметь возможность автоматизировать формирование сообщений об ошибках, если их значения совпадают с именами ключей в файлах локализации.
Лучшие практики
Что не нужно выполнять в сервисе
Не разбирайте параметры запроса в сервисе. Например, преобразовывайте строки в объекты дат на уровне контроллера и после этого передайте в сервис. Разбор параметров в сервисе будет мешать чтению кода и концентрации на алгоритме.
Не выносите повторяющийся код контролеров в сервис, если он не относится к бизнес-логике. Например, если у вас имеется сложная повторяющаяся логика подготовки параметров или преобразования данных в удобный для ответа вид, то выносите их в Concern, а не в сервис. Если у вас в проекте появятся сервисы, ответственные не за бизнес-логику, то это усложнит навигацию по исходному коду.
Не работайте с интернационализацией в сервисе. Формируйте сообщения для пользователей на уровне контроллера. Иначе может случиться следующее:
- Сервисы станут сильно привязаны к конкретным контроллерам. Их будет трудно использовать в других контроллерах, а еще сложнее — в других сервисах. Будет затруднительно формировать в разных местах вызова отличающиеся сообщения.
- Для покрытия сервиса тестами придется производить сравнение строк сообщений. Это обычная практика для тестирования модулей отображения, но не для тестирования бизнес-логики.
Не выносите код коллбэков ActiveRecord в сервис, если он не относится к бизнес-логике. Например:
- Удаление лишних пробелов в начале и в конце строк.
- Заполнение вспомогательных полей в таблице, которые нужны для оптимизации запросов.
Пространства имен
В больших проектах стоит разбивать сервисы по пространствам имен, не то будет сложно ориентироваться в одной большой папке с сервисами. Я предпочитаю создавать отдельные папки для каждой сущности, с которой производится работа. Для работы с пользователями — папка “users”. Для работы с уведомлениями — папка “notifications”. Если ваши модели, в свою очередь, тоже разбиты на модули, то сделайте аналогично и с сервисами.
Обработка исключений
Обрабатывайте исключения внутри сервиса таким образом, чтобы он всегда возвращал корректный стандартизированный результат и не выбрасывал исключения. Это позволит писать единообразный код при обращении с любыми сервисами.
Реализация
Для использования данного подхода вы можете подключить этот Gem, который соблюдает все вышеперечисленные принципы: