Invalidation du cache Rails au niveau du champ : une solution DSL
Publié: 2022-03-11Dans le développement Web moderne, la mise en cache est un moyen rapide et puissant d'accélérer les choses. Lorsqu'elle est bien faite, la mise en cache peut apporter des améliorations significatives aux performances globales de votre application. Lorsqu'il est mal fait, cela se terminera très certainement par un désastre.
L'invalidation du cache, comme vous le savez peut-être, est l'un des trois problèmes les plus difficiles en informatique, les deux autres étant le nommage des choses et les erreurs ponctuelles. Une solution simple consiste à tout invalider, à gauche et à droite, chaque fois que quelque chose change. Mais cela va à l'encontre de l'objectif de la mise en cache. Vous souhaitez invalider le cache uniquement lorsque cela est absolument nécessaire.
Si vous voulez tirer le meilleur parti de la mise en cache, vous devez être très précis sur ce que vous invalidez et éviter à votre application de gaspiller de précieuses ressources en travaux répétés.
Dans cet article de blog, vous apprendrez une technique pour mieux contrôler le comportement des caches Rails : en particulier, implémenter l'invalidation du cache au niveau du champ. Cette technique repose sur Rails ActiveRecord et ActiveSupport::Concern
ainsi que sur la manipulation du comportement de la méthode touch
.
Ce billet de blog est basé sur mes expériences récentes dans un projet où nous avons constaté une amélioration significative des performances après la mise en œuvre de l'invalidation du cache au niveau du champ. Cela a permis de réduire les invalidations de cache inutiles et le rendu répété des modèles.
Rails, rubis et performances
Ruby n'est pas le langage le plus rapide, mais dans l'ensemble, c'est une option appropriée en ce qui concerne la vitesse de développement. De plus, ses capacités de métaprogrammation et de langage spécifique au domaine (DSL) intégrées offrent au développeur une flexibilité considérable.
Il existe des études comme celle de Jakob Nielsen qui nous montrent que si une tâche prend plus de 10 secondes, nous perdons notre concentration. Et retrouver notre concentration prend du temps. Cela peut donc être coûteux de manière inattendue.
Malheureusement, dans Ruby on Rails, il est très facile de dépasser ce seuil de 10 secondes avec la génération de modèles. Vous ne verrez pas cela se produire dans n'importe quelle application "hello world" ou projet d'animal de compagnie à petite échelle, mais dans des projets réels où beaucoup de choses sont chargées sur une seule page, croyez-moi, la génération de modèles peut très facilement commencer à traîner.
Et c'est exactement ce que j'ai dû résoudre dans mon projet.
Optimisations simples
Mais comment accélérer les choses exactement ?
La réponse : comparer et optimiser.
Dans mon projet, deux étapes d'optimisation très efficaces étaient :
- Suppression des requêtes N+1
- Présentation d'une bonne technique de mise en cache pour les modèles
Requêtes N+1
La résolution des requêtes N + 1 est facile. Ce que vous pouvez faire, c'est vérifier vos fichiers journaux. Chaque fois que vous voyez plusieurs requêtes SQL comme celles ci-dessous dans vos journaux, éliminez-les en les remplaçant par un chargement rapide :
Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ? Learning Load (0.3ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ? Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ?
Il existe un joyau pour cela qui s'appelle la balle pour aider à détecter cette inefficacité. Vous pouvez également parcourir chacun des cas d'utilisation et, en attendant, vérifier les journaux en les inspectant par rapport au modèle ci-dessus. En éliminant toutes les inefficacités N+1, vous pouvez être suffisamment sûr que vous ne surchargerez pas votre base de données et que votre temps passé sur ActiveRecord diminuera de manière significative.
Après avoir apporté ces modifications, mon projet fonctionnait déjà plus rapidement. Mais j'ai décidé de passer au niveau supérieur et de voir si je pouvais réduire encore plus ce temps de chargement. Il y avait encore pas mal de rendus inutiles dans les modèles, et finalement, c'est là que la mise en cache des fragments a aidé.
Mise en cache des fragments
La mise en cache des fragments permet généralement de réduire considérablement le temps de génération des modèles. Mais le comportement par défaut du cache Rails ne le coupait pas pour mon projet.
L'idée derrière la mise en cache des fragments Rails est géniale. Il fournit un mécanisme de mise en cache super simple et efficace.
Les auteurs de Ruby On Rails ont écrit un très bon article dans Signal v. Noise sur le fonctionnement de la mise en cache des fragments.
Disons que vous avez un peu d'interface utilisateur qui affiche certains champs d'une entité.
- Lors du chargement de la page, Rails calcule le
cache_key
en fonction de la classe de l'entité et du champupdated_at
. - En utilisant ce
cache_key
, il vérifie s'il y a quelque chose dans le cache associé à cette clé. - S'il n'y a rien dans le cache, le code HTML de ce fragment est rendu pour la vue (et le contenu nouvellement rendu est stocké dans le cache).
- S'il existe un contenu existant dans le cache avec cette clé, la vue est rendue avec le contenu du cache.
Cela implique que le cache n'a jamais besoin d'être invalidé explicitement. Chaque fois que nous modifions l'entité et rechargeons la page, un nouveau contenu de cache est rendu pour l'entité.
Rails, par défaut, offre également la possibilité d'invalider le cache des entités parentes en cas de modification de l'enfant :
belongs_to :parent_entity, touch: true
Ceci, lorsqu'il est inclus dans un modèle, touchera automatiquement le parent lorsque l'enfant est touché . Vous pouvez en savoir plus sur touch
ici. Avec cela, Rails nous fournit un moyen simple et efficace d'invalider le cache de nos entités parents simultanément avec le cache des entités enfants.
Mise en cache dans Rails
Cependant, la mise en cache dans Rails est créée pour servir les interfaces utilisateur où le fragment HTML représentant l'entité parent contient des fragments HTML représentant uniquement les entités enfants du parent. En d'autres termes, le fragment HTML représentant les entités enfants dans ce paradigme ne peut pas contenir de champs de l'entité parent.
Mais ce n'est pas ce qui se passe dans le monde réel. Vous pouvez très bien avoir besoin de faire des choses dans votre application Rails qui violent cette condition.
Comment géreriez-vous une situation où l'interface utilisateur affiche les champs d'une entité parent à l'intérieur du fragment HTML représentant l'entité enfant ?

Si l'enfant contient des champs de l'entité parent, vous rencontrez des problèmes avec le comportement d'invalidation du cache par défaut de Rails.
Chaque fois que ces champs présentés à partir de l'entité parent sont modifiés, vous devrez toucher toutes les entités enfants appartenant à ce parent. Par exemple, si Parent1
est modifié, vous devrez vous assurer que le cache des vues Child1
et Child2
sont tous deux invalidés.
De toute évidence, cela peut entraîner un énorme goulot d'étranglement des performances. Toucher chaque entité enfant chaque fois qu'un parent a changé entraînerait de nombreuses requêtes de base de données sans raison valable.
Un autre scénario similaire est lorsque les entités associées à l'association has_and_belongs_to
ont été présentées dans la liste, et la modification de ces entités a déclenché une cascade d'invalidation du cache à travers la chaîne d'association.
class Event < ActiveRecord::Base has_many :participants has_many :users, through: :participants end class Participant < ActiveRecord::Base belongs_to :event belongs_to :user end class User < ActiveRecord::Base has_many :participants has_many :events, through :participants end
Ainsi, pour l'interface utilisateur ci-dessus, il serait illogique de toucher le participant ou l'événement lorsque l'emplacement de l'utilisateur change. Mais nous devrions toucher à la fois l'événement et le participant lorsque le nom de l'utilisateur change, n'est-ce pas ?
Ainsi, les techniques de l'article Signal v. Noise sont inefficaces pour certaines instances UI/UX, comme décrit ci-dessus.
Bien que Rails soit super efficace pour des choses simples, les vrais projets ont leurs propres complications.
Invalidation du cache Rails au niveau du champ
Dans mes projets, j'ai utilisé un petit Ruby DSL pour gérer des situations comme celles ci-dessus. Il vous permet de spécifier de manière déclarative les champs qui déclencheront l'invalidation du cache via les associations.
Jetons un coup d'œil à quelques exemples où cela aide vraiment :
Exemple 1:
class Event < ActiveRecord::Base include Touchable ... has_many :tasks ... touch :tasks, in_case_of_modified_fields: [:name] ... end class Task < ActiveRecord::Base belongs_to :event end
Cet extrait exploite les capacités de métaprogrammation et les capacités DSL internes de Ruby.
Pour être plus précis, seul un changement de nom dans l'événement invalidera le cache de fragments de ses tâches associées. La modification d'autres champs de l'événement, comme l'objet ou l'emplacement, n'invalidera pas le cache de fragments de la tâche. J'appellerais ce contrôle d'invalidation de cache à grain fin au niveau du champ .
Exemple 2 :
Examinons un exemple qui montre l'invalidation du cache via la chaîne d'association has_many
.
Le fragment d'interface utilisateur illustré ci-dessous montre une tâche et son propriétaire :
Pour cette interface utilisateur, le fragment HTML représentant la tâche doit être invalidé uniquement lorsque la tâche change ou lorsque le nom du propriétaire change. Si tous les autres champs du propriétaire (comme le fuseau horaire ou les préférences) changent, le cache des tâches doit rester intact.
Ceci est réalisé en utilisant le DSL illustré ici :
class User < ActiveRecord::Base include Touchable touch :tasks, in_case_of_modified_fields: [:first_name, :last_name] ... end class Task < ActiveRecord::Base has_one owner, class_name: :User end
Mise en œuvre du DSL
L'essence principale du DSL est la méthode touch
. Son premier argument est une association, et le prochain argument est une liste de champs qui déclenche le touch
sur cette association :
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]
Cette méthode est fournie par le module Touchable
:
module Touchable extend ActiveSupport::Concern included do before_save :check_touchable_entities after_save :touch_marked_entities end module ClassMethods def touch association, options @touchable_associations ||= {} @touchable_associations[association] = options end end end
Dans ce code, le point principal est que nous stockons les arguments de l'appel touch
. Ensuite, avant de sauvegarder l'entité, nous marquons l'association comme sale si le champ spécifié a été modifié. Nous touchons les entités de cette association après avoir enregistré si l'association était sale.
Ensuite, la partie privée du souci est :
... private def klass_level_meta_info self.class.instance_variable_get('@touchable_associations') end def meta_info @meta_info ||= {} end def check_touchable_entities return unless klass_level_meta_info.present? klass_level_meta_info.each_pair do |association, change_triggering_fields| if any_of_the_declared_field_changed?(change_triggering_fields) meta_info[association] = true end end end def any_of_the_declared_field_changed?(options) (options[:in_case_of_modified_fields] & changes.keys.map{|x|x.to_sym}).present? end …
Dans la méthode check_touchable_entities
, nous vérifions si le champ déclaré a changé . Si tel est le cas, nous marquons l'association comme sale en définissant meta_info[association]
sur true
.
Ensuite, après avoir enregistré l'entité, nous vérifions nos associations sales et touchons les entités qu'elle contient si nécessaire :
… def touch_marked_entities return unless klass_level_meta_info.present? klass_level_meta_info.each_key do |association_key| if meta_info[association_key] association = send(association_key) association.update_all(updated_at: Time.zone.now) meta_info[association_key] = false end end end …
Et c'est tout ! Vous pouvez maintenant effectuer une invalidation du cache au niveau du champ dans Rails avec un simple DSL.
Conclusion
La mise en cache des rails promet des améliorations de performances dans votre application avec une relative facilité. Cependant, les applications du monde réel peuvent être compliquées et posent souvent des défis uniques. Le comportement par défaut du cache Rails fonctionne bien pour la plupart des scénarios, mais il existe certains scénarios où un peu plus d'optimisation dans l'invalidation du cache peut aller très loin.
Maintenant que vous savez comment implémenter l'invalidation du cache au niveau du champ dans Rails, vous pouvez empêcher les invalidations inutiles des caches dans votre application.