Les Form Objects, l'abstraction manquante dans Rails
Cessez de mettre le bon code au mauvais endroit !
Il est facile de s’emballer en utilisant le scaffolding de Rails, mais dans certains cas un simple objet Ruby (un PORO) suffit amplement. Et les Form Objects sont particulièrement efficaces pour alléger les modèles et les contrôleurs, tout en regroupant des fonctionnalités qui vont ensemble dans des objets faciles à tester.
Qu’est-ce qu’un Form Object ?
Les Form Objects (objets de formulaire) tirent leur nom de leur principal cas d’utilisation : être le pendant, côté backend, d’un formulaire côté frontend.
Il peut s’agir d’un mécanisme de filtrage utilisant des requêtes complexes, ou d’un formulaire d’inscription nécessitant des validations spécifiques et créant plusieurs enregistrements par exemple.
Les situations suivantes indiquent typiquement qu’une abstraction semble manquer :
- un modèle, ses validations ou ses callbacks deviennent incontrôlables,
- une action de contrôleur interagit avec plusieurs modèles,
- des requêtes, des scopes ou un groupe de scopes associés se regroupent dans un modèle,
- les tests deviennent difficiles à mettre en place, car il faut instancier ou sauvegarder plusieurs objets.
Bien que ce ne soient pas vraiment des péchés capitaux, ces soucis révèlent que nous devons intervenir pour garder le code sous contrôle, et éviter une détérioration progressive de la qualité du code du projet.
Les objets de formulaire encapsulent la logique et les validations liées aux formulaires. Ils agissent généralement comme intermédiaire entre la vue et un ou plusieurs modèles, gérant le traitement des données et la validation avant de les persister éventuellement dans la base de données.
Mise en place des Form Objects dans un projet Rails
La convention est de placer les objets de formulaire dans un dossier app/forms
. Cela facilitera la compréhension pour les nouveaux développeurs, leur indiquant qu’il s’agit d’un type particulier de classes, et où en ajouter de nouveaux.
Grâce au chargement automatique de Rails, les classes de ce dossier seront automatiquement disponibles dans les contrôleurs et ailleurs.
Il est aussi conseillé de créer une classe de base ApplicationForm
, en d’en faire hériter tous les Form Objects de l’application, pour suivre les conventions de Rails.
Cette classe de base devrait vous permettre de gérer les attributs et l’état, d’ajouter des validations, de garder les changements atomiques, etc.
Vous pouvez soit utiliser une gem pour cela, soit la construire vous-même pour éviter d’ajouter une nouvelle dépendance dans le Gemfile.
Utilisation d’une gem
La catégorie Form Object du site Ruby Toolbox liste plusieurs gemmes sur ce créneau. Je n’ai pas utilisé Reform, Virtus ou Dry-validation, mais je les éviterais probablement puisqu’ils semblent peu maintenus, ils risquent donc de cesser de fonctionner lorsque la syntaxe de Ruby ou de Rails évoluera.
L’une des plus utilisées est ActiveType, de Makandra, mais je suis mal à l’aise de voir qu’ils écrasent la méthode attribute
d’ActiveRecord
.
Mon expérience personnelle avec YAAF
est plutôt positive, bien que j’aie dû l’augmenter avec des macros pour gérer les objets imbriqués — ce qui est nativement fourni par ActiveType
.
Ayant mis ces macros en place dans la classe de base ApplicationForm
, il m’a été facile de les configurer au cas par cas.
Si vous choisissez de ne pas utiliser de gemme, vous devrez faire en sorte de gérer les attributs, les validations et les callbacks, effectuer des changements atomiques et construire des scopes si nécessaire.
Gérer les attributs
Les objets de formulaire ont souvent besoin de valider des paramètres ou des attributs. Heureusement, Rails facilite cela, il suffit d’inclure ActiveModel::Model
.
Après avoir ajouté cette ligne dans une classe Ruby simple, déclarer des attributs qui peuvent être typés devient un jeu d’enfant, ainsi qu’ajouter des validations et des callbacks, le tout en utilisant la syntaxe familière de Rails.
Il est également considéré comme une bonne pratique d’ajouter une interface publique de type Rails (save/save!
, create/create!
, etc.) aux objets de formulaire. Les contrôleurs n’ont pas besoin de savoir si le modèle avec lequel ils interagissent est un objet de formulaire ou un modèle classique.
L’autre avantage d’implémenter une interface identique à ActiveRecord, c’est qu’il devient possible d’extraire un objet de formulaire à partir d’un modèle existant, sans avoir besoin de changer une seule ligne de code du contrôleur associé.
S’assurer que les changements sont atomiques
Une exigence fréquente quand un utilisateur s’inscrit sur une plateforme est de lui créer un enregistrement utilisateur et un compte associé, et d’envoyer un e-mail de bienvenue. Si la création de l’utilisateur ou du compte échoue, l’e-mail de bienvenue ne devrait pas être envoyé ou mis en file d’attente.
Rails fournit des blocs pour gérer les transactions, qui sont automatiquement annulés si une erreur est levée à l’intérieur du bloc.
Vous pouvez initier une transaction avec ActiveRecord::Base.transaction
, ou être plus spécifique : Foo.transaction do … end
.
Cette dernière syntaxe est plus lisible — et plus pérenne, maintenant que les applications Rails peuvent se connecter à plusieurs bases de données.
Attention, les jobs créés depuis une transaction étaient auparavant mis en file d’attente immédiatement, et pouvaient s’exécuter soit avant que la transaction soit validée, soit passer alors que la transaction était finalement annulée !
Requêtes complexes
Pour un objet de formulaire servant à filtrer des enregistrements, il est préférable de permettre le chaînage, la pagination, etc. Pour cela, il vaut mieux retourner relation (ActiveRecord::Relation
) au lieu d’un tableau d’objets.
Pour renvoyer une relation, le plus simple est de commencer par créer un scope
interne à l’objet de formulaire, en utilisant Foo.all
dans le cas d’un modèle appelé Foo
, et de l’augmenter dans chaque méthode (scope.where…
).
Vous pouvez également tirer parti du helper extending
de Rails pour ajouter des méthodes à un scope existant.
Démonstration
Maintenant que nous avons couvert les bases, appliquons le modèle d’objet de formulaire pour refactoriser et améliorer le code.
Exemple de code existant
Imaginez que le processus d’inscription mentionné plus tôt utilisait le code suivant :
# app/models/user.rb
class User < ApplicationRecord
attr_accessor :company_name
belongs_to :company
validates :email, presence: true
validates :password, presence: true, length: { minimum: 8 }
validates :company_name, presence: true, on: :create
before_create :set_company
after_create :send_slack_notification
def set_company
self.company ||= Company.find_or_create_by(name: company_name)
end
private
def send_slack_notification
SlackNotifier.self_congratulate
end
end
# app/controllers/users_controller.rb
def UsersController < ApplicationController
# POST /users
def create
@user = User.new(user_params)
if @user.save
UserMailer.with(user: @user).welcome.send_later
redirect_to dashboard_path
else
render :new
end
end
end
Voici deux des problèmes les plus saillants :
- La configuration du lien avec
company
pollue le modèle (ajout d’un accesseur, d’une validation, de callbacks et de méthodes privées), bien qu’elle ne soit nécessaire que lors de la création d’un utilisateur. - Deux actions différentes sont déclenchées lorsqu’un utilisateur s’inscrit : un e-mail de bienvenue et une notification Slack, chacune depuis un emplacement de code différent. Si un lot d’utilisateurs devait être importé depuis la ligne de commande ou un fichier, les notifications Slack arriveraient en masse, mais aucun des nouveaux utilisateurs ne recevrait d’e-mail de bienvenue.
Ajout de tests
Avant de refactoriser, d’ajouter des fonctionnalités ou de corriger des bugs, il faut nous assurer que le code est correctement couvert par des tests. Cela nous aidera également à nous assurer que nous comprenons le problème et que nous ajoutons juste assez de code pour effectuer la tâche que nous nous sommes fixés.
Comme nous allons déplacer la responsabilité d’envoyer des notifications vers l’objet de formulaire, nous n’aurons besoin que de tests unitaires. Pour nous assurer que le test n’exerce que l’objet qui nous intéresse, nous allons simuler la dépendance envers Slack Notifier. Idéalement, nous devrions également simuler la persistance de l’utilisateur, ce qui accélérerait considérablement l’exécution des tests, mais ce n’est pas le sujet de cet article, je le laisse donc de côté volontairement.
Rspec.describe UserRegistrationForm do
describe "#save" do
let(:params) do
{
user: {
email: Faker::Internet.email,
password: Faker::Alphanumeric.alphanumeric(number: 10)
},
company_name: Faker::Company.name
}
end
let!(:slack_notifier) { instance_double("SlackNotifier", self_congratulate: true) }
subject(:registration) { UserRegistrationForm.new(params) }
context "lorsque les paramètres contiennent des valeurs valides" do
it "retourne true" do
expect(registration.save).to eq true
end
it "met en file d'attente un e-mail de bienvenue" do
expect { registration.save }.to have_enqueued_mail(UserMailer, :welcome)
end
it "envoie une notification Slack" do
registration.save
expect(slack_notifier).to have_received(:self_congratulate)
end
end
context "lorsque les paramètres contiennent des valeurs invalides" do
let(:params) { {} }
it "retourne false" do
expect(registration.save).to eq false
end
it "ne met pas en file d'attente un e-mail de bienvenue" do
expect { registration.save }.not_to have_enqueued_mail(UserMailer, :welcome)
end
it "n'envoie pas de notification Slack" do
registration.save
expect(slack_notifier).not_to have_received(:self_congratulate)
end
end
end
end
Refactorisation en utilisant un objet de formulaire
Si vous n’avez jamais utilisé d’objet de formulaire, je recommande d’installer la gem YAAF pour éviter de réinventer la roue. Exécutez bundle add yaaf
, puis de créer une classe ApplicationForm
de base, héritant de YAAF::Form
.
En cas d’entrée invalide, la gem YAAF
récupère les erreurs des modèles sous-jacents, elles sont donc récupérables avec la méthode .errors
, prêtes à être affichées dans la vue.
Extrayons maintenant le code de création d’utilisateur dans un objet de formulaire. Nous devons d’abord lui trouver un nom, et UserRegistrationForm
semble à la fois explicite et adapté.
Ensuite, il faut déplacer le code spécifique à l’inscription depuis le modèle et le contrôleur vers cette nouvelle classe.
Voici à quoi nous pourrions arriver :
# app/models/user.rb
class User < ApplicationRecord
belongs_to :company
validates :email, presence: true
validates :password, presence: true, length: { minimum: 8 }
end
# app/controllers/users_controller.rb
def UsersController < ApplicationController
# POST /users
def create
@user = UserRegistrationForm.new(user_params)
if @user.save
redirect_to dashboard_path
else
render :new
end
end
private
def user_params
params.require(:user).permit(:email, :password, :company_name)
end
end
# app/forms/application_form.rb
def ApplicationForm < YAAF::Form
# Ajouté pour la cohérence
def new_record?
true
end
end
# app/forms/user_registration_form.rb
def UserRegistrationForm < ApplicationForm
attr_accessor :user_attributes
attr_accessor :company_name
before_validation :set_company
after_commit :send_notifications
def initialize(attributes)
company_name = attributes.delete(:company_name)
super(user_attributes: attributes)
@models = [user]
end
private
def set_company
user.company = Company.find_or_create_by(name: company_name)
end
def send_notifications
UserMailer.with(user:).welcome.send_later
SlackNotifier.self_congratulate
end
end
Conclusion
Bien qu’il puisse être tentant de suivre la convention de routage RESTful et de créer des ressources pour chaque URL, certains concepts ne correspondent pas vraiment à un modèle ou une table de base de données. Et même si Rails n’encourage pas activement l’utilisation des objets de formulaire, il est facile de tirer parti de leurs pouvoirs en utilisant des gemmes ou des helpers natifs.
Les objets de formulaire sont puissants car il est facile de les extraire sans modifier l’interface, tout en améliorant grandement la lisibilité et l’extensibilité. Il est également plus facile de tester leur comportement sans toucher à la base de données, réduisant ainsi le temps et le coût d’exécution de la suite de tests.
Si vous êtes tentés de créer plusieurs enregistrements à l’intérieur d’une action, que vous avez besoin d’effectuer une validation spécifique dans un contexte particulier, ou d’implémenter des filtres complexes en lien avec un formulaire, il est probablement pertinent d’utiliser un Form Object !
En bonus : ajouter de macros à YAAF
Dans un projet utilisant des objets de formulaire, j’ai trouvé pratique de gérer les modèles imbriqués en utilisant les macros de Rails. Je les ai implémentées en complément de YAAF en injectant un module dans la classe, tout comme le fait Rails. Voici le code que j’ai utilisé :
class ApplicationForm < YAAF::Form
class << self
# This is useful for I18n, dom_id, etc
def to_model
name.sub("Form", "").safe_constantize
end
# This allows adding Rails-like macros (has_one, accepts_nested_attributes_for…)
def association_macros
@association_macros ||= begin
mod = const_set(:GeneratedAssociationMacros, Module.new)
include mod
mod
end
end
# This adds [model]= , [model]_id, and [model]_id= methods, just like Rails does
def has_one(model, **options)
klass = options.fetch(:class, model.to_s.classify)
association_macros.class_eval <<-CODE, __FILE__, __LINE__ + 1
attr_writer :#{model}
delegate :id, to: :#{model}, prefix: true, allow_nil: true
def #{model}
@#{model} ||= #{klass}.new
end
def #{model}_id=(id)
@#{model} = #{klass}.find(id) if id.present?
end
CODE
end
# This allows setting attributes for a nested has_one model
def accepts_nested_attributes_for(model, **options)
klass = options.delete(:class) || model.to_s.classify
association_macros.class_eval <<-CODE, __FILE__, __LINE__ + 1
attr_reader :#{model}_attributes
def #{model}_attributes=(params = {})
@#{model} = #{klass}.new(params) if params.present?
end
CODE
end
end
end
Et voici les tests associés à cette classe de base, pour vérifier que la génération des macros fonctionne bien.
# spec/forms/application_form_spec.rb
RSpec.describe ApplicationForm do
describe ".association_macros" do
it "includes the GeneratedAssociationMacros module" do
described_class.association_macros
expect(described_class.ancestors.second).to eq ApplicationForm::GeneratedAssociationMacros
end
end
describe ".has_one" do
let(:dummy) {
class DummyAssociation; end
class Dummy < ApplicationForm
has_one :dummy_association
end
Dummy
}
[:dummy_association, :dummy_association=, :dummy_association_id, :dummy_association_id=].each do |method|
it "adds the #{method} method" do
expect(dummy.instance_methods).to include(method)
end
end
end
describe ".accepts_nested_attributes_for" do
let(:dummy) {
class DummyAssociation; end
class Dummy < ApplicationForm
accepts_nested_attributes_for :dummy_association
end
Dummy
}
[:dummy_association_attributes, :dummy_association_attributes=].each do |method|
it "adds the #{method} method" do
expect(dummy.instance_methods).to include(method)
end
end
end
end