Całkiem prawdopodobne, że w pracy nie raz spotkałeś się z przeładowanymi modelami i ogromną liczbą wywołań w kontrolerach. Bazując na wiedzy zdobytej w środowisku Rails, w tym artykule zamierzam zaproponować proste rozwiązanie tego problemu.
Bardzo ważnym aspektem aplikacji railsowej jest zminimalizowanie ilości zbędnych zależności, dlatego też całe środowisko Railsowe w ostatnim czasie promuje podejście service object i wykorzystanie metody PORO (Pure Old Ruby Object). Opis tego, jak korzystać z takiego rozwiązania można znaleźć na stronie tutaj. W tym artykule rozwiążemy tę koncepcję krok po kroku i dostosujemy ją do problemu.
Problem
W hipotetycznej aplikacji mamy do czynienia ze skomplikowanym systemem transakcyjnym. Nasz model, reprezentujący każdą transakcję, ma zestaw zakresów, które pomagają uzyskać dane. Jest to duże ułatwienie pracy, ponieważ można je znaleźć w jednym miejscu. Nie trwa to jednak długo. Wraz z rozwojem aplikacji, w projekt staje się coraz bardziej skomplikowana. Zakresy nie mają już prostych odniesień "gdzie", brakuje nam danych i zaczynamy ładować relacje. Po pewnym czasie przypomina to skomplikowany system luster. A co gorsza, nie wiemy jak zrobić wieloliniową lambdę!
Poniżej znajduje się już rozszerzony model aplikacji. Przechowywane są w nim transakcje systemu płatności. Jak widać na poniższym przykładzie:
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
Model to jedno, ale wraz ze wzrostem skali naszego projektu, kontrolery również zaczynają puchnąć. Spójrzmy na poniższy przykład:
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
Widzimy tutaj wiele linii metod łańcuchowych wraz z dodatkowymi złączeniami, których nie chcemy wykonywać w wielu miejscach, a jedynie w tym konkretnym. Dołączone dane są później wykorzystywane przez metodę apply_filters, która dodaje odpowiednie filtrowanie danych na podstawie parametrów GET. Oczywiście możemy przenieść część z tych referencji do scope, ale czy nie jest to problem, który tak naprawdę próbujemy rozwiązać?
Rozwiązanie
Skoro znamy już problem, musimy go rozwiązać. W oparciu o odniesienie we wstępie, użyjemy tutaj podejścia PORO. W tym konkretnym przypadku podejście to nazywane jest obiektem zapytań, który jest rozwinięciem koncepcji obiektów usługowych.
Utwórzmy nowy katalog o nazwie "services", znajdujący się w katalogu apps naszego projektu. Tam utworzymy klasę o nazwie TransactionsQuery
.
class TransactionsQuery
koniec
W kolejnym kroku musimy utworzyć inicjalizator, w którym zostanie utworzona domyślna ścieżka wywołania dla naszego obiektu
class TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
end
Dzięki temu będziemy mogli przenieść relację z aktywnego rekordu do naszego obiektu. Teraz możemy przenieść wszystkie nasze zakresy do klasy, które są potrzebne tylko w prezentowanym kontrolerze.
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
Wciąż brakuje nam najważniejszej części, czyli zebrania danych w jeden ciąg i upublicznienia interfejsu. Metoda, w której połączymy wszystko razem, zostanie nazwana "call".
Co naprawdę ważne, użyjemy tam zmiennej instancji @scope, gdzie znajduje się zakres naszego wywołania.
class TransactionsQuery
...
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
end
private
...
end
Cała klasa prezentuje się następująco:
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
Po naszym oczyszczeniu model wygląda zdecydowanie lżej. Tam skupiamy się tylko na walidacji danych i relacjach między innymi modelami.
class Transaction < ActiveRecord::Base
belongs_to :account
has_one :withdrawal_item
end
Kontroler zaimplementował już nasze rozwiązanie; przenieśliśmy wszystkie dodatkowe zapytania do osobnej klasy. Jednak wywołania, których nie mieliśmy w modelu, pozostają nierozwiązaną kwestią. Po kilku zmianach, nasza akcja indeksu wygląda następująco:
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
Rozwiązanie
W przypadku wdrażania dobrych praktyk i konwencji, dobrym pomysłem może być zastąpienie wszystkich podobnych wystąpień danego problemu. Dlatego przeniesiemy zapytanie SQL z akcji indeksu do oddzielnego obiektu zapytania. Nazwiemy to TransactionsFilterableQuery
zajęcia. Styl, w jakim przygotowujemy zajęcia, będzie podobny do tego prezentowanego w TransactionsQuery
. W ramach kod zmiany, bardziej intuicyjny zapis dużych zapytań SQL będzie przemycany za pomocą wielowierszowych ciągów znaków o nazwie heredoc. Dostępne rozwiązanie znajdziesz poniżej:
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
W przypadku zmian w kontrolerze redukujemy masę wierszy poprzez dodanie obiektu zapytania. Ważne jest, aby oddzielić wszystko oprócz części odpowiedzialnej za paginację.
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
Podsumowanie
Obiekt zapytania zmienia wiele w podejściu do pisania zapytań SQL. W ActiveRecord bardzo łatwo jest umieścić całą logikę biznesową i bazodanową w modelu, ponieważ wszystko jest w jednym miejscu. Sprawdzi się to całkiem dobrze w przypadku mniejszych aplikacji. Wraz ze wzrostem złożoności projektu, logikę umieszczamy w innych miejscach. Ten sam obiekt zapytania pozwala na grupowanie zapytań członkowskich do określonego problemu.
Dzięki temu mamy łatwą możliwość późniejszego dziedziczenia kodu, a ze względu na duck typing można te rozwiązania wykorzystywać również w innych modelach. Wadą tego rozwiązania jest większa ilość kodu i fragmentacja odpowiedzialności. Jednak to czy chcemy podjąć takie wyzwanie czy nie, zależy od nas i tego jak bardzo przeszkadzają nam grube modele.