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.

Tags : Rails Design Pattern

Publié le 18 septembre 2024

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
Page précédente Page précédente Page suivante Page suivante