Il est fort probable qu'au travail, vous ayez été confronté à des modèles surchargés et à un grand nombre d'appels dans les contrôleurs pendant un bon nombre de fois. En me basant sur mes connaissances de l'environnement Rails, je vais proposer dans cet article une solution simple à ce problème.
Un aspect très important de l'application Rails est de minimiser le nombre de dépendances redondantes, c'est pourquoi l'ensemble de l'environnement Rails a récemment promu l'approche des objets de service et l'utilisation de la méthode PORO (Pure Old Ruby Object). Vous trouverez une description de l'utilisation d'une telle solution dans les pages suivantes ici. Dans cet article, nous allons résoudre le concept étape par étape et l'adapter au problème.
Problème
Dans une application hypothétique, nous avons affaire à un système de transaction complexe. Notre modèle, qui représente chaque transaction, dispose d'un ensemble de champs d'application qui vous aident à obtenir des données. Cela facilite grandement le travail, car les données peuvent être trouvées en un seul endroit. Cependant, cela ne dure pas longtemps. Avec le développement de l'application, les projet devient de plus en plus complexe. Les champs d'application n'ont plus de références "où" simples, nous manquons de données et commençons à charger des relations. Au bout d'un moment, cela rappelle un système compliqué de miroirs. Et, pire encore, nous ne savons pas comment faire une lambda multiligne !
Vous trouverez ci-dessous un modèle d'application déjà développé. Les transactions du système de paiement sont stockées dans. Comme vous pouvez le voir dans l'exemple ci-dessous :
classe Transaction { where(visible : true) }
scope(:active, lambda do
joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
AND source.accepted_at IS NOT NULL
SQL
end)
fin
Le modèle est une chose, mais à mesure que l'échelle de notre projet augmente, les contrôleurs commencent eux aussi à gonfler. Examinons l'exemple ci-dessous :
classe TransactionsController < ApplicationController
def index
@transactions = Transaction.for_publishers
.active
.visible
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')")
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transactions = apply_filters(@transactions)
end
end
Ici, nous pouvons voir de nombreuses lignes de méthodes enchaînées ainsi que des jointures supplémentaires que nous ne voulons pas effectuer à de nombreux endroits, mais seulement à celui-ci. Les données jointes sont ensuite utilisées par la méthode apply_filters, qui ajoute le filtrage de données approprié, sur la base des paramètres GET. Bien sûr, nous pouvons transférer certaines de ces références à la portée, mais n'est-ce pas là le problème que nous essayons de résoudre ?
Solution
Puisque nous connaissons déjà un problème, nous devons le résoudre. Sur la base de la référence dans l'introduction, nous utiliserons ici l'approche PORO. Dans ce cas précis, cette approche s'appelle l'objet de requête, qui est un développement du concept d'objet de service.
Créons un nouveau répertoire nommé "services", situé dans le répertoire apps de notre projet. Nous y créerons une classe nommée TransactionsQuery
.
classe TransactionsQuery
fin
L'étape suivante consiste à créer un initialisateur dans lequel un chemin d'appel par défaut sera créé pour notre objet.
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Grâce à cela, nous pourrons transférer la relation de l'enregistrement actif à notre installation. Nous pouvons maintenant transférer tous nos champs d'application à la classe, qui ne sont nécessaires que dans le contrôleur présenté.
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
private
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
AND source.accepted_at IS NOT NULL
SQL
end
def visible(scope)
scope.where(visible : true)
end
def for_publishers(scope)
scope.select("transactions.*")
.joins(:account)
.where("accounts.owner_type = 'Publisher'")
.joins("JOIN publishers ON owner_id = publishers.id")
fin
fin
Il nous manque encore la partie la plus importante, à savoir rassembler les données dans une chaîne de caractères et rendre l'interface publique. La méthode par laquelle nous rassemblerons toutes les données sera appelée "call".
Ce qui est vraiment important, c'est que nous utiliserons la variable d'instance @scope à cet endroit, où se trouve la portée de notre appel.
classe TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
fin
L'ensemble de la classe se présente comme suit :
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
AND source.accepted_at IS NOT NULL
SQL
end
def visible(scope)
scope.where(visible : true)
end
def for_publishers(scope)
scope.select("transactions.*")
.joins(:account)
.where("accounts.owner_type = 'Publisher'")
.joins("JOIN publishers ON owner_id = publishers.id")
fin
fin
Après notre nettoyage, le modèle paraît nettement plus léger. Ici, nous nous concentrons uniquement sur la validation des données et les relations entre les autres modèles.
classe Transaction < ActiveRecord::Base
belongs_to :account
has_one :withdrawal_item
fin
Le contrôleur a déjà mis en œuvre notre solution ; nous avons déplacé toutes les requêtes supplémentaires dans une classe distincte. Cependant, les appels que nous n'avions pas dans le modèle restent un problème non résolu. Après quelques modifications, notre action d'indexation ressemble à ceci :
classe TransactionsController < ApplicationController
def index
@transactions = TransactionsQuery.new
.call
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')")
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transactions = apply_filters(@transactions)
end
end
Solution
Dans le cas de la mise en œuvre de bonnes pratiques et de conventions, une bonne idée peut consister à remplacer toutes les occurrences similaires d'un problème donné. Par conséquent, nous déplacerons la requête SQL de l'action d'index vers l'objet de requête séparé. Nous appellerons cela un TransactionsFilterableQuery
classe. Le style dans lequel nous préparons le cours sera similaire à celui présenté dans TransactionsQuery
. Dans le cadre de la code un enregistrement plus intuitif des grandes requêtes SQL sera introduit en contrebande, à l'aide de chaînes de caractères multilignes appelées heredoc. La solution disponible se trouve ci-dessous :
classe TransactionsFilterableQuery
def initialize(scope = Transaction.all)
@scope = scope
end
def call
withdrawal(@scope).then(&method(:withdrawal_items))
end
private
def retrait(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')
SQL
fin
def withdrawal_items(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id
SQL
end
end
En cas de modification du contrôleur, nous réduisons le nombre de lignes en ajoutant l'objet de requête. Il est important de tout séparer, sauf la partie responsable de la pagination.
classe TransactionsController < ApplicationController
def index
@transactions = TransactionsQuery.new.call.then do |scope|
TransactionsFilterableQuery.new(scope).call
end.page(params[:page]).per(params[:page])
@transactions = apply_filters(@transactions)
end
end
Résumé
L'objet de requête change beaucoup l'approche de l'écriture des requêtes SQL. Dans ActiveRecord, il est très facile de placer toute la logique de l'entreprise et de la base de données dans le modèle, puisque tout se trouve au même endroit. Cela fonctionne très bien pour les petites applications. Au fur et à mesure que la complexité du projet augmente, nous plaçons la logique à d'autres endroits. Le même objet de requête vous permet de regrouper les requêtes des membres dans un problème spécifique.
Grâce à cela, nous avons une possibilité facile d'héritage ultérieur du code et, en raison du typage de canard, vous pouvez également utiliser ces solutions dans d'autres modèles. L'inconvénient de cette solution est l'augmentation de la quantité de code et la fragmentation des responsabilités. Toutefois, la décision de relever ou non un tel défi dépend de nous et de notre degré d'inconfort vis-à-vis des modèles lourds.