Je dost pravděpodobné, že jste se v práci již mnohokrát setkali s přetíženými modely a velkým počtem volání v řadičích. Na základě znalostí z prostředí Rails vám v tomto článku navrhnu jednoduché řešení tohoto problému.
Velmi důležitým aspektem kolejnice aplikace je minimalizovat počet nadbytečných závislostí, což je důvod, proč se v celém prostředí Rails v poslední době prosazuje přístup založený na objektech služeb a používání metody PORO (Pure Old Ruby Object). Popis, jak takové řešení použít, naleznete zde zde. V tomto článku budeme postupně řešit tento koncept a přizpůsobíme ho danému problému.
Problém
V hypotetické aplikaci máme co do činění s komplikovaným transakčním systémem. Náš model, reprezentující každou transakci, má sadu rozsahů, které pomáhají získávat data. Je to velké usnadnění práce, protože je lze najít na jednom místě. To však netrvá dlouho. S vývojem aplikace se projekt je stále složitější. Rozsahy již nemají jednoduché odkazy "kde", chybí nám data a začínáme načítat vztahy. Po chvíli to připomíná složitý systém zrcadel. A co hůř, neumíme udělat víceřádkovou lambdu!
Níže najdete již rozšířený model aplikace. Transakce platebního systému jsou uloženy v. Jak můžete vidět na příkladu níže:
třída 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
Model je jedna věc, ale s rostoucím rozsahem našeho projektu začínají bobtnat i kontrolory. Podívejme se na příklad níže:
třída 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 withdrawalals.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)
konec
end
Zde vidíme mnoho řádků zřetězených metod spolu s dalšími spoji, které nechceme provádět na mnoha místech, pouze na tomto konkrétním. Připojená data později použije metoda apply_filters, která na základě parametrů GET přidá příslušné filtrování dat. Samozřejmě můžeme některé z těchto odkazů přenést do oboru, ale není to vlastně problém, který se snažíme vyřešit?
Řešení
Protože již víme o problému, který máme, musíme ho vyřešit. Na základě odkazu v úvodu zde použijeme přístup PORO. Přesně v tomto případě se tento přístup nazývá dotazovací objekt, který je rozvinutím konceptu servisních objektů.
Vytvořme nový adresář s názvem "services", který se nachází v adresáři apps našeho projektu. V něm vytvoříme třídu s názvem TransactionsQuery.
třída TransactionsQuery
konec
V dalším kroku musíme vytvořit inicializátor, ve kterém bude vytvořena výchozí cesta volání pro náš objekt.
třída TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Díky tomu budeme moci přenést vztah z aktivního záznamu do našeho zařízení. Nyní můžeme do třídy přenést všechny naše obory, které jsou potřebné pouze v prezentovaném kontroléru.
třída 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")
konec
konec
Stále nám chybí ta nejdůležitější část, tj. shromáždění dat do jednoho řetězce a zveřejnění rozhraní. Metoda, ve které vše slepíme dohromady, se bude jmenovat "volání".
Důležité je, že zde použijeme instanční proměnnou @scope, ve které se nachází obor našeho volání.
třída TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
end
Celá třída se prezentuje takto:
třída 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")
konec
konec
Po našem vyčištění vypadá model rozhodně lehčí. V něm se zaměříme pouze na validaci dat a vztahy mezi ostatními modely.
třída Transaction < ActiveRecord::Base
belongs_to :account
has_one :withdrawal_item
end
Kontrolér již implementoval naše řešení; všechny další dotazy jsme přesunuli do samostatné třídy. Nevyřešeným problémem však zůstávají volání, která jsme v modelu neměli. Po několika změnách vypadá naše akce indexu takto:
třída 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 withdrawalals.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)
konec
end
Řešení
V případě zavádění osvědčených postupů a konvencí může být dobrým nápadem nahradit všechny podobné výskyty daného problému. Proto přesuneme SQL dotaz z indexové akce do samostatného objektu dotazu. Ten budeme nazývat TransactionsFilterableQuery třída. Sloh, kterým třídu připravujeme, bude podobný tomu, který je prezentován v přednášce TransactionsQuery. V rámci kód změny, intuitivnější záznam velkých dotazů SQL bude propašován pomocí víceřádkových řetězců znaků tzv. heredoc. Dostupné řešení najdete níže:
třída TransactionsFilterableQuery
def initialize(scope = Transaction.all)
@scope = scope
end
def call
withdrawal(@scope).then(&method(:withdrawal_items))
end
private
def stažení(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id NEBO
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')
SQL
konec
def withdrawal_items(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id
SQL
end
end
V případě změn v řadiči zmenšíme množství řádků přidáním objektu dotazu. Důležité je, abychom oddělili vše kromě části zodpovědné za stránkování.
třída 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
konec
Souhrn
Objekt dotazu hodně mění přístup k psaní dotazů SQL. V ActiveRecordu je velmi snadné umístit veškerou obchodní a databázovou logiku do modelu, protože vše je na jednom místě. To bude docela dobře fungovat u menších aplikací. S rostoucí složitostí projektu nastavíme logiku na jiná místa. Stejný objekt dotazu umožňuje seskupovat členské dotazy do určitého problému.
Díky tomu máme snadnou možnost pozdějšího dědění kódu a díky kachnímu typování lze tato řešení použít i v jiných modelech. Nevýhodou tohoto řešení je větší množství kódu a roztříštěnost odpovědnosti. Nicméně to, zda se do takové výzvy chceme pustit, nebo ne, záleží na nás a jak moc nám vadí tlusté modelky.