Objets de service Rails : un guide complet
Publié: 2022-03-11Ruby on Rails est livré avec tout ce dont vous avez besoin pour prototyper rapidement votre application, mais lorsque votre base de code commencera à croître, vous rencontrerez des scénarios où le mantra conventionnel Fat Model, Skinny Controller se brisera. Lorsque votre logique métier ne peut s'intégrer ni dans un modèle ni dans un contrôleur, c'est à ce moment que les objets de service interviennent et nous permettent de séparer chaque action métier dans son propre objet Ruby.
Dans cet article, j'expliquerai quand un objet de service est requis ; comment s'y prendre pour écrire des objets de service propres et les regrouper pour la santé mentale 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 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[:message]
?
Si vous avez utilisé Vanilla Rails jusqu'à présent, vous avez probablement fait quelque chose comme ceci :
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
Le problème ici est que vous avez ajouté au moins dix lignes à votre contrôleur, mais elles n'y appartiennent pas vraiment. Et si vous vouliez utiliser la même fonctionnalité dans un autre contrôleur ? Déplacez-vous cela vers une préoccupation ? Attendez, mais ce code n'appartient pas du tout aux contrôleurs. Pourquoi l'API Twitter ne peut-elle pas simplement venir avec un seul objet préparé à appeler ?
La première fois que j'ai fait ça, j'ai eu l'impression d'avoir fait quelque chose de sale. Mes contrôleurs Rails, auparavant magnifiquement minces, avaient commencé à grossir et je ne savais pas quoi faire. Finalement, j'ai réparé mon contrôleur avec un objet de service.
Avant de commencer à lire cet article, imaginons :
- Cette application gère un compte Twitter.
- The Rails Way signifie "la façon conventionnelle de faire les choses Ruby on Rails" et le livre n'existe pas.
- Je suis un expert Rails… ce qu'on me dit tous les jours, mais j'ai du mal à le croire, alors disons que j'en suis vraiment un.
Que sont les objets de service ?
Les objets de service sont des objets PORO (Plain Old Ruby Objects) conçus pour exécuter une seule action dans la logique de votre domaine et le faire correctement. Considérez l'exemple ci-dessus : notre méthode a déjà la logique de 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 appeler une méthode ? Quelque chose comme:
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])
C'est à peu près tout; notre objet de service TweetCreator
, une fois créé, peut être appelé de n'importe où, et il ferait très bien cette chose.
Création d'un objet de service
Commençons par créer un nouveau TweetCreator
dans un nouveau dossier appelé app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
Et laissons simplement toute notre logique dans une nouvelle classe 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
Ensuite, vous pouvez appeler TweetCreator.new(params[:message]).send_tweet
n'importe où dans votre application, et cela fonctionnera. Rails chargera cet objet comme par magie car il charge automatiquement tout sous app/
. Vérifiez cela en exécutant :
$ 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
Vous voulez en savoir plus sur le fonctionnement du autoload
? Lisez le Guide de chargement automatique et de rechargement des constantes.
Ajouter du sucre syntaxique pour rendre les objets de service Rails moins gourmands
Écoutez, ça fait du bien en théorie, mais TweetCreator.new(params[:message]).send_tweet
n'est qu'une bouchée. C'est beaucoup trop verbeux avec des mots redondants… un peu comme HTML (ba-dum tiss ! ). Mais sérieusement, pourquoi les gens utilisent-ils HTML quand HAML est là ? Ou même Slim. Je suppose que c'est un autre article pour une autre fois. Retour à la tâche à accomplir :
TweetCreator
est un joli nom de classe court, mais le truc supplémentaire autour de l'instanciation de l'objet et de l'appel de la méthode est tout simplement trop long ! Si seulement il y avait une priorité dans Ruby pour appeler quelque chose et le faire s'exécuter immédiatement avec les paramètres donnés… oh attendez, il y en a ! C'est Proc#call
.
Proccall
appelle le bloc, définissant les paramètres du bloc sur les valeurs de params en utilisant quelque chose de proche de la sémantique d'appel de méthode. Elle renvoie 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) #=> [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]
Documentation
Si cela vous confond, laissez-moi vous expliquer. Un proc
peut être call
-ed pour s'exécuter avec les paramètres donnés. Ce qui signifie que si TweetCreator
était un proc
, nous pourrions l'appeler avec TweetCreator.call(message)
et le résultat serait équivalent à TweetCreator.new(params[:message]).call
, qui ressemble assez à notre vieux TweetCreator.new(params[:message]).send_tweet
peu maniable TweetCreator.new(params[:message]).send_tweet
.
Faisons donc en sorte que notre objet de service se comporte plus comme un proc
!
Tout d'abord, parce que nous voulons probablement réutiliser ce comportement sur tous nos objets de service, empruntons à Rails Way et créons une classe appelée ApplicationService
:
# app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end
Avez-vous vu ce que j'ai fait là-bas? 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 transmettez, 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 hériter la classe 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
Et enfin, terminons cela en appelant notre objet de service dans le contrôleur :
class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end
Regroupement d'objets de service similaires pour l'intégrité
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 connexes, par exemple, avoir un service Follower
qui suivait un autre compte Twitter ? Honnêtement, je deviendrais fou si un dossier contenait 200 fichiers d'apparence unique, alors c'est une bonne chose qu'il y ait un autre modèle de Rails Way que nous pouvons copier - je veux dire, utiliser comme inspiration : l'espacement des noms.
Imaginons que nous ayons é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 à tout le 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. Suivant cette convention, j'appellerai mon nouvel objet : ProfileFollower
.
Maintenant, puisque je suis le maître suprême de cette application, je vais créer un poste de direction dans ma hiérarchie de services et déléguer la responsabilité de ces deux services à ce poste. J'appellerai ce nouveau poste de direction TwitterManager
.
Puisque ce gestionnaire ne fait que gérer, faisons-en un module et emboîtons 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.rb module TwitterManager class TweetCreator < ApplicationService ... end end
# services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end
Et nos appels deviendront désormais 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 effectué des appels d'API, mais les objets de service peuvent également être utilisés lorsque tous les appels sont destinés à votre base de données au lieu d'une API. Ceci est particulièrement utile si certaines actions commerciales nécessitent plusieurs mises à jour de base de données intégrées dans une transaction. Par exemple, cet exemple de code utiliserait des services pour enregistrer un échange de devises en cours.
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
Que dois-je renvoyer de mon objet de service ?
Nous avons expliqué comment call
notre objet de service, mais que doit renvoyer l'objet ? Il y a trois façons d'aborder cela :
- Renvoie
true
oufalse
- Retourne une valeur
- Retourne un Enum
Renvoie true
ou false
Celui-ci est simple : si une action fonctionne comme prévu, retourne true
; sinon, retourne false
:
def call ... return true if client.update(@message) false end
Renvoyer une valeur
Si votre objet de service récupère des données quelque part, vous souhaiterez probablement renvoyer 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 souhaitez gérer différents scénarios, vous pouvez simplement ajouter des énumérations pour contrôler le flux de vos services :
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
Et puis dans votre application, 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 des objets de service dans lib/services
lieu de app/services
?
C'est subjectif. Les opinions des gens divergent sur l'endroit où placer leurs objets de service. Certaines personnes les mettent dans lib/services
, tandis que d'autres créent app/services
. Je tombe dans ce dernier camp. Le guide de démarrage de Rails décrit le dossier lib/
comme l'endroit où placer les « modules étendus pour votre application ».
À mon humble avis, les « modules étendus » désignent des modules qui n'encapsulent pas la logique du domaine de base et peuvent généralement être utilisés dans tous les projets. Dans les mots sages 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'appartient pas exactement à 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.
Ce n'est pas parce qu'une autre personne a surutilisé les objets de service qu'ils sont intrinsèquement mauvais. À 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ù m'a toujours été évidente : je conserve toutes les actions commerciales dans les objets de service tout en conservant 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 vous convient le mieux.
Cependant, 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, c'est que Ruby aime les objets. Non, sérieusement, Ruby aime les objets. C'est fou, totalement dingue, mais j'adore ça ! Exemple :
> 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"
Voir? 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 dois-je pas utiliser un objet de service ?
Celui-ci est facile. J'ai ces règles :
- Votre code gère-t-il le routage, les paramètres ou fait-il d'autres choses liées au contrôleur ?
Si c'est le cas, n'utilisez pas d'objet de service - votre code appartient au contrôleur. - Essayez-vous de partager votre code dans différents contrôleurs ?
Dans ce cas, n'utilisez pas d'objet de service, utilisez une préoccupation. - Votre code ressemble-t-il à un modèle qui n'a pas besoin de persistance ?
Si tel est le cas, n'utilisez pas d'objet de service. Utilisez plutôt un modèle non ActiveRecord. - Votre code est-il une action commerciale spécifique ? (par exemple, "Sortez la poubelle", "Générez un PDF en utilisant ce texte" ou "Calculez les droits de douane en utilisant ces règles compliquées")
Dans ce cas, utilisez un objet de service. Ce code ne correspond probablement pas logiquement à votre contrôleur ou à votre modèle.
Bien sûr, ce sont mes règles, vous pouvez donc les adapter à vos propres cas d'utilisation. Ceux-ci ont très bien fonctionné pour moi, mais votre kilométrage peut varier.
Règles d'écriture de bons objets de service
J'ai quatre règles pour créer des objets de service. Ceux-ci ne sont pas écrits dans la pierre, et si vous voulez vraiment les casser, vous le pouvez, mais je vous demanderai probablement de le changer dans les revues de code à moins que votre raisonnement ne soit solide.
Règle 1 : une seule méthode publique par objet de service
Les objets de service sont des actions métier 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 tout ce qui m'importe. Ne créez simplement pas deux méthodes publiques pour un seul objet de service. Cassez-le en deux objets si vous en avez besoin.
Règle 2 : Nommez les objets de service comme des rôles idiots dans une entreprise
Les objets de service sont des actions métier uniques. Imaginez si vous embauchiez une personne dans l'entreprise pour faire ce travail, comment l'appelleriez-vous ? Si leur travail consiste à créer des tweets, appelez-les 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 plusieurs actions
Les objets de service sont des actions métier uniques . J'ai divisé la fonctionnalité en deux parties : 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 vider toutes les fonctionnalités de l'API. S'il vous plaît ne faites pas ça. Cela va à l'encontre de l'état d'esprit "action commerciale" et fait ressembler l'objet de service à la fée Twitter. Si vous souhaitez partager du code entre les objets métier, créez simplement un objet ou un module BaseTwitterManager
et mélangez-le dans vos objets de service.
Règle 4 : Gérer les exceptions à l'intérieur de l'objet de service
Pour la énième fois : les objets de service sont des actions commerciales uniques. Je ne peux pas le dire assez. Si vous avez une personne qui lit les tweets, elle vous donnera le tweet ou dira : "Ce tweet n'existe pas". De même, ne laissez pas votre objet de service paniquer, sautez sur le bureau de votre contrôleur et dites-lui d'arrêter tout travail car "Erreur!" Renvoyez simplement false
et laissez le contrôleur continuer à partir de là.
Crédits et prochaines étapes
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 vous demanderez peut-être comment forcer certaines réponses lors des tests. Je recommande de lire cet article sur la création d'objets de service fictifs dans Rspec qui renverront toujours le résultat souhaité, sans toucher l'objet de service !
Si vous voulez en savoir plus sur les astuces de Ruby, je vous recommande de créer un DSL Ruby : un guide de métaprogrammation avancée par un collègue Toptaler Mate Solymosi. Il explique comment le fichier routes.rb
ne ressemble pas à Ruby et vous aide à créer votre propre DSL.