Objets de service avec rails utilisant Aldous

L'un des concepts avec lesquels nous avons eu un grand succès dans l'équipe Tuts + est les objets de service. Nous avons utilisé des objets de service pour réduire les couplages dans nos systèmes, les rendre plus faciles à tester et rendre la logique métier importante plus évidente pour tous les développeurs de l'équipe.. 

Ainsi, lorsque nous avons décidé de codifier certains des concepts que nous avons utilisés dans notre développement Rails dans une gemme Ruby (appelée Aldous), les objets de service se trouvaient en haut de la liste..

Ce que je voudrais faire aujourd’hui, c’est donner un aperçu rapide des objets de service tels que nous les avons mis en œuvre dans Aldous. J'espère que cela vous indiquera la plupart des choses que vous devez savoir pour utiliser les objets de service Aldous dans vos propres projets..

L'anatomie d'un objet de service de base

Photo de Dennis Skley

Un objet de service est fondamentalement une méthode encapsulée dans un objet. Parfois, un objet de service peut contenir plusieurs méthodes, mais la version la plus simple est simplement une classe avec une méthode, par exemple:

class DoSomething def effectue # faire les choses fin fin

Nous sommes tous habitués à utiliser des noms pour nommer nos objets, mais il peut parfois être difficile de trouver un bon nom pour représenter un concept, alors que le parler en termes d'action (ou de verbe) est simple et naturel. Un objet de service est ce que nous obtenons lorsque nous "suivons le courant" et que nous transformons simplement le verbe en objet..

Bien sûr, étant donné la définition ci-dessus, nous pouvons transformer toute action / méthode en objet de service si nous le souhaitons. Le suivant…

class Customer def createPurchase (order) # fait les choses fin fin

… Pourrait être transformé en:

classe CreateCustomerPurchase def initialize (client, commande) end def

Nous pourrions écrire plusieurs autres articles sur l'effet que les objets de service pourraient avoir sur la conception de votre système, les différents compromis que vous ferez, etc. Pour l'instant, prenons-les simplement comme concept et considérons-les simplement comme un autre outil. nous avons dans notre arsenal.

Pourquoi utiliser des objets de service dans des rails

À mesure que les applications Rails deviennent plus grandes, nos modèles ont tendance à devenir de plus en plus grands. Nous cherchons donc des moyens de transférer certaines fonctionnalités dans des objets «auxiliaires». Mais c'est souvent plus facile à dire qu'à faire. Rails n'a pas de concept, dans la couche de modèle, qui est plus granulaire qu'un modèle. Donc, vous finissez par avoir à faire beaucoup de jugements:

  • Créez-vous un modèle PORO ou créez-vous une classe dans le lib dossier?
  • Quelles méthodes déplacez-vous dans cette classe?
  • Comment nommez-vous judicieusement cette classe étant donné les méthodes que nous avons utilisées?? 

Vous devez maintenant communiquer ce que vous avez fait aux autres développeurs de votre équipe et à toutes les nouvelles personnes qui rejoignent ultérieurement. Et, bien sûr, face à une situation similaire, d'autres développeurs pourraient faire appel à des jugements différents, ce qui entraînerait des incohérences.

Les objets de service nous donnent un concept plus granulaire qu'un modèle. Nous pouvons avoir un emplacement cohérent pour tous nos services et vous ne déplacez jamais qu'une méthode dans un service. Vous nommez cette classe après l'action / la méthode qu'elle va représenter. Nous pouvons extraire des fonctionnalités dans des objets plus granulaires sans trop d'appels de jugement, ce qui permet à toute l'équipe de rester sur la même page, ce qui nous permet de créer une application de qualité.. 

L'utilisation d'objets de service réduit le couplage entre vos modèles Rails et les services résultants sont hautement réutilisables en raison de leur faible encombrement / faible encombrement.. 

Les objets de service sont également hautement testables, car ils ne nécessitent généralement pas autant de tests que des objets plus lourds, et vous ne vous inquiétez que de tester la méthode que l'objet contient.. 

Les objets de service et leurs tests sont faciles à lire / à comprendre car ils sont très cohérents (également un effet secondaire de leur petite taille). Vous pouvez également supprimer et réécrire les objets de service et leurs tests presque à volonté, car le coût de cette opération est relativement faible et il est très facile de maintenir leur interface..

Les objets de service ont certainement beaucoup à faire, surtout lorsque vous les introduisez dans vos applications Rails. 

Objets de service avec Aldous

Photo de Trevor Leyenhorst

Étant donné que les objets de service sont si simples, pourquoi avons-nous même besoin d'un bijou? Pourquoi ne pas simplement créer des PORO, sans avoir à s'inquiéter d'une autre dépendance? 

Vous pouvez certainement le faire, et en fait nous l'avons fait pendant un bon bout de temps dans Tuts +, mais grâce à un usage intensif, nous avons fini par développer quelques modèles de services qui simplifiaient un peu nos vies, et c'est exactement ce que nous avons poussé dans Aldous. Ces modèles sont légers et n'impliquent pas beaucoup de magie. Ils facilitent un peu nos vies, mais nous gardons tout le contrôle si nous en avons besoin.

Où ils devraient vivre

Tout d’abord, où devraient vivre vos services? Nous avons tendance à les mettre dans app / services, donc vous avez besoin de ce qui suit dans votre app / config / application.rb:

config.autoload_paths + =% W (# config.root / app / services) config.eager_load_paths + =% W (# config.root / app / services)

Ce qu'ils devraient être appelés

Comme je l’ai mentionné ci-dessus, nous avons tendance à nommer les objets de service après des actions / verbes (par exemple,. Créer un utilisateur, RemboursementAchat), mais nous avons aussi tendance à ajouter «service» à tous les noms de classe (par exemple. CreateUserService, RemboursementService Achat). Ainsi, quel que soit le contexte dans lequel vous vous trouvez (consultation des fichiers sur le système de fichiers, recherche d'une classe de service n'importe où dans la base de code), vous savez toujours que vous avez affaire à un objet de service..

Ceci n'est pas imposé par la gemme en aucune façon, mais mérite d'être pris en compte comme une leçon apprise.

Les objets de service sont immuables

Lorsque nous disons immuable, nous entendons qu'après que l'objet ait été initialisé, son état interne ne changera plus. C’est vraiment formidable, car il est beaucoup plus simple de raisonner sur l’état de chaque objet ainsi que sur le système dans son ensemble..

Pour que ce qui précède soit vrai, la méthode de l'objet de service ne peut pas changer l'état de l'objet. Par conséquent, toutes les données doivent être renvoyées en tant que sortie de la méthode. Cela est difficile à appliquer directement, puisqu'un objet aura toujours accès à son propre état interne. Avec Aldous, nous essayons de le faire respecter par le biais de conventions et d’éducation, et les deux sections suivantes vous montreront comment.

Représenter le succès et l'échec

Un objet de service Aldous doit toujours renvoyer l'un des deux types d'objet:

  • Aldous :: Service :: Résultat :: Succès
  • Aldous :: Service :: Résultat :: Échec

Voici un exemple:

classe CreateUserService < Aldous::Service def perform user = User.new(user_data_hash) if user.save Result::Success.new else Result::Failure.new end end end

Parce que nous héritons de Aldous :: Service, nous pouvons construire nos objets de retour comme Résultat :: Succès. L'utilisation de ces objets comme valeurs de retour nous permet d'effectuer les tâches suivantes:

hash =  resultat = CreateUserService.perform (hash) si result.success? # Est-ce que le succès est un autre # result.failure? # faire des trucs d'échec fin

En théorie, nous pourrions simplement renvoyer true ou false et obtenir le même comportement que ci-dessus, mais si nous le faisions, nous ne pourrions pas transporter de données supplémentaires avec notre valeur de retour et nous voulons souvent transporter des données..

Utilisation de DTO

Le succès ou l'échec d'une opération / d'un service n'est qu'une partie de l'histoire. Nous aurons souvent créé un objet que nous voudrions renvoyer ou généré des erreurs dont nous voudrions notifier le code appelant. C'est pourquoi il est utile de renvoyer des objets, comme nous l'avons montré ci-dessus. Ces objets ne servent pas uniquement à indiquer le succès ou l'échec, ils sont également des objets de transfert de données..

Aldous vous permet de remplacer une méthode de la classe de service de base afin de spécifier un ensemble de valeurs par défaut que les objets renvoyés du service contiendraient, par exemple:

classe CreateUserService < Aldous::Service attr_reader :user_data_hash def initialize(user_data_hash) @user_data_hash = user_data_hash end def default_result_data user: nil end def perform user = User.new(user_data_hash) if user.save Result::Success.new(user: user) else Result::Failure.new end end end

Les clés de hachage contenues dans default_result_data deviendront automatiquement des méthodes sur le Résultat :: Succès et Résultat :: Échec objets retournés par le service. Et si vous fournissez une valeur différente pour l'une des clés de cette méthode, la valeur par défaut sera remplacée. Donc, dans le cas de la classe ci-dessus:

hash =  resultat = CreateUserService.perform (hash) si result.success? result.user # sera une instance de l'utilisateur result.blah # soulèverait une erreur sinon # result.failure? result.user # sera nul result.blah # soulèvera une fin d'erreur

En effet, les clés de hachage dans le default_result_data method sont un contrat pour les utilisateurs de l'objet de service. Nous vous garantissons que vous pourrez appeler n'importe quelle clé dans ce hachage en tant que méthode sur tout objet de résultat sortant du service..

API sans erreur

Image de Roberto Zingales

Lorsque nous parlons d’API sans erreur, nous entendons des méthodes qui ne génèrent jamais d’erreur, mais renvoient toujours une valeur indiquant le succès ou l’échec. J'ai déjà écrit sur les API sans erreur. Les services Aldous sont exempts d'erreur, cela dépend de la façon dont vous les appelez. Dans l'exemple ci-dessus: 

resultat = CreateUserService.perform (dièse)

Cela ne provoquera jamais d'erreur. En interne, Aldous enveloppe votre méthode perform dans un porter secours bloquer et si votre code lève une erreur, il retournera un Résultat :: Échec avec le default_result_data comme données. 

C'est assez libérateur, car vous n'avez plus besoin de penser à ce qui peut mal tourner avec le code que vous avez écrit. Vous ne vous intéressez qu'au succès ou à l'échec de votre service, et toute erreur entraînera un échec. 

Ceci est idéal pour la plupart des situations. Mais parfois, vous voulez une erreur générée. Le meilleur exemple en est lorsque vous utilisez un objet de service dans un agent d'arrière-plan et qu'une erreur entraîne une nouvelle tentative de l'agent d'arrière-plan. C’est pourquoi un service Aldous obtient aussi, comme par magie, une effectuer! méthode et vous permet de remplacer une autre méthode de la classe de base. Voici encore notre exemple:

classe CreateUserService < Aldous::Service attr_reader :user_data_hash def initialize(user_data_hash) @user_data_hash = user_data_hash end def raisable_error MyApplication::Errors::UserError end def default_result_data user: nil end def perform user = User.new(user_data_hash) if user.save Result::Success.new(user: user) else Result::Failure.new end end end

Comme vous pouvez le voir, nous avons maintenant remplacé le raisable_error méthode. Nous voulons parfois qu'une erreur soit produite, mais nous ne voulons pas non plus que ce soit une erreur. Dans le cas contraire, notre code d'appel devrait connaître toutes les erreurs possibles que le service peut produire ou être obligé de détecter l'un des types d'erreur de base. C’est pourquoi, lorsque vous utilisez le effectuer! méthode, Aldous capturera toujours toutes les erreurs pour vous, mais relancera ensuite la raisable_error vous avez spécifié et défini l'erreur d'origine comme cause. Vous pouvez maintenant avoir ceci:

hash =  begin service = CreateUserService.build (hash) result = service.perform! rescue service.raisable_error => e # fin truc d'erreur

Test des objets de service Aldous

Vous avez peut-être remarqué l'utilisation de la méthode d'usine:

CreateUserService.build (hash) CreateUserService.perform (hash)

Vous devriez toujours les utiliser et ne jamais construire d'objet de service directement. Les méthodes d’usine nous permettent d’accrocher proprement les fonctionnalités intéressantes comme le sauvetage automatique et l’ajout du default_result_data.

Cependant, quand il s'agit de tests, vous ne voulez pas vous inquiéter de la façon dont Aldous augmente les fonctionnalités de vos objets de service. Ainsi, lors du test, construisez simplement les objets directement à l'aide du constructeur, puis testez vos fonctionnalités. Vous obtiendrez des spécifications pour la logique que vous avez écrite et aurez la certitude qu'Aldous fera ce qu'il est censé faire (Aldous a ses propres tests pour cela) lorsqu'il est en production.

Conclusion

J'espère que cela vous a donné une idée de la façon dont les objets de service (et en particulier les objets de service Aldous) peuvent constituer un outil intéressant dans votre arsenal lorsque vous utilisez Ruby / Rails. Essayez Aldous et dites-nous ce que vous pensez. N'hésitez pas également à consulter le code Aldous. Nous ne l'avons pas seulement écrit pour être utile, mais aussi pour être lisible et facile à comprendre / modifier.