Construisez des composants de rails élégants avec des objets Ruby simples
Publié: 2022-03-11Votre site Web gagne du terrain et vous vous développez rapidement. Ruby/Rails est votre langage de programmation de prédilection. Votre équipe est plus grande et vous avez abandonné les "modèles gras, les contrôleurs maigres" comme style de conception pour vos applications Rails. Cependant, vous ne voulez toujours pas abandonner l'utilisation de Rails.
Aucun problème. Aujourd'hui, nous allons discuter de la façon d'utiliser les meilleures pratiques de la POO pour rendre votre code plus propre, plus isolé et plus découplé.
Votre application vaut-elle la peine d'être refactorisée ?
Commençons par examiner comment vous devriez décider si votre application est un bon candidat pour la refactorisation.
Voici une liste de métriques et de questions que je me pose habituellement pour déterminer si oui ou non mon code a besoin d'être refactorisé.
- Tests unitaires lents. Les tests unitaires PORO s'exécutent généralement rapidement avec un code bien isolé, de sorte que des tests lents peuvent souvent être un indicateur d'une mauvaise conception et de responsabilités trop couplées.
- Modèles ou contrôleurs FAT. Un modèle ou un contrôleur avec plus de 200 lignes de code (LOC) est généralement un bon candidat pour la refactorisation.
- Base de code excessivement grande. Si vous avez ERB/HTML/HAML avec plus de 30 000 LOC ou du code source Ruby (sans GEM) avec plus de 50 000 LOC, il y a de fortes chances que vous deviez refactoriser.
Essayez d'utiliser quelque chose comme ceci pour savoir combien de lignes de code source Ruby vous avez :
find app -iname "*.rb" -type f -exec cat {} \;| wc -l
Cette commande recherchera dans tous les fichiers avec l'extension .rb (fichiers ruby) dans le dossier /app et imprimera le nombre de lignes. Veuillez noter que ce nombre n'est qu'approximatif puisque les lignes de commentaires seront incluses dans ces totaux.
Une autre option plus précise et plus informative consiste à utiliser les stats
de la tâche Rails rake qui génèrent un résumé rapide des lignes de code, du nombre de classes, du nombre de méthodes, du rapport des méthodes aux classes et du rapport des lignes de code par méthode :
bundle exec rake stats +----------------------+-------+-----+-------+---------+-----+-------+ | Name | Lines | LOC | Class | Methods | M/C | LOC/M | +----------------------+-------+-----+-------+---------+-----+-------+ | Controllers | 195 | 153 | 6 | 18 | 3 | 6 | | Helpers | 14 | 13 | 0 | 2 | 0 | 4 | | Models | 120 | 84 | 5 | 12 | 2 | 5 | | Mailers | 0 | 0 | 0 | 0 | 0 | 0 | | Javascripts | 45 | 12 | 0 | 3 | 0 | 2 | | Libraries | 0 | 0 | 0 | 0 | 0 | 0 | | Controller specs | 106 | 75 | 0 | 0 | 0 | 0 | | Helper specs | 15 | 4 | 0 | 0 | 0 | 0 | | Model specs | 238 | 182 | 0 | 0 | 0 | 0 | | Request specs | 699 | 489 | 0 | 14 | 0 | 32 | | Routing specs | 35 | 26 | 0 | 0 | 0 | 0 | | View specs | 5 | 4 | 0 | 0 | 0 | 0 | +----------------------+-------+-----+-------+---------+-----+-------+ | Total | 1472 |1042 | 11 | 49 | 4 | 19 | +----------------------+-------+-----+-------+---------+-----+-------+ Code LOC: 262 Test LOC: 780 Code to Test Ratio: 1:3.0
- Puis-je extraire des modèles récurrents dans ma base de code ?
Le découplage en action
Commençons par un exemple concret.
Imaginez que nous voulons écrire une application qui suit le temps des joggeurs. Sur la page principale, l'utilisateur peut voir les heures saisies.
Chaque entrée de temps a une date, une distance, une durée et des informations supplémentaires pertinentes sur le "statut" (par exemple, la météo, le type de terrain, etc.), ainsi qu'une vitesse moyenne qui peut être calculée en cas de besoin.
Nous avons besoin d'une page de rapport qui affiche la vitesse et la distance moyennes par semaine.
Si la vitesse moyenne de l'entrée est supérieure à la vitesse moyenne globale, nous en informerons l'utilisateur par SMS (pour cet exemple, nous utiliserons l'API Nexmo RESTful pour envoyer le SMS).
La page d'accueil vous permettra de sélectionner la distance, la date et le temps passé à faire du jogging pour créer une entrée similaire à celle-ci :
Nous avons également une page de statistics
qui est essentiellement un rapport hebdomadaire qui inclut la vitesse moyenne et la distance parcourue par semaine.
- Vous pouvez consulter l'échantillon en ligne ici.
Le code
La structure du répertoire de l' app
ressemble à :
⇒ tree . ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── helpers │ ├── application_helper.rb │ ├── entries_helper.rb │ └── statistics_helper.rb ├── mailers ├── models │ ├── entry.rb │ └── user.rb └── views ├── devise │ └── ... ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Je ne discuterai pas du modèle User
car il n'a rien de spécial puisque nous l'utilisons avec Devise pour implémenter l'authentification.
Quant au modèle Entry
, il contient la logique métier de notre application.
Chaque Entry
appartient à un User
.
Nous validons la présence des attributs distance
, time_period
, date_time
et status
pour chaque entrée.
Chaque fois que nous créons une entrée, nous comparons la vitesse moyenne de l'utilisateur avec la moyenne de tous les autres utilisateurs du système et notifions l'utilisateur par SMS à l'aide de Nexmo (nous ne discuterons pas de l'utilisation de la bibliothèque Nexmo, même si je voulais pour illustrer un cas dans lequel nous utilisons une bibliothèque externe).
- Echantillon essentiel
Notez que le modèle Entry
contient plus que la seule logique métier. Il gère également certaines validations et rappels.
Le entries_controller.rb
a les principales actions CRUD (pas de mise à jour cependant). EntriesController#index
obtient les entrées de l'utilisateur actuel et classe les enregistrements par date de création, tandis que EntriesController#create
crée une nouvelle entrée. Inutile de discuter des évidences et des responsabilités d' EntriesController#destroy
:
- Echantillon essentiel
Alors que statistics_controller.rb
est responsable du calcul du rapport hebdomadaire, StatisticsController#index
récupère les entrées de l'utilisateur connecté et les regroupe par semaine, en utilisant la méthode #group_by
contenue dans la classe Enumerable de Rails. Il essaie ensuite de décorer les résultats en utilisant des méthodes privées.
- Echantillon essentiel
Nous ne discutons pas beaucoup des vues ici, car le code source est explicite.
Vous trouverez ci-dessous la vue permettant de répertorier les entrées de l'utilisateur connecté ( index.html.erb
). C'est le modèle qui sera utilisé pour afficher les résultats de l'action index (méthode) dans le contrôleur d'entrées :
- Echantillon essentiel
Notez que nous utilisons des rendus partiels render @entries
pour extraire le code partagé dans un modèle partiel _entry.html.erb
afin que nous puissions garder notre code DRY et réutilisable :
- Echantillon essentiel
Il en va de même pour le partiel _form
. Au lieu d'utiliser le même code avec les actions (nouveau et modifier), nous créons un formulaire partiel réutilisable :
- Echantillon essentiel
Comme pour la vue de la page de rapport hebdomadaire, statistics/index.html.erb
affiche quelques statistiques et rapporte les performances hebdomadaires de l'utilisateur en regroupant certaines entrées :
- Echantillon essentiel
Et enfin, l'assistant pour les entrées, entries_helper.rb
, comprend deux assistants readable_time_period
et readable_speed
qui devraient rendre les attributs plus lisibles humainement :
- Echantillon essentiel
Rien d'extraordinaire jusqu'à présent.
La plupart d'entre vous diront que refactoriser cela va à l'encontre du principe KISS et rendra le système plus compliqué.
Alors, cette application a-t-elle vraiment besoin d'être refactorisée ?
Absolument pas , mais nous ne l'examinerons qu'à des fins de démonstration.
Après tout, si vous consultez la section précédente et les caractéristiques qui indiquent qu'une application doit être refactorisée, il devient évident que l'application de notre exemple n'est pas un candidat valable pour la refactorisation.
Cycle de la vie
Commençons donc par expliquer la structure du modèle Rails MVC.
Habituellement, cela commence par le navigateur qui fait une demande, telle que https://www.toptal.com/jogging/show/1
.
Le serveur Web reçoit la requête et utilise des routes
pour savoir quel controller
utiliser.
Les contrôleurs effectuent le travail d'analyse des demandes des utilisateurs, des soumissions de données, des cookies, des sessions, etc., puis demandent au model
d'obtenir les données.
Les models
sont des classes Ruby qui communiquent avec la base de données, stockent et valident les données, exécutent la logique métier et font le gros du travail. Les vues sont ce que l'utilisateur voit : HTML, CSS, XML, Javascript, JSON.
Si nous voulons montrer la séquence d'un cycle de vie de requête Rails, cela ressemblerait à ceci :
Ce que je veux réaliser, c'est ajouter plus d'abstraction à l'aide d'objets ruby anciens simples (PORO) et faire du modèle quelque chose comme ce qui suit pour les actions de create/update
à jour :
Et quelque chose comme ce qui suit pour les actions list/show
:
En ajoutant des abstractions PORO, nous assurerons une séparation complète entre les responsabilités SRP, ce que Rails n'est pas très doué.
Des lignes directrices
Pour réaliser le nouveau design, j'utiliserai les directives énumérées ci-dessous, mais veuillez noter que ce ne sont pas des règles que vous devez suivre pour le T. Considérez-les comme des directives flexibles qui facilitent la refactorisation.
- Les modèles ActiveRecord peuvent contenir des associations et des constantes, mais rien d'autre. Cela signifie donc pas de rappels (utilisez des objets de service et ajoutez-y les rappels) et pas de validations (utilisez des objets Form pour inclure la dénomination et les validations du modèle).
- Conservez les contrôleurs sous forme de couches minces et appelez toujours les objets de service. Certains d'entre vous demanderaient pourquoi utiliser des contrôleurs puisque nous voulons continuer à appeler des objets de service pour contenir la logique ? Eh bien, les contrôleurs sont un bon endroit pour avoir le routage HTTP, l'analyse des paramètres, l'authentification, la négociation de contenu, l'appel du bon service ou de l'objet éditeur, la capture d'exception, le formatage de la réponse et le retour du bon code d'état HTTP.
- Les services doivent appeler des objets Query et ne doivent pas stocker d'état. Utilisez des méthodes d'instance, pas des méthodes de classe. Il devrait y avoir très peu de méthodes publiques conformes au SRP.
- Les requêtes doivent être effectuées dans des objets de requête. Les méthodes d'objet de requête doivent renvoyer un objet, un hachage ou un tableau, et non une association ActiveRecord.
- Évitez d'utiliser des aides et utilisez plutôt des décorateurs. Pourquoi? Un écueil courant avec les assistants Rails est qu'ils peuvent se transformer en un gros tas de fonctions non-OO, partageant toutes un espace de noms et se chevauchant. Mais bien pire, c'est qu'il n'y a pas de bon moyen d'utiliser n'importe quel type de polymorphisme avec les assistants Rails - fournissant différentes implémentations pour différents contextes ou types, remplaçant ou sous-classant les assistants. Je pense que les classes d'assistance Rails doivent généralement être utilisées pour les méthodes utilitaires, et non pour des cas d'utilisation spécifiques, tels que le formatage des attributs de modèle pour tout type de logique de présentation. Gardez-les légers et aérés.
- Évitez d'utiliser des soucis et utilisez plutôt des décorateurs/délégants. Pourquoi? Après tout, les préoccupations semblent être au cœur de Rails et peuvent assécher le code lorsqu'elles sont partagées entre plusieurs modèles. Néanmoins, le principal problème est que les préoccupations ne rendent pas l'objet modèle plus cohérent. Le code est simplement mieux organisé. En d'autres termes, il n'y a pas de réel changement dans l'API du modèle.
- Essayez d'extraire les objets de valeur des modèles pour garder votre code plus propre et pour regrouper les attributs associés.
- Passez toujours une variable d'instance par vue.
Refactoring
Avant de commencer, je veux discuter d'une chose. Lorsque vous démarrez le refactoring, vous finissez généralement par vous demander : "Est-ce vraiment un bon refactoring ?"
Si vous sentez que vous faites plus de séparation ou d'isolement entre les responsabilités (même si cela signifie ajouter plus de code et de nouveaux fichiers), c'est généralement une bonne chose. Après tout, découpler une application est une très bonne pratique et nous permet de réaliser plus facilement des tests unitaires appropriés.
Je ne discuterai pas de choses, comme le déplacement de la logique des contrôleurs vers les modèles, car je suppose que vous le faites déjà et que vous êtes à l'aise avec Rails (généralement Skinny Controller et le modèle FAT).
Dans l'intérêt de garder cet article serré, je ne discuterai pas des tests ici, mais cela ne signifie pas que vous ne devriez pas tester.
Au contraire, vous devriez toujours commencer par un test pour vous assurer que tout va bien avant d'aller de l'avant. C'est un must, surtout lors de la refactorisation.
Ensuite, nous pouvons implémenter des modifications et nous assurer que les tests réussissent tous pour les parties pertinentes du code.
Extraction d'objets de valeur
Tout d'abord, qu'est-ce qu'un objet de valeur ?
Martin Fowler explique :
L'objet de valeur est un petit objet, tel qu'un objet monétaire ou de plage de dates. Leur principale propriété est qu'ils suivent une sémantique de valeur plutôt qu'une sémantique de référence.
Parfois, vous pouvez rencontrer une situation où un concept mérite sa propre abstraction et dont l'égalité n'est pas basée sur la valeur, mais sur l'identité. Les exemples incluent la date, l'URI et le nom de chemin de Ruby. L'extraction vers un objet de valeur (ou un modèle de domaine) est très pratique.
Pourquoi s'embêter?
L'un des principaux avantages d'un objet Value est l'expressivité qu'il permet d'obtenir dans votre code. Votre code aura tendance à être beaucoup plus clair, ou du moins il peut l'être si vous avez de bonnes pratiques de nommage. Étant donné que l'objet de valeur est une abstraction, il conduit à un code plus propre et à moins d'erreurs.
Une autre grande victoire est l'immuabilité. L'immuabilité des objets est très importante. Lorsque nous stockons certains ensembles de données, qui pourraient être utilisés dans un objet de valeur, je ne souhaite généralement pas que ces données soient manipulées.
Quand est-ce utile ?
Il n'y a pas de réponse unique et unique. Faites ce qui est le mieux pour vous et ce qui a du sens dans une situation donnée.
Au-delà de cela, cependant, j'utilise certaines lignes directrices pour m'aider à prendre cette décision.
Si vous pensez qu'un groupe de méthodes est lié, avec les objets Value, ils sont plus expressifs. Cette expressivité signifie qu'un objet Value doit représenter un ensemble distinct de données, que votre développeur moyen peut déduire simplement en regardant le nom de l'objet.
Comment est-ce fait?
Les objets de valeur doivent suivre certaines règles de base :
- Les objets de valeur doivent avoir plusieurs attributs.
- Les attributs doivent être immuables tout au long du cycle de vie de l'objet.
- L'égalité est déterminée par les attributs de l'objet.
Dans notre exemple, je vais créer un objet de valeur EntryStatus
pour extraire les attributs Entry#status_weather
et Entry#status_landform
de leur propre classe, qui ressemble à ceci :
- Echantillon essentiel
Remarque : il s'agit simplement d'un objet PORO (Plain Old Ruby Object) qui n'hérite pas de ActiveRecord::Base
. Nous avons défini des méthodes de lecture pour nos attributs et les affectons lors de l'initialisation. Nous avons également utilisé un mixin comparable pour comparer des objets en utilisant la méthode (<=>).
Nous pouvons modifier le modèle d' Entry
pour utiliser l'objet de valeur que nous avons créé :
- Echantillon essentiel
Nous pouvons également modifier la méthode EntryController#create
pour utiliser le nouvel objet de valeur en conséquence :
- Echantillon essentiel
Extraire les objets de service
Qu'est-ce qu'un objet Service ?
Le travail d'un objet Service consiste à contenir le code d'un élément particulier de la logique métier. À la différence du style « gros modèle » , où un petit nombre d'objets contient de très nombreuses méthodes pour toute la logique nécessaire, l'utilisation d'objets Service aboutit à de nombreuses classes, chacune ayant un but unique.

Pourquoi? Quels sont les bénéfices?
- Découplage. Les objets de service vous aident à mieux isoler les objets.
- Visibilité. Les objets de service (si bien nommés) montrent ce que fait une application. Je peux simplement jeter un coup d'œil sur le répertoire des services pour voir les fonctionnalités fournies par une application.
- Modèles et contrôleurs de nettoyage. Les contrôleurs transforment la demande (params, session, cookies) en arguments, les transmettent au service et redirigent ou rendent en fonction de la réponse du service. Alors que les modèles ne traitent que des associations et de la persistance. L'extraction du code des contrôleurs/modèles vers les objets de service prendrait en charge le SRP et rendrait le code plus découplé. La responsabilité du modèle serait alors uniquement de gérer les associations et la sauvegarde/suppression d'enregistrements, tandis que l'objet de service aurait une responsabilité unique (SRP). Cela conduit à une meilleure conception et à de meilleurs tests unitaires.
- Séchez et adoptez le changement. Je garde les objets de service aussi simples et petits que possible. Je compose des objets de service avec d'autres objets de service, et je les réutilise.
- Nettoyez et accélérez votre suite de tests. Les services sont faciles et rapides à tester car ce sont de petits objets Ruby avec un point d'entrée (la méthode d'appel). Les services complexes sont composés d'autres services, vous pouvez donc facilement fractionner vos tests. De plus, l'utilisation d'objets de service facilite la simulation/le remplacement d'objets liés sans avoir à charger l'ensemble de l'environnement rails.
- Appelable de n'importe où. Les objets de service sont susceptibles d'être appelés depuis les contrôleurs ainsi que d'autres objets de service, les tâches DelayedJob / Rescue / Sidekiq, les tâches Rake, la console, etc.
D'autre part, rien n'est jamais parfait. Un inconvénient des objets Service est qu'ils peuvent être excessifs pour une action très simple. Dans de tels cas, vous pourriez très bien finir par compliquer, plutôt que simplifier, votre code.
Quand devez-vous extraire les objets de service ?
Il n'y a pas de règle absolue ici non plus.
Normalement, les objets Service conviennent mieux aux systèmes moyens à grands ; ceux avec une quantité décente de logique au-delà des opérations CRUD standard.
Ainsi, chaque fois que vous pensez qu'un extrait de code pourrait ne pas appartenir au répertoire dans lequel vous alliez l'ajouter, c'est probablement une bonne idée de reconsidérer et de voir s'il doit plutôt aller dans un objet de service.
Voici quelques indicateurs indiquant quand utiliser les objets Service :
- L'action est complexe.
- L'action s'étend sur plusieurs modèles.
- L'action interagit avec un service externe.
- L'action n'est pas une préoccupation centrale du modèle sous-jacent.
- Il existe plusieurs façons d'effectuer l'action.
Comment devez-vous concevoir des objets de service ?
La conception de la classe pour un objet de service est relativement simple, car vous n'avez pas besoin de gemmes spéciales, vous n'avez pas à apprendre un nouveau DSL et vous pouvez plus ou moins compter sur les compétences en conception de logiciels que vous possédez déjà.
J'utilise généralement les directives et conventions suivantes pour concevoir l'objet de service :
- Ne stocke pas l'état de l'objet.
- Utilisez des méthodes d'instance, pas des méthodes de classe.
- Il devrait y avoir très peu de méthodes publiques (de préférence une pour prendre en charge SRP.
- Les méthodes doivent renvoyer des objets de résultat riches et non des booléens.
- Les services se trouvent dans le répertoire
app/services
. Je vous encourage à utiliser des sous-répertoires pour les domaines lourds en logique métier. Par exemple, le fichierapp/services/report/generate_weekly.rb
définiraReport::GenerateWeekly
tandis queapp/services/report/publish_monthly.rb
définiraReport::PublishMonthly
. - Les services commencent par un verbe (et ne se terminent pas par Service) :
ApproveTransaction
,SendTestNewsletter
,ImportUsersFromCsv
. - Les services répondent à la méthode d'appel. J'ai trouvé que l'utilisation d'un autre verbe le rend un peu redondant : ApproveTransaction.approve() ne se lit pas bien. En outre, la méthode call est la méthode de facto pour les objets lambda, procs et method.
Si vous regardez StatisticsController#index
, vous remarquerez un groupe de méthodes ( weeks_to_date_from
, weeks_to_date_to
, avg_distance
, etc.) couplées au contrôleur. Ce n'est pas vraiment bon. Considérez les ramifications si vous souhaitez générer le rapport hebdomadaire en dehors statistics_controller
.
Dans notre cas, créons Report::GenerateWeekly
et extrayons la logique de rapport de StatisticsController
:
- Echantillon essentiel
Donc StatisticsController#index
a maintenant l'air plus propre :
- Echantillon essentiel
En appliquant le modèle d'objet Service, nous regroupons le code autour d'une action spécifique et complexe et favorisons la création de méthodes plus petites et plus claires.
Travail à la maison : pensez à utiliser l'objet Value pour le WeeklyReport
au lieu de Struct
.
Extraire les objets de requête des contrôleurs
Qu'est-ce qu'un objet Query ?
Un objet Query est un PORO qui représente une requête de base de données. Il peut être réutilisé à différents endroits de l'application tout en masquant la logique de requête. Il fournit également une bonne unité isolée à tester.
Vous devez extraire les requêtes SQL/NoSQL complexes dans leur propre classe.
Chaque objet Query est chargé de renvoyer un ensemble de résultats basé sur les critères/règles métier.
Dans cet exemple, nous n'avons pas de requêtes complexes, donc l'utilisation de l'objet Query ne sera pas efficace. Cependant, à des fins de démonstration, extrayons la requête dans Report::GenerateWeekly#call
et créons generate_entries_query.rb
:
- Echantillon essentiel
Et dans Report::GenerateWeekly#call
, remplaçons :
def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( ... ) end end
avec:
def call weekly_grouped_entries = GroupEntriesQuery.new(@user).call weekly_grouped_entries.map do |week, entries| WeeklyReport.new( ... ) end end
Le modèle d'objet de requête permet de garder la logique de votre modèle strictement liée au comportement d'une classe, tout en gardant vos contrôleurs maigres. Puisqu'ils ne sont rien de plus que de simples anciennes classes Ruby, les objets de requête n'ont pas besoin d'hériter de ActiveRecord::Base
, et ne devraient être responsables que de l'exécution des requêtes.
Extraire une entrée de création vers un objet de service
Maintenant, extrayons la logique de création d'une nouvelle entrée dans un nouvel objet de service. Utilisons la convention et créons CreateEntry
:
- Echantillon essentiel
Et maintenant, notre EntriesController#create
est le suivant :
def create begin CreateEntry.new(current_user, entry_params).call flash[:notice] = 'Entry was successfully created.' rescue Exception => e flash[:error] = e.message end redirect_to root_path end
Déplacer les validations dans un objet de formulaire
Maintenant, ici, les choses commencent à devenir plus intéressantes.
Rappelez-vous dans nos directives, nous avons convenu que nous voulions que les modèles contiennent des associations et des constantes, mais rien d'autre (pas de validations et pas de rappels). Commençons donc par supprimer les rappels et utilisons plutôt un objet Form.
Un objet Form est un objet PORO (Plain Old Ruby Object). Il prend le relais de l'objet contrôleur/service partout où il doit communiquer avec la base de données.
Pourquoi utiliser des objets Form ?
Lorsque vous cherchez à refactoriser votre application, c'est toujours une bonne idée de garder à l'esprit le principe de responsabilité unique (SRP).
SRP vous aide à prendre de meilleures décisions de conception concernant ce dont une classe devrait être responsable.
Votre modèle de table de base de données (un modèle ActiveRecord dans le contexte de Rails), par exemple, représente un seul enregistrement de base de données dans le code, il n'y a donc aucune raison pour qu'il soit concerné par tout ce que fait votre utilisateur.
C'est là qu'interviennent les objets Form.
Un objet Form est chargé de représenter un formulaire dans votre application. Ainsi, chaque champ d'entrée peut être traité comme un attribut dans la classe. Il peut valider que ces attributs répondent à certaines règles de validation, et il peut transmettre les données "propres" là où elles doivent aller (par exemple, vos modèles de base de données ou peut-être votre générateur de requêtes de recherche).
Quand utiliser un objet Form ?
- Lorsque vous souhaitez extraire les validations des modèles Rails.
- Lorsque plusieurs modèles peuvent être mis à jour par une seule soumission de formulaire, vous pouvez créer un objet Form.
Cela vous permet de mettre toute la logique du formulaire (conventions de dénomination, validations, etc.) en un seul endroit.
Comment créer un objet Form ?
- Créez une classe Ruby ordinaire.
- Inclure
ActiveModel::Model
(dans Rails 3, vous devez inclure Naming, Conversion et Validations à la place) - Commencez à utiliser votre nouvelle classe de formulaire comme s'il s'agissait d'un modèle ActiveRecord normal, la plus grande différence étant que vous ne pouvez pas conserver les données stockées dans cet objet.
Veuillez noter que vous pouvez utiliser la gemme de réforme, mais en nous en tenant aux PORO, nous allons créer entry_form.rb
qui ressemble à ceci :
- Echantillon essentiel
Et nous allons modifier CreateEntry
pour commencer à utiliser l'objet Form EntryForm
:
class CreateEntry ...... ...... def call @entry_form = ::EntryForm.new(@params) if @entry_form.valid? .... else .... end end end
Remarque : Certains d'entre vous diraient qu'il n'est pas nécessaire d'accéder à l'objet Form à partir de l'objet Service et que nous pouvons simplement appeler l'objet Form directement à partir du contrôleur, ce qui est un argument valide. Cependant, je préférerais avoir un flux clair, et c'est pourquoi j'appelle toujours l'objet Form à partir de l'objet Service.
Déplacer les rappels vers l'objet de service
Comme nous l'avons convenu précédemment, nous ne voulons pas que nos modèles contiennent des validations et des rappels. Nous avons extrait les validations à l'aide d'objets Form. Mais nous utilisons toujours des rappels ( after_create
dans le modèle Entry
compare_speed_and_notify_user
).
Pourquoi voulons-nous supprimer les rappels des modèles ?
Les développeurs Rails commencent généralement à remarquer des problèmes de rappel pendant les tests. Si vous ne testez pas vos modèles ActiveRecord, vous commencerez à remarquer la douleur plus tard à mesure que votre application se développe et que plus de logique est nécessaire pour appeler ou éviter le rappel.
Les rappels after_*
sont principalement utilisés en relation avec l'enregistrement ou la persistance de l'objet.
Une fois l'objet enregistré, le but (c'est-à-dire la responsabilité) de l'objet a été rempli. Donc, si nous voyons toujours des rappels être invoqués après que l'objet a été enregistré, ce que nous voyons probablement, ce sont des rappels sortant de la zone de responsabilité de l'objet, et c'est là que nous rencontrons des problèmes.
Dans notre cas, nous envoyons un SMS à l'utilisateur après avoir enregistré une entrée, qui n'est pas vraiment liée au domaine de l'entrée.
Un moyen simple de résoudre le problème consiste à déplacer le rappel vers l'objet de service associé. Après tout, l'envoi d'un SMS pour l'utilisateur final est lié à l'objet de service CreateEntry
et non au modèle Entry lui-même.
Ce faisant, nous n'avons plus à remplacer la méthode compare_speed_and_notify_user
dans nos tests. Nous avons simplifié la création d'une entrée sans nécessiter l'envoi d'un SMS, et nous suivons une bonne conception orientée objet en nous assurant que nos classes ont une responsabilité unique (SRP).
Alors maintenant, notre CreateEntry
ressemble à :
- Echantillon essentiel
Utilisez des décorateurs au lieu d'assistants
Bien que nous puissions facilement utiliser la collection Draper de modèles de vue et de décorateurs, je m'en tiendrai aux PORO pour les besoins de cet article, comme je l'ai fait jusqu'à présent.
Ce dont j'ai besoin, c'est d'une classe qui appellera des méthodes sur l'objet décoré.
Je peux utiliser method_missing
pour implémenter cela, mais j'utiliserai la bibliothèque standard de Ruby SimpleDelegator
.
Le code suivant montre comment utiliser SimpleDelegator
pour implémenter notre décorateur de base :
% app/decorators/base_decorator.rb require 'delegate' class BaseDecorator < SimpleDelegator def initialize(base, view_context) super(base) @object = base @view_context = view_context end private def self.decorates(name) define_method(name) do @object end end def _h @view_context end end
Alors pourquoi la méthode _h
?
Cette méthode agit comme un proxy pour le contexte de vue. Par défaut, le contexte de vue est une instance d'une classe de vue, la classe de vue par défaut étant ActionView::Base
. Vous pouvez accéder aux assistants d'affichage comme suit :
_h.content_tag :div, 'my-div', class: 'my-class'
Pour le rendre plus pratique, nous ajoutons une méthode de decorate
à ApplicationHelper
:
module ApplicationHelper # ..... def decorate(object, klass = nil) klass ||= "#{object.class}Decorator".constantize decorator = klass.new(object, self) yield decorator if block_given? decorator end # ..... end
Maintenant, nous pouvons déplacer les assistants EntriesHelper
vers les décorateurs :
# app/decorators/entry_decorator.rb class EntryDecorator < BaseDecorator decorates :entry def readable_time_period mins = entry.time_period return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60 Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe end def readable_speed "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe end end
Et nous pouvons utiliser readable_time_period
et readable_speed
comme ceci :
# app/views/entries/_entry.html.erb - <td><%= readable_speed(entry) %> </td> + <td><%= decorate(entry).readable_speed %> </td>
- <td><%= readable_time_period(entry) %></td> + <td><%= decorate(entry).readable_time_period %></td>
Structure après refactorisation
Nous nous sommes retrouvés avec plus de fichiers, mais ce n'est pas nécessairement une mauvaise chose (et rappelez-vous que, dès le début, nous avons reconnu que cet exemple était uniquement à des fins de démonstration et n'était pas nécessairement un bon cas d'utilisation pour le refactoring) :
app ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── decorators │ ├── base_decorator.rb │ └── entry_decorator.rb ├── forms │ └── entry_form.rb ├── helpers │ └── application_helper.rb ├── mailers ├── models │ ├── entry.rb │ ├── entry_status.rb │ └── user.rb ├── queries │ └── group_entries_query.rb ├── services │ ├── create_entry.rb │ └── report │ └── generate_weekly.rb └── views ├── devise │ └── .. ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Conclusion
Même si nous nous sommes concentrés sur Rails dans ce billet de blog, RoR n'est pas une dépendance des objets de service décrits et des autres PORO. Vous pouvez utiliser cette approche avec n'importe quel framework Web, application mobile ou console.
En utilisant MVC comme architecture des applications Web, tout reste couplé et vous ralentit car la plupart des modifications ont un impact sur d'autres parties de l'application. En outre, cela vous oblige à réfléchir à l'endroit où mettre une logique métier - doit-elle entrer dans le modèle, le contrôleur ou la vue ?
En utilisant de simples PORO, nous avons déplacé la logique métier vers des modèles ou des services qui n'héritent pas d' ActiveRecord
, ce qui est déjà une grande victoire, sans oublier que nous avons un code plus propre, qui prend en charge le SRP et des tests unitaires plus rapides.
L'architecture propre vise à placer les cas d'utilisation au centre/en haut de votre structure, afin que vous puissiez facilement voir ce que fait votre application. Il facilite également l'adoption des changements car il est beaucoup plus modulaire et isolé.
J'espère avoir démontré comment l'utilisation de Plain Old Ruby Objects et d'autres abstractions découple les problèmes, simplifie les tests et aide à produire un code propre et maintenable.