Είναι πολύ πιθανό ότι στη δουλειά σας να έχετε αντιμετωπίσει πολλές φορές υπερφορτωμένα μοντέλα και τεράστιο αριθμό κλήσεων στους ελεγκτές. Με βάση τις γνώσεις στο περιβάλλον του Rails, σε αυτό το άρθρο, θα σας προτείνω μια απλή λύση σε αυτό το πρόβλημα.
Μια πολύ σημαντική πτυχή της εφαρμογής rails είναι η ελαχιστοποίηση του αριθμού των περιττών εξαρτήσεων, γι' αυτό και ολόκληρο το περιβάλλον Rails προωθεί πρόσφατα την προσέγγιση των αντικειμένων υπηρεσιών και τη χρήση της μεθόδου PORO (Pure Old Ruby Object). Μια περιγραφή του τρόπου χρήσης μιας τέτοιας λύσης θα βρείτε εδώ. Σε αυτό το άρθρο, θα λύσουμε την έννοια βήμα προς βήμα και θα την προσαρμόσουμε στο πρόβλημα.
Πρόβλημα
Σε μια υποθετική εφαρμογή, έχουμε να κάνουμε με ένα πολύπλοκο σύστημα συναλλαγών. Το μοντέλο μας, που αναπαριστά κάθε συναλλαγή, έχει ένα σύνολο από πεδία εφαρμογής, που σας βοηθούν να λάβετε δεδομένα. Είναι μια μεγάλη διευκόλυνση της εργασίας, καθώς μπορούν να βρεθούν σε ένα μέρος. Ωστόσο, αυτό δεν διαρκεί για πολύ. Με την ανάπτυξη της εφαρμογής, η έργο γίνεται όλο και πιο περίπλοκη. Τα πεδία εφαρμογής δεν έχουν πλέον απλές αναφορές "όπου", μας λείπουν δεδομένα και αρχίζουμε να φορτώνουμε σχέσεις. Μετά από λίγο, θυμίζει ένα περίπλοκο σύστημα καθρεφτών. Και, το χειρότερο, δεν ξέρουμε πώς να κάνουμε ένα lambda πολλαπλών γραμμών!
Παρακάτω, θα βρείτε ένα ήδη διευρυμένο μοντέλο εφαρμογής. Οι συναλλαγές του συστήματος πληρωμών αποθηκεύονται σε. Όπως μπορείτε να δείτε στο παρακάτω παράδειγμα:
class Συναλλαγή { 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)
τέλος
Το μοντέλο είναι ένα πράγμα, αλλά καθώς η κλίμακα του έργου μας αυξάνεται, οι ελεγκτές αρχίζουν επίσης να διογκώνονται. Ας δούμε το παρακάτω παράδειγμα:
class 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
Εδώ μπορούμε να δούμε πολλές γραμμές αλυσιδωτών μεθόδων μαζί με πρόσθετες ενώσεις που δεν θέλουμε να εκτελέσουμε σε πολλά σημεία, μόνο σε αυτό το συγκεκριμένο. Τα συνημμένα δεδομένα χρησιμοποιούνται αργότερα από τη μέθοδο apply_filters, η οποία προσθέτει το κατάλληλο φιλτράρισμα των δεδομένων, με βάση τις παραμέτρους GET. Φυσικά, μπορούμε να μεταφέρουμε κάποιες από αυτές τις αναφορές στην εμβέλεια, αλλά αυτό δεν είναι το πρόβλημα που ουσιαστικά προσπαθούμε να επιλύσουμε;
Λύση
Εφόσον γνωρίζουμε ήδη ένα πρόβλημα που έχουμε, πρέπει να το λύσουμε. Με βάση την αναφορά στην εισαγωγή, θα χρησιμοποιήσουμε εδώ την προσέγγιση PORO. Σε αυτήν ακριβώς την περίπτωση, η προσέγγιση αυτή ονομάζεται αντικείμενο ερωτήματος, το οποίο αποτελεί εξέλιξη της έννοιας των αντικειμένων υπηρεσιών.
Ας δημιουργήσουμε έναν νέο κατάλογο με το όνομα "services", ο οποίος βρίσκεται στον κατάλογο apps του έργου μας. Εκεί θα δημιουργήσουμε μια κλάση με το όνομα TransactionsQuery
.
class TransactionsQuery
end
Ως επόμενο βήμα, πρέπει να δημιουργήσουμε έναν αρχικοποιητή όπου θα δημιουργηθεί μια προεπιλεγμένη διαδρομή κλήσης για το αντικείμενό μας
class TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Χάρη σε αυτό, θα είμαστε σε θέση να μεταφέρουμε τη σχέση από το ενεργό αρχείο στην εγκατάστασή μας. Τώρα μπορούμε να μεταφέρουμε όλα τα πεδία εφαρμογής μας στην κλάση, τα οποία χρειάζονται μόνο στον παρουσιαζόμενο ελεγκτή.
class 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")
end
end
Μας λείπει ακόμα το πιο σημαντικό μέρος, δηλαδή η συλλογή δεδομένων σε μια συμβολοσειρά και η δημοσιοποίηση της διεπαφής. Η μέθοδος όπου θα κολλήσουμε τα πάντα μαζί θα ονομάζεται "κλήση".
Αυτό που είναι πραγματικά σημαντικό είναι ότι θα χρησιμοποιήσουμε τη μεταβλητή @scope εκεί, όπου βρίσκεται η εμβέλεια της κλήσης μας.
class TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
end
Ολόκληρη η τάξη παρουσιάζεται ως εξής:
class 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")
end
end
Μετά τον καθαρισμό μας, το μοντέλο φαίνεται σίγουρα πιο ελαφρύ. Εκεί εστιάζουμε μόνο στην επικύρωση των δεδομένων και στις σχέσεις μεταξύ άλλων μοντέλων.
class Συναλλαγή < ActiveRecord::Base
belongs_to :account
has_one :withdrawal_item
end
Ο ελεγκτής έχει ήδη υλοποιήσει τη λύση μας- έχουμε μεταφέρει όλα τα πρόσθετα ερωτήματα σε μια ξεχωριστή κλάση. Ωστόσο, οι κλήσεις, που δεν είχαμε στο μοντέλο, παραμένουν ένα άλυτο ζήτημα. Μετά από κάποιες αλλαγές, η ενέργεια ευρετηρίου μας μοιάζει ως εξής:
class 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
Λύση
Στην περίπτωση της εφαρμογής ορθών πρακτικών και συμβάσεων, μια καλή ιδέα μπορεί να είναι η αντικατάσταση όλων των παρόμοιων περιστατικών ενός συγκεκριμένου προβλήματος. Ως εκ τούτου, θα μεταφέρουμε το ερώτημα SQL από την ενέργεια ευρετηρίου στο ξεχωριστό αντικείμενο ερωτήματος. Θα το ονομάσουμε αυτό TransactionsFilterableQuery
κατηγορία. Το ύφος, με το οποίο προετοιμάζουμε το μάθημα, θα είναι παρόμοιο με αυτό που παρουσιάζεται στο TransactionsQuery
. Στο πλαίσιο του κωδικός αλλαγές, μια πιο διαισθητική καταγραφή των μεγάλων ερωτημάτων SQL θα γίνεται λαθραία, χρησιμοποιώντας συμβολοσειρές χαρακτήρων πολλών γραμμών που ονομάζονται heredoc. Τη διαθέσιμη λύση θα τη βρείτε παρακάτω:
class TransactionsFilterableQuery
def initialize(scope = Transaction.all)
@scope = scope
end
def call
withdrawal(@scope).then(&method(:withdrawal_items))
end
private
def withdrawal(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
end
def withdrawal_items(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id
SQL
end
end
Σε περίπτωση αλλαγών στον ελεγκτή, μειώνουμε τη μάζα των γραμμών προσθέτοντας το αντικείμενο ερώτησης. Είναι σημαντικό να διαχωρίσουμε τα πάντα εκτός από το τμήμα που είναι υπεύθυνο για την σελιδοποίηση.
class 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
Περίληψη
Το αντικείμενο ερώτησης αλλάζει πολύ την προσέγγιση στη συγγραφή ερωτημάτων SQL. Στην ActiveRecord, είναι πολύ εύκολο να τοποθετήσετε όλη την επιχειρηματική λογική και τη λογική της βάσης δεδομένων στο μοντέλο, αφού όλα βρίσκονται σε ένα μέρος. Αυτό θα λειτουργήσει αρκετά καλά για μικρότερες εφαρμογές. Καθώς αυξάνεται η πολυπλοκότητα του έργου, τοποθετούμε τη λογική σε άλλα μέρη. Το ίδιο αντικείμενο ερωτήματος σας επιτρέπει να ομαδοποιείτε τα ερωτήματα των μελών σε ένα συγκεκριμένο πρόβλημα.
Χάρη σε αυτό, έχουμε μια εύκολη δυνατότητα μεταγενέστερης κληρονομικότητας του κώδικα και λόγω της τυποποίησης πάπιας, μπορείτε επίσης να χρησιμοποιήσετε αυτές τις λύσεις σε άλλα μοντέλα. Το μειονέκτημα αυτής της λύσης είναι η μεγαλύτερη ποσότητα κώδικα και ο κατακερματισμός της ευθύνης. Ωστόσο, το αν θέλουμε να αναλάβουμε μια τέτοια πρόκληση ή όχι, εξαρτάται από εμάς και από το πόσο πολύ μας ενοχλούν τα παχιά μοντέλα.