On üsna tõenäoline, et olete tööl kokku puutunud ülekoormatud mudelite ja tohutu hulga kõnedega kontrollerites üsna palju kordi. Lähtudes teadmistest Railsi keskkonnas, pakun selles artiklis välja lihtsa lahenduse sellele probleemile.
Väga oluline aspekt rails'i rakenduses on vähendada üleliigsete sõltuvuste arvu, mistõttu kogu Rails'i keskkond on viimasel ajal propageerinud teenuseobjektide lähenemist ja PORO (Pure Old Ruby Object) meetodi kasutamist. Kirjelduse sellise lahenduse kasutamise kohta leiate siin. Selles artiklis lahendame kontseptsiooni samm-sammult ja kohandame seda probleemile.
Probleem
Hüpoteetilises rakenduses on tegemist keerulise tehingusüsteemiga. Meie mudelil, mis esindab iga tehingut, on hulk ulatusi, mis aitavad saada andmeid. See on suur töö lihtsustamine, kuna see on leitav ühest kohast. See ei kesta aga kaua. Rakenduse arendamisel on projekt muutub üha keerulisemaks. Skeemidel ei ole enam lihtsaid "kus" viiteid, meil puuduvad andmed ja hakkame laadima seoseid. Mõne aja pärast meenutab see keerulist peeglisüsteemi. Ja mis veel hullem, me ei oska teha mitmerealist lambda't!
Allpool leiate juba laiendatud rakendusmudeli. Maksesüsteemi tehingud on salvestatud. Nagu näete alljärgnevas näites:
class 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)
end
Mudel on üks asi, kuid kui meie projekti ulatus kasvab, hakkavad ka kontrollerid paisuma. Vaatame alljärgnevat näidet:
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 taganemised ON taganemised.id = taganemised_tooted.taganemiste_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
Siin näeme mitmeid ridu aheldatud meetodeid koos täiendavate ühendustega, mida me ei taha paljudes kohtades teostada, vaid ainult selles konkreetses kohas. Lisatud andmeid kasutab hiljem meetod apply_filters, mis lisab GET-parameetrite põhjal sobiva andmete filtreerimise. Loomulikult saame mõned neist viidetest üle viia scope'ile, kuid kas see ei ole mitte see probleem, mida me tegelikult püüame lahendada?
Lahendus
Kuna me juba teame, et meil on probleem, peame selle lahendama. Sissejuhatuses toodud viite põhjal kasutame siinkohal PORO lähenemist. Täpselt sellisel juhul nimetatakse seda lähenemisviisi päringuobjektiks, mis on teenuseobjektide kontseptsiooni edasiarendus.
Loome uue kataloogi nimega "services", mis asub meie projekti rakenduste kataloogis. Seal loome klassi nimega TransactionsQuery
.
klass TransactionsQuery
end
Järgmise sammuna peame looma initsialiseerija, kus luuakse meie objektile vaikimisi kutsetee
klass TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Tänu sellele saame me aktiivsest registrist kanda suhte üle meie rajatisse. Nüüd saame kanda kõik meie ulatused üle klassi, mida on vaja ainult esitatud kontrolleris.
klass 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
Meil jääb endiselt puudu kõige olulisem osa, st andmete kogumine ühte stringi ja liidese avalikustamine. Meetod, kus me kõik kokku kleebime, saab nimeks "call".
Tõeliselt oluline on see, et me kasutame seal @scope instantsmuutujat, kus asub meie kõne ulatus.
klass TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
end
Kogu klass esitleb end järgmiselt:
klass 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
Pärast meie puhastamist näeb mudel kindlasti kergem välja. Seal keskendume ainult andmete valideerimisele ja teiste mudelite vahelistele seostele.
class Transaction < ActiveRecord::Base
belongs_to :account
has_one :withdrawal_item
end
Kontroller on juba rakendanud meie lahenduse; me oleme kõik täiendavad päringud viinud eraldi klassi. Kuid kutsed, meil ei olnud mudelis, jäävad lahendamata probleemiks. Pärast mõningaid muudatusi näeb meie indeksitegevus välja selline:
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 taganemised ON taganemised.id = taganemised_tooted.taganemiste_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
Lahendus
Heade tavade ja konventsioonide rakendamisel võib olla hea mõte asendada kõik antud probleemi sarnased esinemiskohad. Seetõttu liigutame SQL-küsitluse indeksitegevusest eraldi küsitlusobjekti. Nimetame seda TransactionsFilterableQuery
klass. Stiil, milles me klassi ette valmistame, on sarnane sellega, mida esitletakse aastal TransactionsQuery
. Osana kood muutuste puhul smugeldatakse suuremate SQL päringute intuitiivsemat salvestust, kasutades mitmerealisi tähemärgijada, mida nimetatakse heredoc. Saadaval oleva lahenduse leiad allpool:
klass 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
Kontrolleri muudatuste korral vähendame ridade massi, lisades päringuobjekti. Oluline on, et me eraldame kõik peale lehekülgede koostamise eest vastutava osa.
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
Kokkuvõte
Päringu objekt muudab palju lähenemist SQL päringute kirjutamisele. ActiveRecordis on väga lihtne paigutada kogu äri- ja andmebaasiloogika mudelisse, kuna kõik on ühes kohas. See töötab üsna hästi väiksemate rakenduste puhul. Kui projekti keerukus suureneb, paigutame loogika teistesse kohtadesse. Sama päringuobjekt võimaldab rühmitada liikmete päringuid konkreetseks probleemiks.
Tänu sellele on meil lihtne võimalus koodi hilisemaks pärimiseks ja tänu pardatüübile saab neid lahendusi kasutada ka teistes mudelites. Selle lahenduse puuduseks on suurem koodimaht ja vastutuse killustatus. Kuid kas me tahame sellise väljakutse vastu võtta või mitte, sõltub meist endist ja sellest, kui väga meid rasvased mudelid häirivad.