Сервисные объекты Rails: подробное руководство

Опубликовано: 2022-03-11

Ruby on Rails поставляется со всем, что вам нужно для быстрого прототипирования вашего приложения, но когда ваша кодовая база начнет расти, вы столкнетесь со сценариями, в которых традиционная мантра «толстая модель, тонкий контроллер» не работает. Когда ваша бизнес-логика не может вписаться ни в модель, ни в контроллер, тогда на помощь приходят сервисные объекты, которые позволяют нам разделить каждое бизнес-действие на отдельный объект Ruby.

Пример цикла запроса с сервисными объектами Rails

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

Зачем мне сервисные объекты?

Попробуйте это: что вы делаете, когда вашему приложению нужно твитнуть текст из params[:message] ?

Если вы до сих пор использовали vanilla Rails, то вы, вероятно, сделали что-то вроде этого:

 class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end

Проблема здесь в том, что вы добавили в свой контроллер как минимум десять строк, но на самом деле они там неуместны. Кроме того, что, если вы хотите использовать те же функции в другом контроллере? Вы переводите это в заботу? Подождите, но этот код вообще не относится к контроллерам. Почему API Twitter не может предоставить мне один подготовленный объект для вызова?

В первый раз, когда я сделал это, я чувствовал, что сделал что-то грязное. Мои ранее изящные контроллеры Rails начали толстеть, и я не знал, что делать. В конце концов, я исправил свой контроллер с помощью служебного объекта.

Прежде чем вы начнете читать эту статью, давайте представим:

  • Это приложение обрабатывает учетную запись Twitter.
  • Rails Way означает «традиционный способ ведения дел Ruby on Rails», и этой книги не существует.
  • Я эксперт по Rails... мне каждый день говорят, что я им являюсь, но мне трудно в это поверить, так что давайте просто притворимся, что я действительно им являюсь.

Что такое сервисные объекты?

Служебные объекты — это обычные объекты Ruby (PORO), которые предназначены для выполнения одного единственного действия в логике вашего домена и делают это хорошо. Рассмотрим приведенный выше пример: в нашем методе уже есть логика для выполнения одной единственной операции — создания твита. Что, если бы эта логика была инкапсулирована в одном классе Ruby, который мы можем создать и вызвать метод? Что-то типа:

 tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])

Это почти все; наш сервисный объект TweetCreator , однажды созданный, может быть вызван из любого места, и он очень хорошо справляется с этой задачей.

Создание объекта службы

Сначала давайте создадим новый TweetCreator в новой папке с именем app/services :

 $ mkdir app/services && touch app/services/tweet_creator.rb

И давайте просто поместим всю нашу логику в новый класс Ruby:

 # app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

Затем вы можете вызвать TweetCreator.new(params[:message]).send_tweet в любом месте вашего приложения, и это сработает. Rails волшебным образом загрузит этот объект, потому что он автоматически загружает все в app/ . Убедитесь в этом, запустив:

 $ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services

Хотите узнать больше о том, как работает autoload ? Прочтите Руководство по автозагрузке и перезагрузке констант.

Добавление синтаксического сахара, чтобы сделать объекты сервиса Rails менее отстойными

Послушайте, в теории это прекрасно, но TweetCreator.new(params[:message]).send_tweet — это просто глоток. Это слишком многословно, с лишними словами… очень похоже на HTML ( черт возьми! ). А если серьезно, то почему люди используют HTML, когда есть HAML? Или даже Слим. Я предполагаю, что это другая статья в другой раз. Вернемся к поставленной задаче:

TweetCreator — это красивое короткое имя класса, но дополнительные хлопоты, связанные с созданием экземпляра объекта и вызовом метода, слишком длинны! Если бы только в Ruby был приоритет для вызова чего-либо и его немедленного выполнения с заданными параметрами… о, подождите, он есть! Это Proc#call .

Proccall вызывает блок, устанавливая параметры блока в значения в params, используя что-то близкое к семантике вызова метода. Он возвращает значение последнего выражения, вычисленного в блоке.

 aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]

Документация

Если это смущает вас, позвольте мне объяснить. proc может быть вызван call для выполнения самого себя с заданными параметрами. Это означает, что если бы TweetCreator был proc , мы могли бы вызвать его с помощью TweetCreator.call(message) , и результат был бы эквивалентен TweetCreator.new(params[:message]).call , который очень похож на наш старый громоздкий TweetCreator.new(params[:message]).send_tweet .

Итак, давайте сделаем так, чтобы наш сервисный объект вел себя как proc !

Во-первых, поскольку мы, вероятно, хотим повторно использовать это поведение во всех наших сервисных объектах, давайте позаимствуем Rails Way и создадим класс с именем ApplicationService :

 # app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end

Вы видели, что я там делал? Я добавил метод класса call , который создает новый экземпляр класса с аргументами или блоком, которые вы ему передаете, и вызывает call для этого экземпляра. Именно то, что мы хотели! Последнее, что нужно сделать, это переименовать метод нашего класса TweetCreator в call и наследовать класс от ApplicationService :

 # app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

И, наконец, давайте завершим это, вызвав наш сервисный объект в контроллере:

 class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end

Группировка похожих сервисных объектов для здравомыслия

В приведенном выше примере есть только один служебный объект, но в реальном мире все может быть сложнее. Например, что если бы у вас были сотни сервисов, и половина из них была связана с деловыми действиями, например, сервис « Follower » был подписан на другой аккаунт в Твиттере? Честно говоря, я бы сошел с ума, если бы папка содержала 200 уникально выглядящих файлов, так что хорошо, что есть еще один шаблон из Rails Way, который мы можем скопировать — я имею в виду, использовать в качестве вдохновения: пространство имен.

Давайте представим, что нам поручили создать служебный объект, который следует за другими профилями Twitter.

Давайте посмотрим на имя нашего предыдущего объекта службы: TweetCreator . Звучит как человек или, по крайней мере, роль в организации. Кто-то, кто создает твиты. Мне нравится называть свои сервисные объекты так, как если бы они были просто ролями в организации. Следуя этому соглашению, я назову свой новый объект: ProfileFollower .

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

Поскольку этот менеджер ничего не делает, кроме управления, давайте сделаем его модулем и вложим наши сервисные объекты в этот модуль. Наша структура папок теперь будет выглядеть так:

 services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb

И наши сервисные объекты:

 # services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
 # services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end

И наши вызовы теперь станут TwitterManager::TweetCreator.call(arg) и TwitterManager::ProfileManager.call(arg) .

Сервисные объекты для обработки операций базы данных

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

 module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... end end

Что я возвращаю из своего объекта службы?

Мы обсудили, как call наш сервисный объект, но что должен возвращать объект? Есть три подхода к этому:

  • Вернуть true или false
  • Вернуть значение
  • Вернуть перечисление

Вернуть true или false

Это просто: если действие работает как задумано, верните true ; в противном случае верните false :

 def call ... return true if client.update(@message) false end

Вернуть значение

Если ваш сервисный объект извлекает данные откуда-то, вы, вероятно, захотите вернуть это значение:

 def call ... return false unless exchange_rate exchange_rate end

Ответить перечислением

Если ваш сервисный объект немного сложнее и вы хотите обрабатывать разные сценарии, вы можете просто добавить перечисления для управления потоком ваших сервисов:

 class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end

А затем в своем приложении вы можете использовать:

 case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end

Разве я не должен помещать сервисные объекты в lib/services вместо app/services ?

Это субъективно. Мнения людей расходятся в том, где разместить объекты обслуживания. Кто-то помещает их в lib/services , а кто-то создает app/services . Я попадаю в последний лагерь. Руководство по началу работы с Rails описывает папку lib/ как место для размещения «расширенных модулей для вашего приложения».

По моему скромному мнению, «расширенные модули» означают модули, которые не инкапсулируют логику основной предметной области и обычно могут использоваться в разных проектах. Говоря мудрыми словами случайного ответа Stack Overflow, поместите туда код, который «потенциально может стать самостоятельным драгоценным камнем».

Являются ли сервисные объекты хорошей идеей?

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

Тот факт, что кто-то злоупотреблял сервисными объектами, не означает, что они изначально плохи. В моем стартапе Nazdeeq мы используем сервисные объекты, а также модели, отличные от ActiveRecord. Но для меня всегда была очевидна разница между тем, что куда идет: я храню все бизнес-операции в сервисных объектах, а ресурсы, которые на самом деле не нуждаются в сохранении, в моделях, отличных от ActiveRecord. В конце концов, вам решать, какая модель вам подходит.

Однако считаю ли я сервисные объекты вообще хорошей идеей? Абсолютно! Они обеспечивают аккуратную организацию моего кода, и что придает мне уверенности в использовании PORO, так это то, что Ruby любит объекты. Нет, серьезно, Руби любит объекты. Это безумие, полный бред, но мне это нравится! Дело в точке:

 > 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"

Видеть? 5 буквально объект.

Во многих языках числа и другие примитивные типы не являются объектами. Ruby следует влиянию языка Smalltalk, предоставляя методы и переменные экземпляра для всех его типов. Это упрощает использование Ruby, поскольку правила, применяемые к объектам, применяются ко всему Ruby. Ruby-lang.org

Когда я не должен использовать сервисный объект?

Это легко. У меня есть такие правила:

  1. Обрабатывает ли ваш код маршрутизацию, параметры или другие функции контроллера?
    Если это так, не используйте служебный объект — ваш код принадлежит контроллеру.
  2. Вы пытаетесь поделиться своим кодом в разных контроллерах?
    В этом случае не используйте служебный объект — используйте проблему.
  3. Ваш код похож на модель, которая не нуждается в постоянстве?
    Если это так, не используйте служебный объект. Вместо этого используйте модель, отличную от ActiveRecord.
  4. Является ли ваш код конкретным бизнес-действием? (например, «Вынесите мусор», «Создайте PDF-файл, используя этот текст» или «Рассчитайте таможенную пошлину, используя эти сложные правила»)
    В этом случае используйте сервисный объект. Этот код, вероятно, логически не вписывается ни в ваш контроллер, ни в вашу модель.

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

Правила написания хороших сервисных объектов

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

Правило 1: Только один публичный метод для каждого объекта службы

Сервисные объекты — это отдельные бизнес-операции. Вы можете изменить имя вашего публичного метода, если хотите. Я предпочитаю использовать call , но кодовая база Gitlab CE вызывает его execute , и другие люди могут использовать perform . Используй что хочешь — можешь называть это nermin , мне все равно. Просто не создавайте два общедоступных метода для одного объекта службы. Разбейте его на два объекта, если вам нужно.

Правило 2: именные сервисные объекты похожи на глупые роли в компании

Сервисные объекты — это отдельные бизнес -операции. Представьте, если бы вы наняли одного человека в компанию для выполнения одной этой работы, как бы вы его назвали? Если их работа заключается в создании твитов, назовите их TweetCreator . Если их работа заключается в чтении определенных твитов, назовите их TweetReader .

Правило 3: не создавайте общие объекты для выполнения нескольких действий

Сервисные объекты представляют собой отдельные бизнес -операции . Я разбил функциональность на две части: TweetReader и ProfileFollower . Чего я не сделал, так это создал один общий объект с именем TwitterHandler и выгрузил туда всю функциональность API. Пожалуйста, не делайте этого. Это противоречит мышлению о «деловых действиях» и делает сервисный объект похожим на Twitter Fairy. Если вы хотите разделить код между бизнес-объектами, просто создайте объект или модуль BaseTwitterManager и смешайте его с вашими сервисными объектами.

Правило 4. Обработка исключений внутри объекта службы

Уже в который раз: Сервисные объекты — это отдельные бизнес-операции. Я не могу сказать этого достаточно. Если у вас есть человек, который читает твиты, он либо даст вам твит, либо скажет: «Этого твита не существует». Точно так же не позволяйте вашему сервисному объекту паниковать, прыгать на стол вашего контроллера и приказывать ему остановить всю работу, потому что «Ошибка!» Просто верните false и дайте контроллеру двигаться дальше.

Кредиты и следующие шаги

Эта статья была бы невозможна без замечательного сообщества разработчиков Ruby в Toptal. Если я когда-нибудь столкнусь с проблемой, сообщество будет самой полезной группой талантливых инженеров, которых я когда-либо встречал.

Если вы используете служебные объекты, вам может быть интересно, как заставить определенные ответы во время тестирования. Я рекомендую прочитать эту статью о том, как создавать фиктивные объекты службы в Rspec, которые всегда будут возвращать желаемый результат, фактически не затрагивая объект службы!

Если вы хотите узнать больше о хитростях Ruby, я рекомендую книгу «Создание Ruby DSL: руководство по расширенному метапрограммированию», написанную товарищем по Toptaler Мате Солимози. Он объясняет, почему файл routes.rb не похож на Ruby, и помогает вам создать собственный DSL.