Objetos de servicio de Rails: una guía completa

Publicado: 2022-03-11

Ruby on Rails viene con todo lo que necesita para crear prototipos de su aplicación rápidamente, pero cuando su base de código comience a crecer, se encontrará con escenarios en los que se rompe el mantra convencional Fat Model, Skinny Controller. Cuando la lógica de su negocio no puede encajar en un modelo o un controlador, ahí es cuando entran los objetos de servicio y nos permiten separar cada acción comercial en su propio objeto Ruby.

Ejemplo de ciclo de solicitud con objetos de servicio de Rails

En este artículo, explicaré cuándo se requiere un objeto de servicio; cómo hacer para escribir objetos de servicio limpios y agruparlos para la cordura del colaborador; las reglas estrictas que impongo a mis objetos de servicio para vincularlos directamente a mi lógica comercial; y cómo no convertir sus objetos de servicio en un basurero para todo el código con el que no sabe qué hacer.

¿Por qué necesito objetos de servicio?

Prueba esto: ¿Qué haces cuando tu aplicación necesita twittear el texto de params[:message] ?

Si has estado usando Rails Vanilla hasta ahora, probablemente hayas hecho algo como esto:

 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

El problema aquí es que ha agregado al menos diez líneas a su controlador, pero en realidad no pertenecen allí. Además, ¿qué pasaría si quisiera usar la misma funcionalidad en otro controlador? ¿Transfiere esto a una preocupación? Espera, pero este código realmente no pertenece a los controladores. ¿Por qué la API de Twitter no puede venir con un solo objeto preparado para llamar?

La primera vez que hice esto, sentí que había hecho algo sucio. Mis controladores Rails, anteriormente maravillosamente delgados, habían comenzado a engordar y no sabía qué hacer. Eventualmente, arreglé mi controlador con un objeto de servicio.

Antes de comenzar a leer este artículo, imaginemos:

  • Esta aplicación maneja una cuenta de Twitter.
  • The Rails Way significa “la forma convencional de Ruby on Rails de hacer las cosas” y el libro no existe.
  • Soy un experto en Rails... y me dicen todos los días que lo soy, pero me cuesta creerlo, así que finjamos que realmente lo soy.

¿Qué son los objetos de servicio?

Los objetos de servicio son objetos Plain Old Ruby (PORO) que están diseñados para ejecutar una sola acción en la lógica de su dominio y hacerlo bien. Considere el ejemplo anterior: nuestro método ya tiene la lógica para hacer una sola cosa, y eso es crear un tweet. ¿Qué pasaría si esta lógica se encapsulara dentro de una sola clase de Ruby a la que podemos instanciar y llamar a un método? Algo como:

 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])

Esto es más o menos todo; nuestro objeto de servicio TweetCreator , una vez creado, se puede llamar desde cualquier lugar y lo haría muy bien.

Creación de un objeto de servicio

Primero, creemos un nuevo TweetCreator en una nueva carpeta llamada app/services :

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

Y simplemente descarguemos toda nuestra lógica dentro de una nueva clase de 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

Luego puede llamar a TweetCreator.new(params[:message]).send_tweet en cualquier parte de su aplicación y funcionará. Rails cargará este objeto mágicamente porque carga automáticamente todo en app/ . Verifique esto ejecutando:

 $ 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

¿Quieres saber más sobre cómo funciona la autoload ? Lea la Guía de constantes de recarga y carga automática.

Adición de azúcar sintáctico para hacer que los objetos de servicio de Rails absorban menos

Mira, esto se siente genial en teoría, pero TweetCreator.new(params[:message]).send_tweet es solo un trabalenguas. Es demasiado detallado con palabras redundantes... muy parecido a HTML (¡ba-dum tiss! ). Sin embargo, con toda seriedad, ¿por qué la gente usa HTML cuando HAML está presente? O incluso Delgado. Supongo que ese es otro artículo para otro momento. Volviendo a la tarea en cuestión:

TweetCreator es un buen nombre de clase corto, ¡pero la complejidad adicional de instanciar el objeto y llamar al método es demasiado larga! Si tan solo hubiera precedencia en Ruby para llamar a algo y hacer que se ejecute de inmediato con los parámetros dados... ¡oh, espera, lo hay! Es Proc#call .

Proccall invoca el bloque, configurando los parámetros del bloque a los valores en params usando algo parecido a la semántica de llamadas a métodos. Devuelve el valor de la última expresión evaluada en el bloque.

 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]

Documentación

Si esto te confunde, déjame explicarte. Se puede proc call un proceso para que se ejecute solo con los parámetros dados. Lo que significa que si TweetCreator fuera un proc , podríamos llamarlo con TweetCreator.call(message) y el resultado sería equivalente a TweetCreator.new(params[:message]).call , que se parece bastante a nuestro viejo y poco manejable TweetCreator.new(params[:message]).send_tweet .

¡Así que hagamos que nuestro objeto de servicio se comporte más como un proc !

Primero, debido a que probablemente queramos reutilizar este comportamiento en todos nuestros objetos de servicio, tomemos prestado de Rails Way y creemos una clase llamada ApplicationService :

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

¿Viste lo que hice allí? Agregué un método de clase llamado call que crea una nueva instancia de la clase con los argumentos o el bloque que le pasas, y call a la instancia. ¡Exactamente lo que queríamos! Lo último que debe hacer es cambiar el nombre del método de nuestra clase TweetCreator para call y hacer que la clase herede de 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

Y finalmente, terminemos con esto llamando a nuestro objeto de servicio en el controlador:

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

Agrupación de objetos de servicio similares para cordura

El ejemplo anterior tiene solo un objeto de servicio, pero en el mundo real, las cosas pueden complicarse más. Por ejemplo, ¿qué pasaría si tuviera cientos de servicios y la mitad de ellos fueran acciones comerciales relacionadas, por ejemplo, tener un servicio de Follower que sigue a otra cuenta de Twitter? Honestamente, me volvería loco si una carpeta contuviera 200 archivos de apariencia única, por lo que es bueno que haya otro patrón de Rails Way que podemos copiar, quiero decir, usarlo como inspiración: espacios de nombres.

Supongamos que se nos ha encomendado la tarea de crear un objeto de servicio que siga a otros perfiles de Twitter.

Veamos el nombre de nuestro objeto de servicio anterior: TweetCreator . Suena como una persona, o al menos, un rol en una organización. Alguien que crea Tweets. Me gusta nombrar mis objetos de servicio como si fueran solo eso: roles en una organización. Siguiendo esta convención, llamaré a mi nuevo objeto: ProfileFollower .

Ahora, dado que soy el señor supremo de esta aplicación, crearé un puesto gerencial en mi jerarquía de servicios y delegaré la responsabilidad de ambos servicios en ese puesto. Llamaré a esta nueva posición gerencial TwitterManager .

Dado que este administrador no hace nada más que administrar, convirtámoslo en un módulo y anidemos nuestros objetos de servicio en este módulo. Nuestra estructura de carpetas ahora se verá así:

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

Y nuestros objetos de servicio:

 # 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

Y nuestras llamadas ahora se convertirán en TwitterManager::TweetCreator.call(arg) y TwitterManager::ProfileManager.call(arg) .

Objetos de servicio para manejar operaciones de base de datos

El ejemplo anterior hizo llamadas a la API, pero los objetos de servicio también se pueden usar cuando todas las llamadas son a su base de datos en lugar de a una API. Esto es especialmente útil si algunas acciones comerciales requieren múltiples actualizaciones de bases de datos envueltas en una transacción. Por ejemplo, este código de muestra usaría servicios para registrar un cambio de moneda.

 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

¿Qué devuelvo de mi objeto de servicio?

Hemos discutido cómo call a nuestro objeto de servicio, pero ¿qué debería devolver el objeto? Hay tres formas de abordar esto:

  • Devolver true o false
  • Devolver un valor
  • Devolver una enumeración

Devolver true o false

Este es simple: si una acción funciona según lo previsto, devuelve true ; de lo contrario, devuelve false :

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

Devolver un valor

Si su objeto de servicio obtiene datos de algún lugar, probablemente desee devolver ese valor:

 def call ... return false unless exchange_rate exchange_rate end

Responder con un Enum

Si su objeto de servicio es un poco más complejo y desea manejar diferentes escenarios, simplemente puede agregar enumeraciones para controlar el flujo de sus servicios:

 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

Y luego en su aplicación, puede usar:

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

¿No debería poner objetos de servicio en lib/services en lugar de app/services ?

Esto es subjetivo. Las opiniones de las personas difieren sobre dónde colocar sus objetos de servicio. Algunas personas los ponen en lib/services , mientras que otros crean app/services . Caigo en el último campo. La Guía de inicio rápido de Rails describe la carpeta lib/ como el lugar para colocar "módulos extendidos para su aplicación".

En mi humilde opinión, los "módulos extendidos" significan módulos que no encapsulan la lógica del dominio principal y, por lo general, se pueden usar en todos los proyectos. En las sabias palabras de una respuesta aleatoria de desbordamiento de pila, coloque un código allí que "potencialmente pueda convertirse en su propia joya".

¿Son los objetos de servicio una buena idea?

Depende de su caso de uso. Mire, el hecho de que esté leyendo este artículo en este momento sugiere que está tratando de escribir código que no pertenece exactamente a un modelo o controlador. Recientemente leí este artículo sobre cómo los objetos de servicio son un antipatrón. El autor tiene sus opiniones, pero discrepo respetuosamente.

El hecho de que otra persona haya abusado de los objetos de servicio no significa que sean intrínsecamente malos. En mi startup, Nazdeeq, usamos objetos de servicio, así como modelos que no son ActiveRecord. Pero la diferencia entre lo que va adónde siempre ha sido evidente para mí: mantengo todas las acciones comerciales en objetos de servicio mientras mantengo recursos que realmente no necesitan persistencia en modelos que no son ActiveRecord. Al final del día, depende de usted decidir qué patrón es bueno para usted.

Sin embargo, ¿creo que los objetos de servicio en general son una buena idea? ¡Absolutamente! Mantienen mi código perfectamente organizado, y lo que me da confianza en mi uso de PORO es que a Ruby le encantan los objetos. No, en serio, a Ruby le encantan los objetos. Es una locura, totalmente loco, ¡pero me encanta! Caso en punto:

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

¿Ver? 5 es literalmente un objeto.

En muchos idiomas, los números y otros tipos primitivos no son objetos. Ruby sigue la influencia del lenguaje Smalltalk dando métodos y variables de instancia a todos sus tipos. Esto facilita el uso de Ruby, ya que las reglas que se aplican a los objetos se aplican a todo Ruby. Ruby-lang.org

¿Cuándo no debo usar un objeto de servicio?

Este es fácil. Tengo estas reglas:

  1. ¿Su código maneja el enrutamiento, los parámetros o hace otras cosas relacionadas con el controlador?
    Si es así, no use un objeto de servicio: su código pertenece al controlador.
  2. ¿Estás tratando de compartir tu código en diferentes controladores?
    En este caso, no use un objeto de servicio, use una preocupación.
  3. ¿Es su código como un modelo que no necesita persistencia?
    Si es así, no use un objeto de servicio. Utilice un modelo que no sea ActiveRecord en su lugar.
  4. ¿Es su código una acción comercial específica? (p. ej., "Sacar la basura", "Generar un PDF usando este texto" o "Calcular el arancel aduanero usando estas reglas complicadas")
    En este caso, utilice un objeto de servicio. Ese código probablemente no encaje lógicamente ni en su controlador ni en su modelo.

Por supuesto, estas son mis reglas, por lo que puede adaptarlas a sus propios casos de uso. Estos han funcionado muy bien para mí, pero su kilometraje puede variar.

Reglas para escribir buenos objetos de servicio

Tengo cuatro reglas para crear objetos de servicio. Estos no están escritos en piedra, y si realmente desea romperlos, puede hacerlo, pero probablemente le pediré que lo cambie en las revisiones de código a menos que su razonamiento sea sólido.

Regla 1: solo un método público por objeto de servicio

Los objetos de servicio son acciones empresariales únicas . Puede cambiar el nombre de su método público si lo desea. Prefiero usar call , pero el código base de Gitlab CE lo llama execute y otras personas pueden usar perform . Usa lo que quieras, podrías llamarlo nermin por lo que a mí respecta. Simplemente no cree dos métodos públicos para un solo objeto de servicio. Divídelo en dos objetos si es necesario.

Regla 2: Objetos de servicio de nombres como roles tontos en una empresa

Los objetos de servicio son acciones comerciales únicas. Imagínese si contratara a una persona en la empresa para hacer ese trabajo, ¿cómo la llamaría? Si su trabajo es crear tweets, TweetCreator . Si su trabajo es leer tweets específicos, TweetReader .

Regla 3: no cree objetos genéricos para realizar múltiples acciones

Los objetos de servicio son acciones empresariales únicas. Dividí la funcionalidad en dos partes: TweetReader y ProfileFollower . Lo que no hice fue crear un solo objeto genérico llamado TwitterHandler y volcar allí toda la funcionalidad de la API. Por favor, no hagas esto. Esto va en contra de la mentalidad de "acción empresarial" y hace que el objeto de servicio se parezca al Hada de Twitter. Si desea compartir código entre los objetos comerciales, simplemente cree un objeto o módulo BaseTwitterManager y mézclelo con sus objetos de servicio.

Regla 4: manejar excepciones dentro del objeto de servicio

Por enésima vez: los objetos de servicio son acciones comerciales únicas. No puedo decir esto lo suficiente. Si tienes una persona que lee tweets, te dará el tweet o te dirá: "Este tweet no existe". Del mismo modo, no permita que su objeto de servicio entre en pánico, salte sobre el escritorio de su controlador y dígale que detenga todo el trabajo porque "¡Error!" Simplemente devuelva false y deje que el controlador avance desde allí.

Créditos y próximos pasos

Este artículo no hubiera sido posible sin la increíble comunidad de desarrolladores de Ruby en Toptal. Si alguna vez me encuentro con un problema, la comunidad es el grupo de ingenieros talentosos más útil que he conocido.

Si está utilizando objetos de servicio, es posible que se pregunte cómo forzar ciertas respuestas durante la prueba. Recomiendo leer este artículo sobre cómo crear objetos de servicio simulados en Rspec que siempre devolverán el resultado que desea, ¡sin tocar el objeto de servicio!

Si desea obtener más información sobre los trucos de Ruby, le recomiendo Crear un DSL de Ruby: una guía para la metaprogramación avanzada del compañero Toptaler Mate Solymosi. Desglosa cómo el archivo routes.rb no se siente como Ruby y lo ayuda a construir su propio DSL.