Objets de service Rails : A Comprehensive Guide

Ruby on Rails est livré avec tout ce dont vous avez besoin pour prototyper rapidement votre application, mais lorsque votre base de code commence à croître, vous rencontrerez des scénarios où le mantra conventionnel Fat Model, Skinny Controller se brise. Lorsque votre logique métier ne peut s’intégrer ni dans un modèle ni dans un contrôleur, c’est là que les objets de service interviennent et nous permettent de séparer chaque action métier dans son propre objet Ruby.

Un exemple de cycle de requête avec les objets de service Rails

Dans cet article, j’expliquerai quand un objet de service est nécessaire ; comment procéder pour écrire des objets de service propres et les regrouper pour la sanité des contributeurs ; les règles strictes que j’impose à mes objets de service pour les lier directement à ma logique métier ; et comment ne pas transformer vos objets de service en un dépotoir pour tout le code dont vous ne savez pas quoi faire.

Pourquoi ai-je besoin d’objets de service ?

Essayez ceci : Que faites-vous lorsque votre application a besoin de tweeter le texte de params?

Si vous avez utilisé vanilla Rails jusqu’à présent, alors vous avez probablement fait quelque chose comme ceci :

class TweetController < ApplicationController def create send_tweet(params) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(tweet) endend

Le problème ici est que vous avez ajouté au moins dix lignes à votre contrôleur, mais elles n’y ont pas vraiment leur place. De plus, que se passerait-il si vous vouliez utiliser la même fonctionnalité dans un autre contrôleur ? Faut-il déplacer cette fonctionnalité vers une préoccupation ? Attendez, mais ce code n’a pas vraiment sa place dans les contrôleurs du tout. Pourquoi l’API Twitter ne peut-elle pas venir avec un seul objet préparé pour que je puisse l’appeler ?

La première fois que j’ai fait cela, j’ai eu l’impression d’avoir fait quelque chose de sale. Mes, auparavant, magnifiques contrôleurs Rails maigres avaient commencé à devenir gros et je ne savais pas quoi faire. Finalement, j’ai corrigé mon contrôleur avec un objet de service.

Avant que vous ne commenciez à lire cet article, faisons comme si :

  • Cette application gère un compte Twitter.
  • Le Rails Way signifie “la manière conventionnelle de Ruby on Rails de faire les choses” et le livre n’existe pas.
  • Je suis un expert Rails… ce que l’on me dit tous les jours, mais j’ai du mal à le croire, alors faisons comme si j’en étais vraiment un.

Qu’est-ce que les objets de service ?

Les objets de service sont des Plain Old Ruby Objects (PORO) qui sont conçus pour exécuter une seule action dans votre logique de domaine et le faire bien. Considérez l’exemple ci-dessus : Notre méthode a déjà la logique pour faire une seule chose, et c’est de créer un tweet. Et si cette logique était encapsulée dans une seule classe Ruby que nous pouvons instancier et à laquelle nous pouvons appeler une méthode ? Quelque chose comme:

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

C’est à peu près ça ; notre objet service TweetCreator, une fois créé, peut être appelé de n’importe où, et il ferait cette seule chose très bien.

Création d’un objet service

D’abord, créons un nouveau TweetCreator dans un nouveau dossier appelé app/services:

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

Et déversons toute notre logique à l’intérieur d’une nouvelle classe Ruby:

# app/services/tweet_creator.rbclass TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend

Puis vous pouvez appeler TweetCreator.new(params).send_tweet n’importe où dans votre application, et cela fonctionnera. Rails chargera cet objet comme par magie car il autoload tout sous app/. Vérifiez cela en exécutant :

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

Vous voulez en savoir plus sur le fonctionnement de autoload ? Lisez le guide des constantes de chargement automatique et de rechargement.

Ajouter du sucre syntaxique pour que les objets de service Rails craignent moins

Regardez, cela semble génial en théorie, mais TweetCreator.new(params).send_tweet est juste une bouche. C’est beaucoup trop verbeux avec des mots redondants… un peu comme le HTML (ba-dum tiss !). Plus sérieusement, pourquoi les gens utilisent-ils HTML alors qu’il existe HAML ? Ou même Slim. Je suppose que c’est un autre article pour une autre fois. Revenons à nos moutons :

TweetCreator est un joli nom de classe court, mais la trame supplémentaire autour de l’instanciation de l’objet et de l’appel de la méthode est juste trop longue ! Si seulement il y avait une précédence dans Ruby pour appeler quelque chose et le faire s’exécuter immédiatement avec les paramètres donnés… oh attends, il y en a une ! C’est Proc#call.

Proccall invoque le bloc, fixant les paramètres du bloc aux valeurs de params en utilisant quelque chose de proche de la sémantique d’appel de méthode. Il retourne la valeur de la dernière expression évaluée dans le bloc.

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

Documentation

Si cela vous perturbe, laissez-moi vous expliquer. Un proc peut être call-ed pour s’exécuter lui-même avec les paramètres donnés. Ce qui signifie que si TweetCreator était une proc, nous pourrions l’appeler avec TweetCreator.call(message) et le résultat serait équivalent à TweetCreator.new(params).call, ce qui ressemble beaucoup à notre vieille TweetCreator.new(params).send_tweet.

Faisons donc en sorte que notre objet de service se comporte plus comme une proc !

Premièrement, parce que nous voulons probablement réutiliser ce comportement à travers tous nos objets de service, empruntons à la manière Rails et créons une classe appelée ApplicationService:

# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend

Vous avez vu ce que j’ai fait là ? J’ai ajouté une méthode de classe appelée call qui crée une nouvelle instance de la classe avec les arguments ou le bloc que vous lui passez, et appelle call sur l’instance. Exactement ce que nous voulions ! La dernière chose à faire est de renommer la méthode de notre classe TweetCreator en call, et de faire en sorte que la classe hérite de ApplicationService:

# app/services/tweet_creator.rbclass TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend

Et enfin, terminons en appelant notre objet de service dans le contrôleur:

class TweetController < ApplicationController def create TweetCreator.call(params) endend

Groupement d’objets de service similaires pour la sanité

L’exemple ci-dessus n’a qu’un seul objet de service, mais dans le monde réel, les choses peuvent devenir plus compliquées. Par exemple, que se passerait-il si vous aviez des centaines de services, et que la moitié d’entre eux étaient des actions commerciales liées, par exemple, avoir un Follower service qui suivait un autre compte Twitter ? Honnêtement, je deviendrais fou si un dossier contenait 200 fichiers à l’aspect unique, alors heureusement qu’il y a un autre motif de la Rails Way que nous pouvons copier – je veux dire, utiliser comme inspiration : l’espacement des noms.

Prétendons que nous avons été chargés de créer un objet de service qui suit d’autres profils Twitter.

Regardons le nom de notre objet de service précédent : TweetCreator. Cela ressemble à une personne, ou au moins, à un rôle dans une organisation. Quelqu’un qui crée des Tweets. J’aime nommer mes objets de service comme s’ils n’étaient que cela : des rôles dans une organisation. En suivant cette convention, je vais appeler mon nouvel objet : ProfileFollower.

Maintenant, puisque je suis le suzerain suprême de cette application, je vais créer une position de gestionnaire dans ma hiérarchie de services et déléguer la responsabilité de ces deux services à cette position. Je vais appeler cette nouvelle position de gestionnaire TwitterManager.

Puisque ce gestionnaire ne fait rien d’autre que gérer, faisons-en un module et imbriquons nos objets de service sous ce module. Notre structure de dossier ressemblera maintenant à :

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

Et nos objets de service :

# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend

Et nos appels deviendront maintenant TwitterManager::TweetCreator.call(arg), et TwitterManager::ProfileManager.call(arg).

Objets de service pour gérer les opérations de base de données

L’exemple ci-dessus a fait des appels d’API, mais les objets de service peuvent également être utilisés lorsque tous les appels sont à votre base de données au lieu d’une API. Cela est particulièrement utile si certaines actions commerciales nécessitent plusieurs mises à jour de la base de données enveloppées dans une transaction. Par exemple, cet exemple de code utiliserait des services pour enregistrer un échange de devises qui a lieu.

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 ... endend

Que dois-je retourner de mon objet de service?

Nous avons discuté de la façon de callnotre objet de service, mais que doit retourner l’objet ? Il y a trois façons d’aborder cela :

  • Retourner true ou false
  • Retourner une valeur
  • Retourner une Enum

Retourner true ou false

Celle-ci est simple : Si une action fonctionne comme prévu, retournez true ; sinon, retournez false:

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

Retourner une valeur

Si votre objet de service récupère des données de quelque part, vous voulez probablement retourner cette valeur :

 def call ... return false unless exchange_rate exchange_rate end

Répondre avec un enum

Si votre objet de service est un peu plus complexe, et que vous voulez gérer différents scénarios, vous pourriez simplement ajouter des enums pour contrôler le flux de vos services :

class ExchangeRecorder < ApplicationService RETURNS = def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something endend

Et ensuite dans votre app, vous pouvez utiliser :

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

Ne devrais-je pas mettre les objets de service dans lib/services au lieu de app/services ?

C’est subjectif. Les opinions des gens diffèrent sur l’endroit où mettre leurs objets de service. Certaines personnes les mettent dans lib/services, tandis que d’autres créent app/services. Je me range dans ce dernier camp. Le guide de démarrage de Rails décrit le dossier lib/ comme l’endroit où mettre les “modules étendus pour votre application”.”

À mon humble avis, les “modules étendus” signifient des modules qui n’encapsulent pas la logique de domaine de base et peuvent généralement être utilisés à travers les projets. Selon les sages mots d’une réponse aléatoire de Stack Overflow, mettez-y du code qui “peut potentiellement devenir son propre joyau”.”

Les objets de service sont-ils une bonne idée ?

Cela dépend de votre cas d’utilisation. Écoutez – le fait que vous lisiez cet article en ce moment suggère que vous essayez d’écrire du code qui n’a pas exactement sa place dans un modèle ou un contrôleur. J’ai récemment lu cet article sur la façon dont les objets de service sont un anti-modèle. L’auteur a ses opinions, mais je suis respectueusement en désaccord.

Parce qu’une autre personne a surutilisé les objets de service, cela ne signifie pas qu’ils sont intrinsèquement mauvais. Dans ma startup, Nazdeeq, nous utilisons des objets de service ainsi que des modèles non-ActiveRecord. Mais la différence entre ce qui va où va a toujours été évidente pour moi : Je garde toutes les actions commerciales dans les objets de service et les ressources qui n’ont pas vraiment besoin de persistance dans les modèles non ActiveRecord. En fin de compte, c’est à vous de décider quel modèle est bon pour vous.

Pour autant, est-ce que je pense que les objets de service en général sont une bonne idée ? Absolument ! Ils gardent mon code bien organisé, et ce qui me rend confiant dans mon utilisation des PORO est que Ruby aime les objets. Non, sérieusement, Ruby adore les objets. C’est fou, complètement dingue, mais j’adore ça ! Exemple concret :

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

Vous voyez ? 5 est littéralement un objet.

Dans de nombreux langages, les nombres et autres types primitifs ne sont pas des objets. Ruby suit l’influence du langage Smalltalk en donnant des méthodes et des variables d’instance à tous ses types. Cela facilite l’utilisation de Ruby, puisque les règles s’appliquant aux objets s’appliquent à tout Ruby.Ruby-lang.org

Quand ne devrais-je pas utiliser un objet service?

Celle-ci est facile. J’ai ces règles :

  1. Votre code gère-t-il le routage, les paramètres ou fait-il d’autres choses qui relèvent du contrôleur ?
    Si oui, n’utilisez pas un objet de service – votre code appartient au contrôleur.
  2. Essayez-vous de partager votre code dans différents contrôleurs ?
    Dans ce cas, n’utilisez pas un objet de service – utilisez une préoccupation.
  3. Votre code est-il comme un modèle qui n’a pas besoin de persistance ?
    Si oui, n’utilisez pas un objet de service. Utilisez plutôt un modèle non-ActiveRecord.
  4. Votre code est-il une action métier spécifique ? (par exemple, “Sortir la poubelle”, “Générer un PDF en utilisant ce texte” ou “Calculer les droits de douane en utilisant ces règles compliquées”)
    Dans ce cas, utilisez un objet de service. Ce code n’a probablement pas sa place logique ni dans votre contrôleur ni dans votre modèle.

Bien sûr, ce sont mes règles, donc vous êtes invités à les adapter à vos propres cas d’utilisation. Celles-ci ont très bien fonctionné pour moi, mais votre kilométrage peut varier.

Règles pour écrire de bons objets de service

J’ai quatre règles pour créer des objets de service. Elles ne sont pas écrites dans la pierre, et si vous voulez vraiment les briser, vous pouvez, mais je vous demanderai probablement de le changer dans les revues de code, à moins que votre raisonnement soit solide.

Règle 1 : Une seule méthode publique par objet de service

Les objets de service sont des actions commerciales uniques. Vous pouvez changer le nom de votre méthode publique si vous le souhaitez. Je préfère utiliser call, mais la base de code de Gitlab CE l’appelle execute et d’autres personnes peuvent utiliser perform. Utilisez ce que vous voulez – vous pourriez l’appeler nermin pour ce que j’en ai à faire. Juste ne créez pas deux méthodes publiques pour un seul objet de service. Break it into two objects if you need to.

Règle 2 : Nommez les objets de service comme des rôles muets dans une entreprise

Les objets de service sont des actions commerciales uniques. Imaginez que vous engagiez une personne dans l’entreprise pour faire ce seul travail, comment l’appelleriez-vous ? Si son travail consiste à créer des tweets, appelez-la TweetCreator. Si leur travail consiste à lire des tweets spécifiques, appelez-les TweetReader.

Règle 3 : Ne créez pas d’objets génériques pour effectuer des actions multiples

Les objets de service sont des actions commerciales uniques. J’ai cassé la fonctionnalité en deux morceaux : TweetReader, et ProfileFollower. Ce que je n’ai pas fait, c’est de créer un seul objet générique appelé TwitterHandler et d’y déverser toutes les fonctionnalités de l’API. S’il vous plaît, ne faites pas cela. Cela va à l’encontre de l’état d’esprit “action commerciale” et fait ressembler l’objet service à la fée Twitter. Si vous voulez partager du code entre les objets métier, créez simplement un objet ou un module BaseTwitterManager et mélangez-le à vos objets service.

Règle 4 : Gérer les exceptions à l’intérieur de l’objet service

Pour la énième fois : Les objets de service sont des actions commerciales uniques. Je ne le dirai jamais assez. Si vous avez une personne qui lit les tweets, elle va soit vous donner le tweet, soit dire “Ce tweet n’existe pas”. De même, ne laissez pas votre objet de service paniquer, sauter sur le bureau de votre contrôleur et lui dire d’arrêter tout travail parce que “Erreur !”. Retournez simplement false et laissez le contrôleur passer à autre chose.

Cet article n’aurait pas été possible sans l’incroyable communauté de développeurs Ruby de Toptal. Si jamais je rencontre un problème, la communauté est le groupe d’ingénieurs talentueux le plus utile que j’ai jamais rencontré.

Si vous utilisez des objets de service, vous pouvez vous demander comment forcer certaines réponses pendant les tests. Je vous recommande de lire cet article sur la façon de créer des objets de service fantaisie dans Rspec qui renverront toujours le résultat que vous voulez, sans réellement frapper l’objet de service !

Si vous voulez en apprendre davantage sur les astuces Ruby, je vous recommande Creating a Ruby DSL : A Guide to Advanced Metaprogramming par le collègue Toptaler Máté Solymosi. Il décompose comment le fichier routes.rb ne se sent pas comme Ruby et vous aide à construire votre propre DSL.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.