Évaluons votre projet

Rails 6.1 et ses nouveautés notables

Publié le 12 février 2021 par Nicolas Cavigneaux

Cet article est publié sous licence CC BY-NC-SA

En décembre 2020, Rails 6.1 a été publié. Cette nouvelle version propose quelques nouveautés très intéressantes et qui selon moi font avancer Rails dans le bon sens.

Nous allons passer en revue quatre de ces nouveautés qui ont retenu mon attention. Nous allons présenter :

  • le chargement strict des associations
  • les types délégués
  • les objets d’erreur
  • la gestion des dépréciations stricte

Chargement strict des associations

Si j’ai décidé de parler de ce point en premier c’est parce qu’il me semble être de loin l’avancée la plus importante.

Depuis aussi loin que Rails existe il a toujours été possible d’accéder, sans cérémonie, aux associations d’un objet donné.

Disons que vous ayez des utilisateurs qui ont eux même des billets de blog associés. Si vous souhaitez lister l’ensemble des auteurs suivi de leurs billets respectifs vous pouvez faire la chose suivante :

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.all
  end
end

Puis dans la vue associée à cette action :

<!-- app/views/users/index.html.erb` -->
<% @users.each do |user| %>
<h2><%= user.full_name %></h2>
<ul>
  <% user.posts.each do |post| %>
  <li><%= post.title %></li>
  <% end %>
</ul>
<% end %>

Ce code est fonctionnel, vous aurez bien le résultat attendu soit la liste des utilisateurs avec leurs billets respectifs.

C’est donc tout ce qu’il y a de plus simple à mettre en œuvre. Alors que peut-on bien apporter comme amélioration à un code si limpide et facile à écrire ?

On peut apporter un gain de performance ! En écrivant le code ci-dessus vous venez d’introduire une requête N+1. Chaque fois qu’un utilisateur chargera cette page une requête SQL sera exécutée pour récupérer la liste des utilisateurs. Puis à l’affichage de chaque utilisateur, Rails effectuera une requête SQL supplémentaire pour récupérer la liste des billets de l’utilisateur en cours d’itération.

Donc si on liste ici 100 utilisateurs, Rails va, sans rien nous demander, lancer 100 requêtes SQL supplémentaire.

C’est un gouffre à performance. Il est tout à fait possible en SQL, et via ActiveRecord, de récupérer l’ensemble de ces informations en seulement 2 requêtes. Peu importe qu’on souhaite lister 4 utilisateurs, 100 ou 10 000.

Avant Rails 6.1

Étant bien évidemment au fait de ce souci, l’équipe de Rails a mis à disposition une méthode qu’on peut appeler sur nos relations pour indiquer à ActiveRecord de se comporter intelligemment et d’optimiser ce genre de requête :

class UsersController < ApplicationController
  def index
    @users = User.includes(:posts).all
  end
end

Dès lors, plus de problème de requêtes N+1, ActiveRecord prendra soin de faire une requête pour récupérer les utilisateurs puis une seule autre pour récupérer l’ensemble des billets associés à ces utilisateurs et d’en faire le mapping.

On peut évidemment inclure plus d’une association ou même le faire en cascade :

User.includes(:friends, posts: [:comments]).all

On est donc sauf, on a l’outillage pour faire les choses bien ! Oui mais… parce qu’il y a un « mais ». Nous sommes des êtres humains et nous faisons des erreurs, des oublis…

Si quelqu’un dans l’équipe oubli de gérer ce genre de cas et que le code part en production, vous vous retrouvez avec des N+1. Et croyez-moi, ça y restera jusqu’à ce que quelqu’un s’en rende compte par hasard, que le client se plaigne de l’extrême lenteur de chargement de la page X ou que vous mettiez en place un outil dont le but est de repérer les N+1.

Depuis Rails 6.1

Fort heureusement, Eileen M. Uchitelle de chez GitHub a eu la bonne idée de proposer une nouvelle fonctionnalité pour s’assurer de ne plus louper d’éventuelles N+1 introduites par une boucle dans une vue au détour d’une demande fonctionnelle anodine.

Le principe de Strict Loading Associations permet de s’assurer que toutes les associations sont pré-chargées et donc éviter l’apparition de N+1.

On peut maintenant préciser sur une relation qu’on souhaite avoir du chargement strict et lever une exception en cas de tentative de lazy loading. On pourra par exemple faire la chose suivante :

user = User.strict_loading.first
user.posts

# => ActiveRecord::StrictLoadingViolationError:
#    User is marked as strict_loading and AuditLog cannot be lazily loaded.

Dès que nous allons essayer d’accéder à une association qui n’est pas pré-chargée (à l’aide de includes), une exception est levée ce qui facilite sa découverte lors du développement ou encore dans les tests.

C’est déjà un pas en avant ! Pour autant il ne faut pas oublier d’appeler strict_loading sur notre relation avant de l’utiliser sans quoi on pourra toujours charger les relations à la volée.

Une première solution consiste à créer un scope qui force le strict loading sur une association donnée :

class User < ApplicationRecord
  has_many :strict_loading_posts, -> { strict_loading }, class_name: "Post"
end

C’est mieux mais ça peut vite devenir contraignant et répétitif si on a beaucoup de relations. Mais une fois de plus la communauté vient à notre rescousse grâce à Kevin Newton de chez Shopify qui a introduit une option sur la déclaration des associations pour faciliter ce travail :

class User < ApplicationRecord
  has_many :posts, strict_loading: true
end

De mieux en mieux ! Mais je vous vois venir, développeurs fainéants que vous êtes ! Vous voulez que tout ça soit automatique, sur toutes vos associations ?

Eh bien vous avez été entendus ! Voici comment faire :

class User < ApplicationRecord
  self.strict_loading_by_default = true

  has_many :posts
end

Maintenant toutes les associations du modèle User seront soumises au strict loading.

Et je vois venir les plus vicieux d’entre vous, non il n’y a pas (encore) d’option globale pour faire en sorte que ce soit le comportement de tous les modèles.

Mais deux solutions s’offrent à vous. La première, proposer un patch et devenir core contributor Rails ! Sinon vous pouvez simplement définir le strict loading directement dans ApplicationRecord. De ce fait, tous les modèles qui en héritent utiliseront le strict loading :

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  self.strict_loading_by_default = true
end

Les types délégués

Avant Rails 6.1

Jusqu’à maintenant quand on souhaitait agréger plusieurs modèles autour d’une même table en base de donnée et d’un même modèle de base parce qu’une grande majorité des données et comportements étaient communs, on avait tendance à se tourner vers le STI.

class Company < ApplicationRecord; end
class Firm < Company; end
class Client < Company; end

Ici toutes les informations, qu’elles soient créées à travers le modèle Company, Firm ou Client seront stockées dans la table companies avec une colonne type qui permettra de savoir à quel modèle appartient l’enregistrement.

Company permet d’accéder à l’ensemble des données sans distinction de type.

Cette solution peut être très pratique mais est, à mon sens, à réserver à des cas bien maîtrisés pour lesquels on sait que les évolutions seront contenues et majoritairement transverses au modèle de base du STI.

Si vos modèles « enfants » vivent tous leur vie et commencent tous à implémenter des comportements spécifiques avec tout un tas de colonnes spécifiques, votre code et votre table vont vite devenir brouillons.

Alors de deux choses l’une : soit vous revoyez toute votre architecture ainsi que l’essence même du mantra DRY, soit…

Depuis Rails 6.1

C’est pour proposer une alternative plus évolutive et découplée que DHH a ajouté les types délégués.

Les types délégués vont nous permettre de représenter une hiérarchie de classes avec un parent commun qui possède sa propre table. Chaque enfant possédera sa table dédiée pour y stocker ses attributs spécifiques.

Voyons comment mettre en place le modèle parent :

class Company < ApplicationRecord
  delegated_type :structure, types: %w(Firm Client)

  # Code commun à tous
  # Éventuellement des délégations (ie: delegates :owner, to: :companyable)
end

Cette classe s’attend à trouver une table companies dans laquelle il y aura, entre autres, les colonnes structure_type et structure_id. Pour ceux qui ont suivi, c’est tout simplement une association polymorphique.

Cette classe définie ensuite les enfants qui vont lui être associés, ici Firm et Client.

Il ne reste plus qu’à déclarer nos modèles enfant et les rattacher à leur parent :

class Firm < ApplicationRecord
  has_one :company, as: :structure, touch: true

  def owner
    # une façon de récupérer l'info
  end
end

class Client < ApplicationRecord
  has_one :company, as: :structure, touch: true

  def owner
    # une autre façon
  end
end

On peut désormais se servir de nos classes pour gérer notre hiérarchie et déléguer les comportements spécifiques à la classe appropriée.

On crée d’abord une entrée :

Company.create structure: Firm.new(employees_count: 200), name: "Foo Bar Corp"
Company.firms # Relation retournant toutes les entrées de type "firm"
Company.first.firm? # Est-ce une instance de Firm ?

Vos différents modèles sont donc des modèles à part entière, indépendants, avec leurs attributs propres, leur table dédiée mais qui s’inscrivent dans une hiérarchie avec un parent qui regroupe les informations et fonctionnalités communes.

Les objets d’erreur

Lorsqu’un objet ActiveRecord est soumis à validation, son attribut errors est rempli si nécessaire, avec une structure certes normée mais ne bénéficiant d’aucun traitement particulier nous permettant de le manipuler facilement.

C’est pourtant une information avec laquelle on interagit très souvent et que nous devons manipuler pour l’affichage, la structuration des réponses JSON, les vérifications annexes…

Rails 6.1 introduit la notion de error object qui met à disposition une interface qui consiste à utiliser un objet dédié pour chaque entrée du tableau d’erreurs.

Cette nouvelle interface facilite la manipulation et les interactions avec les erreurs remontées par les modèles.

Une erreur peut être instanciée de la façon suivante :

error = ActiveModel::Error.new(user, :name, :too_short, count: 5)

Puis être requêtée :

error.details
# => { error: :too_short, count: 5 }
error.full_message
# => "Name is too short (minimum is 5 characters)"
error.match?(:name, :too_short)
# => true

On peut également requêter et manipuler la liste des erreurs :

model.errors.where(:name, :foo, bar: 3).first
model.errors.delete(:name, :too_powerful, level: 9000)

model.errors.added?(:name, :too_powerful, level: 9000) # false
model.errors.add(:name, :too_powerful, level: 9000)
model.errors.added?(:name, :too_powerful) # true

model.errors.details
# => {:name=>[{error: :too_short, count: 5}, {error: :cant_be_blank}]}

errors.messages_for(:name)
# => ["is too short (minimum is 5 characters)", "can't be blank"]

Il est donc maintenant un peu plus facile d’obtenir des informations concernant une erreur.

S’affranchir des dépréciations

Si vous aimez vous assurer que votre code n’introduit pas d’appels de méthodes dépréciées dans votre application ou simplement que vous préparez une migration vers une version plus récente de Rails. La fonctionnalité suivante vous facilitera grandement la tâche.

Jusqu’à maintenant les dépréciations étaient loguées, on peut maintenant lever une exception dès qu’une méthode dépréciée est utilisée dans notre code.

Cette fonctionnalité est paramétrable de manière flexible, on pourra donc faire en sorte d’avoir un comportement différent en mode développement et en mode production. On pourra aussi ne l’activer que pour des éléments ciblés.

Configuration

Pour activer globalement la levée d’exception lorsqu’une méthode dépréciée est rencontrée, il suffira d’utiliser :

ActiveSupport::Deprecation.disallowed_warnings = :all

Si vous voulez lever des exceptions uniquement en développement et test mais simplement loguer en production, on pourra utiliser :

if Rails.env.production?
  ActiveSupport::Deprecation.disallowed_behavior = [:log]
else
  ActiveSupport::Deprecation.disallowed_behavior = [:raise]
end

Si pour des raisons pratiques vous souhaitez autoriser temporairement l’utilisation de méthodes dépréciées dans votre code vous pourrez définir un bloc à cet effet :

ActiveSupport::Deprecation.allow do
  User.do_thing_that_calls_bad_and_worse_method
end

On autorisera ici absolument toutes les méthodes dépréciées mais la plupart du temps il sera plus judicieux de préciser celles qu’on veut autoriser :

ActiveSupport::Deprecation.allow [:bad_method, "worse_method"] do
  User.do_thing_that_calls_bad_and_worse_method
end

L’argument passé pour décrire les méthodes à autoriser temporairement doit être un tableau qui peut contenir des chaînes, des symboles ou encore des expressions rationnelles.

Il est même prévu de pouvoir passer une condition qui déterminera si le bloc doit être soumis à la levée d’exception ou non :

ActiveSupport::Deprecation.allow [:bad_method], if: Rails.env.production? do
  User.do_thing_that_calls_bad_method
end

Pour finir il est possible de définir de manière très fine les méthodes que vous souhaitez traquer pour lever des exceptions :

ActiveSupport::Deprecation.disallowed_warnings = [
  "bad_method is deprecated",
  :worse_method,
  /(horrible|unsafe)_method/
]

Une fois encore le format attendu est un tableau pouvant contenir des chaînes, des symboles ou des expressions rationnelles.

On a donc pu voir quatre ajouts fonctionnels qui ont été faits à Rails et qui selon moi méritent d’être essayés. Ils vous aideront sans aucun doute à produire du code plus robuste.

D’autres nouveautés et correctifs ont été apportés pour les découvrir je vous invite à consulter le CHANGELOG.


L’équipe Synbioz.
Libres d’être ensemble.