История вопроса
Пару лет назад у нас был проект под названием «Sparksfly». В двух словах его основная идея заключалась в следующем: пользователь коннектил разные социалки, они парсились, после чего результаты со всех аккаунтов отображались в одном фиде.
На тот момент перед нами стояли следующие задачи:
1) парсить френдов этого пользователя;
2) парсить его фид из соцсетей (добавим, что там были различные фото и ссылки на внешние ресурсы).
Кроме того, от нас требовалось все это сохранять в одном формате. В каждой социальной сети он абсолютно разный. Соответственно перед нами возникла задача нормализовать данные: привести различные форматы к единому образцу.
Теперь подробнее. Допустим, у одного пользователя фиды (поля) совпадают с нашей табличкой (специально составленной по такому случаю), а у другого — нет. Поэтому мы решили использовать паттерн «абстрактная фабрика». Он полностью реализует возможности наследования и переопределения. И работает это примерно следующим образом.
У нас имеется какой-то базовый worker. Он получает профайл, который содержит токен доступа к социальной сети. В опциях имеется endpoint (это то, что ему надо парсить: профиль, фид или друзья). В социальных сетях фиды делятся на две категории: собственная активность (feed) и home (все, что постят друзья). Воркер получает еще какие-то параметры, но их мы сейчас опустим и разберемся со спецификой его работы.
Она не представляет особой сложности. Воркер выбирает по эндпойнту граббер для конкретной социальной сети. Этот граббер полностью отвечает за взаимодействие с соответствующей апишкой — делает дополнительные запросы, управляет их параметрами, приводит данные к простому формату. Далее вся необходимая информация передается в Процессор.
Процессор — это модуль, который обрабатывает данные, полученные от граббера. Выглядит он примерно так:
То есть класс Processor::Facebook::Feed узнает от родителей, с какой именно социальной сетью и с каким эндпойнтом он работает. А от Processor::PostInterface он получает методы для построения поста. Такая структура очень проста и понятна. Но со временем нам пришлось вносить в нее правки, основанные на особенностях социальных сетей. Кроме того, появились дополнительные требования к обработке данных.
Нам потребовалось не только приводить их к единому формату, но и обрабатывать с помощью пользовательских фильтров. В общем, код понемногу расширялся и становился все менее логичным и читаемым. Однако мы не хотели нарушать принцип DRY (do not repeat yourself), и нам удалось достичь поставленной цели. Код не повторялся. Вместо этого появились другие проблемы. Огромное количество функций, относящихся к разным задачам (сохранение поста, нормализация данных, сохранение фотографий и пр.) сделали код крайне сложным для восприятия. Попросту говоря, у нас получилась совершенно неуправляемая и маловразумительная каша.
Проблема заключалась в том, что мы нарушили один очень важный принцип – single responsibility. Суть в следующем: при проектировании класса необходимость его изменения возможна лишь в одном случае — когда он перестает работать.
Озвученную проблему попытался решить gem interactor. Однако справился с поставленной задачей не наилучшим образом.
Решение или «решение»?
Что делает gem? Он позволяет описывать сервисы, внутри которых мы решаем единственную проблему. С остальными сложностями гем справляется самостоятельно. То есть он решает задачу связки сервисов и взаимной передачи данных между ними. Мы же об этом вообще не думаем и постепенно (пошагово) преодолеваем проблемы в работе каждого сервиса.
Посмотрим, как это выглядит. У нас есть тестовый пример. Мы получаем свой фид из Twitter и выводим некоторые из его полей: id, body, photo, links. Interactor позволяет описать задачу в виде набора простых шагов:
Теперь давайте посмотрим TestController. Вот у нас есть какой-то token, secret. Для простоты мы его захардкодили. Далее на сцене появляется Interactor, то есть ReceiveTwitterFeed. Мы говорим, что хотим его вызвать, а затем передать auth-token и auth-secret. Если у нас все хорошо (success), то завершающим аккордом станет присвоение какой-либо переменной из data в instance. В противном случае: fail – все сломалось. Мы оставляем очень тонкий и простой контроллер.
При подключении Interactor::Organizer вызывается метод organize. Гем предоставляет два вида подключаемых модулей:
- органайзер;
- сам интерактор.
Органайзер служит для организации взаимодействия интеракторов. Когда мы говорим, что нужно получить twitter feed, мы имеем в виду “сграбить” twitter feed из соцсети. Или запроцессить twitter feed. Это не сложно. Органайзер отвечает за то, чтобы auth-token и auth-secret были доступны везде, а именно — у каждого из двух интеракторов.
Давайте посмотрим GrabTwitterFeed:
Здесь уже мы видим просто интерактор. Чтобы воспользоваться его возможностями, необходимо определить метод call. Он не содержит входных параметров, которые становятся доступными через объект context. Приступаем к выполнению операции. Объявляем, что у нас есть api_client и нам необходимо получить результат метода user_timeline. API Client – это просто twitter gem. Затем просим user_timeline: поместить его в контекст и в переменную twitter_feed. Если же что-то пошло не так, цепочка разорвется. Она упадет с fail.
Тут мы получили сырые данные twitter feed.
Следующий на очереди — процесс обработки ProcessTwitterFeed.
Как видите, этот класс очень прост. Он говорит: «Собери мне новый массив данных, используя ProcessTweet для обработки каждой строки сырых данных и положи результат в контекст, в переменную data».
ProcessTweet описывает, каким образом мы должны обрабатывать один твит. Для этого нам необходимо нормализовать данные, сохранить фото, сохранить его shared ссылки и, наконец, сохранить сам tweet.
Каждый шаг очен прост, и даже если реальной логики будет чуть больше, это не особо усложнит работу сервисов.
Кроме того, gem interactor предоставляет небольшой, но полезный DSL. Он позволяет добавлять действия, которые выполняются перед или после запуска основной логики. В нашем случае речь идет о расстановке вызовов функций для профилирования — мы искали свое “бутылочное горлышко” и в результате оставили их на своих местах. Интерактор позволяет сделать это проще и быстрее.
Выводы
В целом, gem interactor пытается решить очень важные проблемы, но нам совсем не нравится, как он это делает.
Почему? Во-первых, сервисы, написанные с помощью этого гема, обладают слишком высокой связанностью (high coupling). Они имеют один общий контекст и осуществляют передачу данных только через него. То есть (подумайте) в каждом сервисе доступны абсолютно все данные контекста. Более того. Контекст знает о том, что где-то эти данные будут добавлены, что автоматически означает его осведомленность о части имплементации другого сервиса.
Самое же интересное заключается в том, что разработчики, написавшие Interactor, взяли за основу другой гем — Simple Service, который, как раз, решает эту проблему. Мы не знаем, почему авторы проекта не использовали готовую идею. Было бы совсем неплохо описать внутри сервиса, какие поля он может взять из контекста, и какие может в него положить. Более того, хорошо бы не только иметь возможность эти поля описывать, но и накладывать на них контракты. Это повысит надежность и читаемость кода. Открываешь код класса и сразу описание, что In – такие-то поля, Out — такие-то. Красота!
Второй момент касается организации через классы. Называть класс по проблеме, которую он решает не очень удобно. Опять-таки, это снижает читаемость кода. Вот если бы название представляло собой какой-то символ, а символ преобразовывался в классы, это было бы, куда как удобнее и логичнее.
В этом же контексте можно рассмотреть еще одну проблему, связанную с названием класса. Является ли он органайзером или интерактором, исходя из наименования совершенно непонятно. Тем более, что к имени интерактора добавятся неймспейсы. Как известно, складывать их в одну папку абсолютно неудобно.
И еще. Органайзеры не могут задавать управляющую логику. Элементу цепочки приходится внутри себя определять, должен ли он вообще вызываться.
Если что-то пошло не так, Interactor включает собственный механизм обработки данных. Вызывается метод fail или описывается метод rollback, которые отрабатывают в случае неудачи. Мы считаем, что все это менее удобно, чем работа через собственные исключения. Места в коде, где может произойти что-то исключительное, видны очень хорошо, что позволяет реагировать на непредвиденные обстоятельства своевременно и эффективно. При работе с гемом Interactor такая возможность теряется.