Backend

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.

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.


baner

Artykuł został pierwotnie opublikowany na codesthq.com/blog/. Zdjęcie główne artykułu pochodzi z kaboompics.com.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/optymalizacja-kodu-za-pomoca%cc%a8-query-objects/" order_type="social" width="100%" count_of_comments="8" ]