On melko todennäköistä, että olet työssänne kohdannut ylikuormitettuja malleja ja valtavan määrän puheluita ohjaimissa melko monta kertaa. Rails-ympäristön tuntemukseen perustuen aion tässä artikkelissa ehdottaa yksinkertaista ratkaisua tähän ongelmaan.
Erittäin tärkeä osa Rails-sovellusta on minimoida turhien riippuvuuksien määrä, minkä vuoksi koko Rails-ympäristö on viime aikoina edistänyt palveluobjekti-lähestymistapaa ja PORO-menetelmän (Pure Old Ruby Object) käyttöä. Kuvauksen tällaisen ratkaisun käyttämisestä löydät osoitteesta täällä. Tässä artikkelissa ratkaisemme käsitteen askel askeleelta ja sovitamme sen ongelmaan.
Ongelma
Hypoteettisessa sovelluksessa on kyse monimutkaisesta tapahtumajärjestelmästä. Mallimme, joka edustaa kutakin transaktiota, sisältää joukon soveltamisaloja, joiden avulla voit saada tietoja. Se helpottaa työtä huomattavasti, koska se löytyy yhdestä paikasta. Tämä ei kuitenkaan kestä kauan. Sovelluksen kehittämisen myötä projekti on muuttumassa yhä monimutkaisemmaksi. Laajuusalueilla ei ole enää yksinkertaisia "missä"-viittauksia, meiltä puuttuu tietoja ja alamme ladata suhteita. Jonkin ajan kuluttua se muistuttaa monimutkaista peilijärjestelmää. Ja mikä pahinta, emme tiedä, miten tehdä monirivinen lambda!
Alta löydät jo laajennetun sovellusmallin. Maksujärjestelmän tapahtumat tallennetaan. Kuten näet alla olevasta esimerkistä:
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
Malli on yksi asia, mutta kun hankkeemme mittakaava kasvaa, myös valvojat alkavat paisua. Katsotaanpa alla olevaa esimerkkiä:
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 withdrawalals ON withdrawalals.id = withdrawal_items.withdrawal_id OR
(withdrawalals.id = source.resource_id AND source.resource_type = 'Withdrawal')"))
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transactions = apply_filters(@transactions)
end
end
Tässä näemme monta riviä ketjutettuja metodeja sekä ylimääräisiä liitoksia, joita emme halua suorittaa monissa paikoissa, vain tässä nimenomaisessa. Liitettyjä tietoja käytetään myöhemmin apply_filters-metodissa, joka lisää GET-parametreihin perustuvan asianmukaisen tietojen suodatuksen. Voimme tietysti siirtää osan näistä viittauksista scopeen, mutta eikö tämä ole se ongelma, jota itse asiassa yritämme ratkaista?
Ratkaisu
Koska tiedämme jo, että meillä on ongelma, meidän on ratkaistava se. Johdannossa esitetyn viittauksen perusteella käytämme tässä PORO-lähestymistapaa. Tässä nimenomaisessa tapauksessa tätä lähestymistapaa kutsutaan kyselyobjektiksi, joka on palveluobjektien käsitteen kehitys.
Luodaan uusi hakemisto nimeltä "services", joka sijaitsee projektimme apps-hakemistossa. Luomme sinne luokan nimeltä TransactionsQuery
.
luokka TransactionsQuery
end
Seuraavaksi meidän on luotava alustusohjelma, jossa luodaan objektin oletuskutsupolku.
luokka TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Tämän ansiosta pystymme siirtämään suhteen aktiivisesta tietueesta laitoksellemme. Nyt voimme siirtää luokkaan kaikki soveltamisalamme, joita tarvitaan vain esitetyssä ohjaimessa.
luokka 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("transaktiot.*")
.joins(:account)
.where("accounts.owner_type = 'Publisher'")
.joins("JOIN publishers ON owner_id = publishers.id")
end
end
Meiltä puuttuu edelleen tärkein osa, eli tietojen kerääminen yhteen merkkijonoon ja käyttöliittymän julkistaminen. Metodi, jolla me liitämme kaiken yhteen, on nimeltään "call".
Todella tärkeää on, että käytämme @scope-muuttujaa, jossa kutsumme laajuus sijaitsee.
luokka TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
end
Koko luokka esittäytyy seuraavasti:
luokka 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("transaktiot.*")
.joins(:account)
.where("accounts.owner_type = 'Publisher'")
.joins("JOIN publishers ON owner_id = publishers.id")
end
end
Siivouksen jälkeen malli näyttää selvästi kevyemmältä. Keskitymme nyt vain tietojen validointiin ja muiden mallien välisiin suhteisiin.
class Transaction < ActiveRecord::Base
belongs_to :account
has_one :withdrawal_item
end
Ohjain on jo toteuttanut ratkaisumme; olemme siirtäneet kaikki lisäkyselyt erilliseen luokkaan. Kuitenkin kutsut, joita meillä ei ollut mallissa, ovat edelleen ratkaisematta. Joidenkin muutosten jälkeen indeksitoimintomme näyttää tältä:
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 withdrawalals ON withdrawalals.id = withdrawal_items.withdrawal_id OR
(withdrawalals.id = source.resource_id AND source.resource_type = 'Withdrawal')"))
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transactions = apply_filters(@transactions)
end
end
Ratkaisu
Hyvien käytänteiden ja yleissopimusten soveltamisessa voi olla hyvä ajatus korvata kaikki tietyn ongelman samankaltaiset esiintymät. Siksi siirretään SQL-kysely indeksitoiminnosta erilliseen kyselyobjektiin. Kutsumme tätä TransactionsFilterableQuery
luokka. Tyyli, jolla valmistelemme luokan, tulee olemaan samanlainen kuin se, joka esitellään TransactionsQuery
. Osana koodi muutosten myötä suurten SQL-kyselyjen intuitiivisempi tallennus salakuljetetaan käyttämällä monirivisiä merkkijonoja nimeltä heredoc. Saatavilla olevan ratkaisun löydät alta:
luokka 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 withdrawalals ON withdrawalals.id = withdrawal_items.withdrawal_id OR
(withdrawalals.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
Jos ohjaimeen tehdään muutoksia, vähennämme rivien määrää lisäämällä kyselyobjektin. On tärkeää, että erottelemme kaiken muun paitsi sivunmuodostuksesta vastaavan osan.
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
Yhteenveto
Query object muuttaa paljon lähestymistapaa SQL-kyselyjen kirjoittamiseen. ActiveRecordissa kaikki liiketoiminta- ja tietokantalogiikka on erittäin helppo sijoittaa malliin, koska kaikki on yhdessä paikassa. Tämä toimii varsin hyvin pienemmissä sovelluksissa. Kun projektin monimutkaisuus kasvaa, asetamme logiikan muihin paikkoihin. Saman kyselyobjektin avulla voit ryhmitellä jäsenkyselyjä tiettyyn ongelmaan.
Tämän ansiosta meillä on helppo mahdollisuus koodin myöhempään periytymiseen, ja ankkatyypityksen ansiosta voit käyttää näitä ratkaisuja myös muissa malleissa. Tämän ratkaisun haittapuolena on suurempi koodin määrä ja vastuun pirstaloituminen. Se, haluammeko tarttua tällaiseen haasteeseen vai emme, riippuu kuitenkin meistä itsestämme ja siitä, kuinka pahasti meitä häiritsevät lihavat mallit.