Det er ganske sannsynlig at du på jobb har støtt på overbelastede modeller og et stort antall anrop i kontrollerne ganske mange ganger. Basert på kunnskapen i Rails-miljøet, skal jeg i denne artikkelen foreslå en enkel løsning på dette problemet.
Et svært viktig aspekt ved Rails-applikasjonen er å minimere antallet overflødige avhengigheter, og derfor har hele Rails-miljøet i det siste fremmet serviceobjekttilnærmingen og bruken av PORO-metoden (Pure Old Ruby Object). En beskrivelse av hvordan du bruker en slik løsning finner du her. I denne artikkelen vil vi løse konseptet trinn for trinn og tilpasse det til problemet.
Problem
I en hypotetisk applikasjon har vi å gjøre med et komplisert transaksjonssystem. Modellen vår, som representerer hver transaksjon, har et sett med scopes som hjelper deg med å hente data. Det er en stor jobbforbedring, siden det finnes på ett sted. Dette varer imidlertid ikke lenge. Med utviklingen av applikasjonen blir prosjekt blir mer og mer komplisert. Scopes har ikke lenger enkle "hvor"-referanser, vi mangler data og begynner å laste inn relasjoner. Etter en stund minner det om et komplisert system av speil. Og, hva verre er, vi vet ikke hvordan vi skal gjøre en flerlinjers lambda!
Nedenfor finner du en allerede utvidet applikasjonsmodell. Transaksjonene i betalingssystemet er lagret i. Som du kan se i eksempelet nedenfor:
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
Modellen er én ting, men etter hvert som prosjektet øker i omfang, begynner også kontrollerne å svulme opp. La oss se på eksempelet nedenfor:
class TransactionsController < ApplikasjonsController
def index
@transaksjoner = Transaksjon.for_utgivere
.active
.visible
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
.joins("LEFT JOIN uttak ON uttak.id = uttaksposter.uttak_id OR
(uttak.id = kilde.ressurs_id AND kilde.ressurstype = 'Uttak')")
.order(:created_at)
.side(params[:side])
.per(params[:side])
@transaksjoner = apply_filters(@transaksjoner)
end
end
Her kan vi se mange linjer med kjedede metoder sammen med ekstra sammenføyninger som vi ikke ønsker å utføre mange steder, bare i akkurat denne. De vedlagte dataene brukes senere av apply_filters-metoden, som legger til riktig datafiltrering, basert på GET-parametrene. Vi kan selvfølgelig overføre noen av disse referansene til scope, men er det ikke dette problemet vi egentlig prøver å løse?
Løsning
Siden vi allerede vet om et problem vi har, må vi løse dette. Basert på referansen i innledningen vil vi her bruke PORO-tilnærmingen. I dette tilfellet kalles denne tilnærmingen spørringsobjektet, som er en videreutvikling av tjenesteobjektkonseptet.
La oss opprette en ny katalog med navnet "services", som ligger i app-katalogen i prosjektet vårt. Der oppretter vi en klasse med navnet TransactionsQuery
.
class TransactionsQuery
slutt
Som et neste trinn må vi opprette en initialisator der en standard anropsbane for objektet vårt vil bli opprettet
klasse TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Takket være dette vil vi kunne overføre forholdet fra den aktive posten til anlegget vårt. Nå kan vi overføre alle våre scopes til klassen, som bare er nødvendig i den presenterte kontrolleren.
klasse 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("transaksjoner.*")
.joins(:konto)
.where("accounts.owner_type = 'utgiver'")
.joins("JOIN publishers ON owner_id = publishers.id")
end
end
Vi mangler fortsatt den viktigste delen, nemlig å samle data i en streng og gjøre grensesnittet offentlig. Metoden der vi samler alt sammen, får navnet "call".
Det som virkelig er viktig, er at vi bruker instansevariabelen @scope der, der omfanget av kallet vårt befinner seg.
klassen TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
end
Hele klassen presenterer seg som følger:
klasse TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
privat
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("transaksjoner.*")
.joins(:konto)
.where("accounts.owner_type = 'utgiver'")
.joins("JOIN publishers ON owner_id = publishers.id")
end
end
Etter oppryddingen vår ser modellen definitivt lettere ut. Nå fokuserer vi kun på datavalidering og relasjoner mellom andre modeller.
class Transaction < ActiveRecord::Base
tilhører_til :konto
has_one :uttak_item
end
Kontrolleren har allerede implementert løsningen vår, og vi har flyttet alle tilleggsspørringer til en egen klasse. Anropene som vi ikke hadde i modellen, er imidlertid fortsatt et uløst problem. Etter noen endringer ser indekshandlingen vår slik ut:
class TransactionsController < ApplikasjonsController
def index
@transaksjoner = TransactionsQuery.new
.call
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
.joins("LEFT JOIN uttak ON uttak.id = uttaksposter.uttak_id OR
(uttak.id = kilde.ressurs_id AND kilde.ressurstype = 'Uttak')")
.order(:created_at)
.side(params[:side])
.per(params[:side])
@transaksjoner = apply_filters(@transaksjoner)
end
end
Løsning
Når det gjelder implementering av god praksis og konvensjoner, kan det være en god idé å erstatte alle lignende forekomster av et gitt problem. Derfor flytter vi SQL-spørringen fra indekshandlingen til det separate spørringsobjektet. Vi vil kalle dette en TransactionsFilterableQuery
klasse. Stilen som vi forbereder klassen i, vil være lik den som presenteres i TransactionsQuery
. Som en del av kode endringer, vil en mer intuitiv registrering av store SQL-spørringer bli smuglet inn ved hjelp av flerlinjers tegnstrenger kalt Heredoc. Den tilgjengelige løsningen finner du nedenfor:
class TransactionsFilterableQuery
def initialize(scope = Transaction.all)
@scope = scope
end
def call
withdrawal(@scope).then(&method(:withdrawal_items))
end
private
def uttak(scope)
scope.joins(<<-SQL
LEFT JOIN uttak ON uttak.id = uttak_items.uttak_id OR
(uttak.id = kilde.ressurs_id AND kilde.ressurstype = 'Uttak')
SQL
end
def uttak_elementer(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id
SQL
end
end
Ved endringer i kontrolleren reduserer vi mengden linjer ved å legge til spørringsobjektet. Det er viktig at vi skiller alt unntatt den delen som er ansvarlig for paginering.
class TransactionsController < ApplikasjonsController
def index
@transaksjoner = TransactionsQuery.new.call.then do |scope|
TransactionsFilterableQuery.new(scope).call
end.page(params[:page]).per(params[:page])
@transaksjoner = apply_filters(@transaksjoner)
end
end
Sammendrag
Query Object endrer mye i tilnærmingen til å skrive SQL-spørringer. I ActiveRecord er det veldig enkelt å plassere all forretnings- og databaselogikk i modellen, siden alt er samlet på ett sted. Dette fungerer ganske bra for mindre applikasjoner. Etter hvert som kompleksiteten i prosjektet øker, legger vi logikken andre steder. Det samme spørringsobjektet gjør det mulig å gruppere spørringer til et bestemt problem.
Takket være dette har vi en enkel mulighet for senere nedarving av koden, og på grunn av duck typing kan du også bruke disse løsningene i andre modeller. Ulempen med denne løsningen er en større mengde kode og fragmentering av ansvar. Men om vi ønsker å ta en slik utfordring eller ikke, avhenger av oss selv og hvor mye vi plages av fete modeller.