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 railsowych jest zminimalizowanie liczby zbędnych zależności, dlatego też całe środowisko Railsów promuje w ostatnim czasie podejście service object i wykorzystanie technologii PORO (Pure Old Ruby Object). Opis, 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.
Thanks to this, we have an easy possibility of the code’s later inheritance and because of duck typing, you can also use these solutions in other models. The disadvantage of this solution is a larger amount of code and fragmentation of responsibility. However, whether we want to take up such a challenge or not, depends on us and how badly we are disturbed by fat models.