Как стать автором
Обновить
СберМаркет
Кодим будущее доставки товаров

Почему в вашем проекте не нужны Service Objects? Наводим порядок в коде

Время на прочтение7 мин
Количество просмотров2.5K

Привет! Я Олег Федоткин, Head of PaaS в СберМаркете. Хочу поговорить про Service Objects: что с ними не так, почему от них стоит избавляться и как это сделать. 

В этой статье я поделюсь своей болью о Service Objects, разберу их ключевые проблемы на примере из опенсорса и предложу собственное решение с чек-листом по его внедрению. Эти архитектурные решения нашли своё применение и в СберМаркете.

Этот текст написан нашими прекрасными редакторами на основе моего выступления на Ruby on Rails Meetup. Если вам больше нравится смотреть видео, то оно здесь.

Как всё начиналось и причём тут блоги

В далёком 2011 году я начал писать на Ruby и прочитал книгу, которую, уверен, многие из вас читали — «Agile Web Development with Rails». Там я вместе с автором прошёл путь по созданию своего блога на RoR. Под капотом этого блога был View-Model-Controller. Со временем я утащил модель MVC в свои первые рабочие проекты. 

Когда на работе продакт хотел новые фичи, я как инженер вставал перед дилеммой, куда их класть, потому что MVC мне про это ничего не объяснил. Наученный коллегами и Stack Overflow, я клал весь этот код в Контроллеры.

Так продолжалось какое-то время, а потом я начал осознавать, что делаю что-то не так.  Контроллеры стали просто неподдерживаемыми. Тем временем продакт продолжал хотеть новые фичи. Я подумал: «Подтекает логика из контроллеров — заклею её жирными моделями».

Какое-то время и это работало, но проекты росли, и в итоге я пришёл к тому, что в моделе было 2-3 тысячи строк и из неё тоже нужно было что-то выносить. Тогда всё, что не влезло в MVC, я сложил в общую кучу и назвал её «Сервисом» или Service Object. 

Была изобретена прекрасная эвристика. Если это не модель, не представление и не контроллер, это — сервис.

Вроде просто и приятно использовать, Но к чему это приводит?

Что не так с Service Objects?

Ну сложили и сложили всё в одну кучу, а что плохого-то в этом? Покажу на примере проекта Discourse с Github. У него в районе 30 000 звёзд, его часто используют как сервис для комментариев и у него есть директория services, в которую мы сейчас погрузимся.

https://github.com/discourse/discourse/tree/main/app/services
https://github.com/discourse/discourse/tree/main/app/services

Проблема #1. Serviсes — это свалка файлов

Вот что нас встречает, когда мы заходим в Services у Discourse:

  • Query Objects

  • Validator

  • Interactor

  • Specification

  • Logger

  • и многое другое

Всё это лежит в одной директории и как-то (абсолютно непрозрачно) между собой взаимодействует.

Проблема #2. Чтобы воспользоваться сервисом, нужно прочитать весь его код

Провалимся в первый же файл — anonymous_shadow_creator. У него есть набор методов: initiallize, get_master и другие. Но не очень понятно, как его вызвать и им  воспользоваться. Допустим, здесь это get_master или get. Но рядом в директории лежит файл destroy_task и в нём уже совсем другие методы. Проверим три лежащих рядом файла — везде разный вызов.

Проблема #3. Чтобы понять ответ сервиса, надо прочитать весь его код

Вот nil — это успешное завершение или нет? А пустой массив? А false? И как понять, что что-то пошло не так? Мы никогда не поймём, пока не заглянем внутрь и не прочитаем код от начала до конца.

Проблема #4. Как Service Object чинить — непонятно

Эти три проблемы лежат на поверхности, и ещё давным-давно я начал искать ответы на них в книгах по разработке ПО. Но вот беда, Service Object не описан ни в одной книге. 

Cловосочетание Service Object фигурирует в блоге Мартина Флауэра, но относится совсем не к тому, о чём мы говорим. Можно считать, что Service Object — это паттерн, который выдумали, но в книги добавить забыли (статьи на Медиуме, конечно же, не считаются книгами). 

А раз нет описания в профильной литературе, то непонятно, как их фиксить. Значит пора от них избавляться!

Interactor — вместо тысячи Service Objects

Есть прекрасная замена Service Objects, называется Interactor.

Interactor описывает кодом конкретный сценарий использования (use case). После он собирает данные в структуру и возвращает в UI.

Use Case — это то, что вам приносят продакты. Например: пользователь зашёл на страницу, щёлкнул на кнопку и произошла транзакция. Здесь главное, это эти кейсы вам приносит бизнес.

Решение #1. Вместо свалки файлов приходим к screaming architecture

Первый вопрос, с которым мы сталкиваемся: «Что выносить в интеракторы»? Ответ: «Выносим в интерактор бизнес-логику». Проверяем его с помощью инварианта: если оба условия верны, то с вашим интераткором всё хорошо. 

  1. Интерактор содержит бизнес-логику

  2. Бизнес-логика содержится только в интеракторе

Если этот инвариант соблюдается, то вы постепенно приходите к Screaming Architecture, когда структура проекта сама говорит о его содержании, ещё до момента, когда мы открыли файлы с кодом.

Например, благодаря screaming architecture этого проекта, понимаем, что это банковское приложение
Например, благодаря screaming architecture этого проекта, понимаем, что это банковское приложение

Решение #2. Пишем интеракторы так, чтобы можно было не читать весь код

При написании интерактора возникает два ключевых вопроса:

  1. Как Service Objects вызывать?

  2. Какие параметры в него передавать? 

С вызовом всё просто и сложно одновременно. Вам придётся прийти к единой точке вызова. Я на практике видел варианты call, perform, process. Все варианты хороши, ни один не является превалирующим. Главное договориться, что у вас теперь вот так. 

Вторая часть — это контракт. Контракт в интеракторе проверяет входные значения определённых типов на соответствие некоторому шаблону (например, user_id, age, reason). Плюс есть правила (например, age>18). Далее следует описательная часть. Если мы опишем контрактом поведение нашего интерактора, то уже на стадии чтения контракта, поймём, что от нас ожидают и как его вызывать.

Если интерактор — это чёрный ящик, то контракт — это наклейка на этом чёрном ящике. Когда она правильно написана и соответствует действительности, то в чёрный ящик можно и не заглядывать. У вас уже есть всё, что нужно, чтобы этим чёрным ящиком воспользоваться.

Итого, плюсы контрактов:

  • Понятный интерфейс вызова

  • Очищает код от проверок

  • Строгая типизация исключает часть ошибок

  • Рефакторинг станет проще (можно менятть код, не меняя контракт и никто даже не заметит)

  • Модели станут чище

Решение #3. Делаем ответы прозрачными

Здесь нам помогут монады из прекрасной библиотеки DRY-RB.

Монада — это объект, который может принимать два противоположных значения (success и failure). Сам ответ детерминирован, а содержание монады вы можете менять.

Это значит:

  • Ответ интерактора всегда детерминирован: мы всегда знаем, что вернулось (success или failure).

  • Ответ интерактора всегда легко проверить, потому что DRY-RD предоставляет методы  sucess и failure. Больше не нужно гадать, что значит nil или пустое множество.

  • Как итог код становится чище.

Итак, мы решили три главные проблемы, которые возникли у нас с Service Objects, но кроме них у нас появилось ещё и две бонусные плюшки.

Плюшка #1. DI

DI (Dependency Injection) — это внедрение зависимостей. Например, нужно доставить какую-то нотификацию к Firebase или SMS. Мы можем использовать эти зависимости прямо из кода, но тогда их сложнее менять и тестировать. А можем передать их на этапе создания объекта через конструктор. 

Руками это делать долго. Но DRY-RB помогает писать такие опшены, которые поддерживают дефолтовые значения. Кто-то может подумать, что это всё ради изменяемости кода, но, на самом деле, это очень помогает с тестами. А тесты  в свою очередь приводит к более читаемому и поддерживаемому коду.

Плюшка #2. yield

Ещё одна приятная вещь из коробки DRY-RB — это yield. Оно помогает убрать одну боль, которая есть в монадах. 

В стандартном виде вы получаете монаду, проверяете, это success или failure. Если success, вы идёте дальше, если failure, вы выбрасываете return. 

Yield помогает избавиться от многословности. Если success, его значение сразу передаётся в input, если failure, значение самостоятельно делает return и вы не пишете лишних 3-4 строчки кода.

Как перейти от Serice Object к Interactor на практике

Итак, вернёмся к нашему примеру из опенсорса.  В директиве Services у Discourse есть файл user_silencer. Он мьютит определённых пользователей и прячет их посты (на самом деле смысл этого функционала вы поймёте только прочитав весь код). 

До этого момента не понятно:

  • Как его вызвать?

  • Возвращает true или false

  • Что такое by_user (сначала я подумал, что это булевое значение, но нет)

  • Что передавать в opts?

  • Не понятно, насколько все параметры обязательные

  • Какие сценарии завершения?

  • Что нужно для тестов?

Я переписал этот код c учётом всего, что рассказал раньше. Код стал гораздо более читаемым и оптимизированным для тестов.

# frozen_string_literal: true

class SilenceUser < Interactor

  option :posts, reader: :private, default: -> { Post } 

  option :formatter, reader: :private,default: -> { StaffMessageFormat}
  option :logger, reader: :private,default: -> { StaffActionLogger}

  option :event_storage, reader: :private,default: -> { DiscoureEvent}
  option :message_storage, reader: :private,default: -> { SystemMessage}

  contract do
    required(:user).filled(Dry.Types.Instance(User))
    optional(:by_user).filled(Dry.Types.Instance(User))

    required(:opts).hash do
      optional(:keep_posts).filled(:bool)
      optional(:silemced_till).filled(:string)
      optional(:reason).filled(:string)
      optional(:message).filled(:string)
      optional(:message_body).filled(:string)
      optional(:post_id).filled(:integer)
     end
   end

   def call(input)
     hide_posts(input[:user]) unless input[:opts][:keep_posts]
     return Success(:posts_hidden) if input[:user].silenced?

     yield silence_user(user, input:[opts][silenced_till])
     event = yield trigger_event(user, input[:opts])
     yield store_message(user, event)

     Success(:silenced)
   end
  

Приятное следствие: поэтапное исследование кода

Благодаря такой организации кода мы можем углубляться в него постепенно. 

  • Этап 0: смотрим на директории на уровне screaming architecture

  • Этап 1: проваливаемся в файл, смотрим завсимости

  • Этап 2: читаем контракт

  • Этап 3: читаем метод #call

  • Этап 4: читаем приватные методы

Если вам нужно использовать интерактор просто как чёрную коробку, вам чаще всего хватит прочитать контракт. А это значительно экономит трудочасы команды.

Чек-лист хорошего интерактора

Подводим итог. Что такое хороший интерактор?

  • Содержит только бизнес-логику

  • Имеет унифицированный интерфейс для вызова

  • Входные параметры описаны контрактом

  • Возвращает детерминированный результат

А ещё:

  • Избегайте хранение состояний

  • Не используйте монады, если приватный метод возвращает только Sucess

  • Тщательно обдумайте зону ответственности интерактора и не пишите God Object-ы. 

  • Минимизируйте код внутри #call

  • Не называйте интеракторы с окончанием на -er и -or. Не «UserSilencer», а «SilenceUser». Так понятнее :)

Надеюсь эта статья была полезна! Если тебя интересуют управленческие практики в it, заглядывай на мой Telegram-канал «Инженер и менеджер». Там я часто советую книги об управлении и стратегии и делится инсайтами о работе в роли EM. 

Tech-команда СберМаркета завела соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и YouTube.

Теги:
Хабы:
0
Комментарии4

Публикации

Информация

Сайт
sbermarket.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
SberMarket Tech