Optymalizacja kodu za pomocą Query Objects
Niejednokrotnie w swojej pracy spotkaliście się zapewne z przeładowanymi modelami i ogromną ilością wywołań w kontrolerach. W tym artykule chciałbym zaprezentować proste rozwiązanie bazujące na znanej już wiedzy w środowisku rails.
Tomasz Szkaradek. Expert Ruby Developer oraz Development Manager w Codest. Zawodowo z programowaniem związany od 8 lat. Szkoli i dzieli się wiedzą z innymi. W latach 2017-2018 pełnił rolę mentora w szkole programowania Code Sensei.
Bardzo ważnym aspektem w aplikacji raislowej jest minimalizacja ilości nadmiarowych zależności, dlatego też całe środowisko rails w ostatnim czasie promuje podejście z service object i użycie metody PORO, czyli Pure Old Ruby Object. Opis jak użyć takiego rozwiązania w swoim projekcie możecie przeczytać tutaj. Natomiast w tym artykule rozwinę ten koncept i dostosuję do poruszanego problemu.
Spis treści
Problem
W hipotetycznej aplikacji mamy skomplikowany system transakcji. Model reprezentujący każdą transakcję posiada zestaw scopów, które pomagają pobierać dane. Jest to wspaniałe ułatwienie pracy, ponieważ wszystko znajduje się w jednym miejscu. Jednak do czasu. Niestety, wraz z rozwojem aplikacji rozwija się skomplikowanie projektu. Scopy już nie mają prostych odwołań where, brakuje nam danych, zaczynamy wczytywać relacje. Po jakimś czasie przypomina to niestety skomplikowany system luster. A co gorsza, nie wiemy jak należy robić wielolinijkowe lambdy!
Poniżej znajduje się rozbudowany już model aplikacji. Przechowywane są w nim transakcje systemu płatniczego. Jak widać, już w tym momencie zaczyna być problematyczny.
```ruby class Transaction < ActiveRecord::Base belongs_to :account has_one :withdrawal_item scope(:for_publishers, lambda do select("transactions.*") .joins(:account).where("accounts.owner_type = 'Publisher'") .joins("JOIN publishers ON owner_id = publishers.id") end) scope :visible, -> { 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, natomiast wraz ze zwiększającą się skalą naszego projektu, kontrolery również zaczynają “puchnąć”. Popatrzmy na przykład poniżej:
```ruby 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 linijek chainowanych metod, są też dodatkowe joiny, których nie chcemy przeprowadzać w wielu miejscach, tylko w tym konkretnym. Załączone dane są później wykorzystywane przez metodą apply_filters, która dorzuca odpowiednie zawężanie danych na podstawie parametrów GET. Oczywiście część z tych odwołań możemy jak najbardziej przenieść do scopa, ale czy to nie jest właśnie problem, który próbujemy rozwiązać?
Rozwiązanie
Skoro już wiemy, że mamy problem, musimy przystąpić do działania. Bazując na zamieszczonym we wstępie artykułu odnośniku, zastosujemy tutaj podejście z PORO. W tym wypadku takie podejście nazywa się query object. Jest to rozwinięcie konceptu service objects.
Utwórzmy nowy katalog o nazwie services znajdujący się w katalogu apps naszego projektu. W nim utworzymy klasę o nazwie TransactionsQuery
.
```ruby class TransactionsQuery end ```
W następnym kroku należy utworzyć initializer, w którym będziemy tworzyć domyślną ścieżkę wywołania dla naszego obiektu.
```ruby class TransactionsQuery def initialize(scope = Transaction.all) @scope = scope end end ```
Dzięki temu mamy możliwość przeniesienia relacji z active record do naszego obiektu. Teraz spokojnie jesteśmy w stanie przenieść wszystkie nasze scopy do klasy, które są potrzebne tylko w przedstawionym kontrolerze.
```ruby 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
Brakuje jeszcze najważniejszej części, czyli zebrania danych w jeden ciąg i upublicznienie interfejsu. Metodą, w której wszystko skleimy nazwiemy call. Co ważne, w niej skorzystamy ze zmiennej instancyjnej @scope, gdzie znajduje się scope naszego wywołania.
```ruby class TransactionsQuery ... def call visible(@scope) .then(&method(:active)) .then(&method(:for_publishers)) .order(:created_at) end private ... end ```
Cała klasa prezentuje się w następujący sposób:
```ruby 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 ```
Model po wszystkich porządkach wygląda zdecydowanie lżej. W nim skupiamy się tylko i wyłącznie na walidacji danych i relacjach pomiędzy innymi modelami.
```ruby class Transaction < ActiveRecord::Base belongs_to :account has_one :withdrawal_item end ```
W kontrolerze udało się już wdrożyć nasze rozwiązanie, przeniesione zostały wszystkie dodatkowe zapytania do osobnej klasy. Natomiast wciąż nierozwiązaną kwestią pozostają wywołania, które nie znajdowały się w modelu. Akcja index po zmianach wygląda w następujący sposób:
```ruby 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, optymalnym rozwiązaniem jest wymiana wszystkich podobnych wystąpień danego problemu, dlatego też w tym wypadku przeniesione zostaną zapytania sql z akcji index do osobnego query object. Taką klasę nazwiemy TransactionsFilterableQuery
. Styl, w jakim przygotujemy klasę będzie zbliżony do tego, który był zaprezentowany w TransactionsQuery
. W ramach zmian w kodzie przemycony zostanie bardziej intuicyjny zapis dużych zapytań sql, za pomocą wielolinijkowych łańcuchów znaków zwanych heredoc. Rozwiązanie dostępne jest poniżej:
```ruby 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 dużą ilość linijek, dzięki dodaniu queryobject. Ważne jest, aby wydzielić wszystko oprócz części odpowiedzialnej za paginację.
```ruby 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
Query Object wiele zmieniają w podejściu do pisania zapytań sql. W ActiveRecord bardzo łatwo jest umieszczać całą logikę biznesową i bazodanową w modelu. Wszystko znajduje się w jednym miejscu, natomiast to jest dobre dla mniejszych aplikacji. Wraz ze wzrostem skomplikowania projektu wydzielamy logikę do innych miejsc. Same query object pozwalają grupować zbiory zapytań składowych w konkretnym problemie. Dzięki czemu mamy łatwą możliwość późniejszego dziedziczenia kodu i dzięki duck typing można również stosować te rozwiązania w innych modelach.
Wadą takiego rozwiązania jest większa ilość kodu i rozdrobnienie odpowiedzialności, natomiast to czy chcemy podjąć takie wyzwanie zależy od nas i tego, jak bardzo przeszkadzają nam fat models.
Artykuł został pierwotnie opublikowany na codesthq.com/blog/. Zdjęcie główne artykułu pochodzi z kaboompics.com.