Kurs Ruby on Rails

Kurs Ruby on Rails – Lekcja 7 – Warstwy abstrakcji

Czego się nauczycie?

  • Czym są warstwy abstrakcji i do czego nam służą
  • Jakie wyróżniamy warstwy abstrakcji
  • Jak je tworzyć
  • Jak je wykorzystać

Niezbędnik kursu:

1. Czym są warstwy abstrakcji i do czego nam służą

Warstwa abstrakcji jest to zestaw funkcji, które pomagają nam ukryć złożoność danego procesu.

Wyobraźmy sobie, że tworzymy aplikację dla biblioteki. Zaczynamy kodować funkcjonalność dodawania książek, ale szybko okazuję się, że przy jej dodawaniu chcemy mieć więcej funkcji, np. dodawanie autora, jeśli podany nie istnieje.

Więc zamiast tworzyć rozbudowane warunki w kontrolerze, możemy całą tą funkcjonalność zenkapsulować w warstwie abstrakcji.

W takim wypadku nasz kontroler zamiast wyglądać tak:

class BooksController < ApplicationController
 def create
  author = Author.find_by(name: author_params[:name])
  if author
    book_params.merge(author_id: author.id)
  else
    new_author = Author.create(author_params)
    book_params.merge(author_id: new_author.id)
  end

  Book.create(book_params)
 end
end

Będzie wyglądał po wywołaniu w nim funkcjonalności, która nam to wszystko ukryje, tak:

class BooksController < ApplicationController
 def create
   CreateBook.new(params).call
 end
end

gdzie wywołujemy odpowiednią warstwę abstrakcji, dzieląc wszystko na mniejsze części. Dodatkowo, jeśli byśmy musieli wykorzystać tą samą funkcjonalność w innym miejscu w naszej aplikacji, wystarczy wtedy wykorzystać ten sam element. Sprzyja to regule DRY (Don’t Repeat Yourself), która to wspomaga ulepszyć naszą aplikację oraz zmniejszyć ilość miejsc, w których mogą wystąpić błędy.

Dzięki temu sprawiamy, że:

  • wszystko wygląda schludniej
  • łatwiej nam znaleźć to czego szukamy, ponieważ mamy wszystko uporządkowane
  • ułatwia nam to pisanie testów jednostkowych
  • zmniejsza ilość miejsc, w których musimy wykonywać zmiany (lub naprawiać błędy)

2. Jakie wyróżniamy warstwy abstrakcji

Dla naszej aplikacji Railsowej możemy wyróżnić następujące warstwy abstrakcji:

  • Service Objects
  • Query Objects
  • Command Objects
  • Presenters/Decorators

Wszystkie z nich możemy określić jako PORO (Plain Old Ruby Object), czyli obiekty służące pojedynczym akcjom wymaganym przez domenę biznesową.

Service Objects

Service Object ma za zadanie wykonanie akcji logiki biznesowej (np. wysłanie maila), bądź obliczeń, które zostaną wykorzystane w innych częściach aplikacji.

Query Objects

Query Object jest to obiekt, którego zadaniem jest wysyłanie zapytań (z ang. query) do bazy danych.

Command Objects

Command Object, służy do zapisu/aktualizacji obiektów w bazie danych.

Presenters/Decorators

Prezentery/Dekoratory służą układaniu dostarczonych danych w odpowiednią formę jaką chcemy wysłać do innej aplikacji, bądź pokazać użytkownikowi.

3. Jak je tworzyć

Service Object

Zacznijmy od stworzenia Service Object, który posłuży nam w sortowaniu postów po dacie ich aktualizacji.

Zaczynamy od utworzenia nowego folderu services w folderze app. Następnie w naszym nowym folderze utworzymy sobie kolejny folder o nazwie posts. Tworzymy go w celu uporządkowania naszych funkcjonalności w kontekście w jakim chcemy je wykorzystywać. Teraz w naszym folderze posts tworzymy plik sort.rb.

Czyli już z samych nazw obiektów możemy się domyśleć, że app/services/posts/sort.rb będzie Service Objectem odpowiadającym za sortowanie postów.

Przejdźmy więc do samego pliku i jego struktury.

class Posts::Sort
 def initialize(posts)
   @posts = posts
 end

 def call
   sort
 end

 private

 def sort
   @posts.order(:updated_at)
 end
end

Teraz wytłumaczmy sobie po kolei co tutaj się dzieje. Przypominam, że w języku Ruby wszystko traktujemy jako obiekt. I z tego powodu dodaliśmy metodę initialize. Dzięki tej metodzie będziemy mogli przy tworzeniu obiektu przekazać wymagane przez nas parametry Posts::Sort.new(Post.all). Następnie w tej metodzie przypisujemy nasze argumenty do zmiennych instancji (te z @), byśmy mogli z nich korzystać w całej naszej klasie.

Metoda call ma na celu wywołanie naszej wyabstrahowanej akcji (tej którą wrzucamy do naszego serwisu). Z założenia chcemy udostępniać na zewnątrz tej warstwy abstrakcji tylko możliwość wywołania całej akcji, bez dostępu do poszczególnych metod. Dlatego w metodzie call wywołujemy metodę prywatną sort.

Czyli wykorzystanie tego serwisu będzie wyglądało następująco Posts::Sort.new(Post.all).call.

Query Object

Teraz wyobraźmy sobie, że chcemy wyświetlać posortowane posty, ale tylko z ostatnich 30 dni. W takim przypadku przyda nam się Query Object, który będzie nam wyciągać tylko takie posty.

Podobnie jak w przypadku Service Objectu, musimy stworzyć odpowiednie foldery. Stwórzmy więc plik w ścieżce app/queries/posts/recent.rb

class Posts::Recent
 RECENT_DAYS = 30

 def call
   recent_posts
 end

 private

 def recent_posts
   Post.where('updated_at > ?', Time.now - RECENT_DAYS.days)
 end
end

W tym wypadku, nie potrzebujemy przekazywać żadnych parametrów, więc metoda initialize jest nam zbędna. Kolejną większą różnicą, w porównaniu z poprzednim serwisem jest stała RECENT_DAYS. Umiejscowiliśmy tę wartość w stałej oraz na samej górze pliku, by łatwo było ją znaleźć i zmodyfikować.

W tym wypadku również jeżeli nie potrzebujemy metody initialize, możemy metodę call oraz recent_posts zmienić w metody klasy, dzięki czemu nie będziemy musieli inicjalizować nowego obiektu.

class Posts::Recent
 RECENT_DAYS = 30

 def self.call
   recent_posts
 end

 private

 def self.recent_posts
   Post.where('updated_at > ?', Time.now - RECENT_DAYS.days)
 end
end

Teraz wywołanie tego pliku będzie wyglądało następująco Posts::Recent.call.

Command Object

Teraz wyobraźmy sobie, że przy okazji tworzenia postu chcemy automatycznie utworzyć dla niego generyczny komentarz.

Stwórzmy plik w ścieżce app/commands/posts/create.rb.

class Posts::Create
 def initialize(params)
   @params = params
 end

 def call
   create_post_with_comment
 end

 private

 def create_post_with_comment
   post = Post.create(@params)
   Comment.create(post_id: post.id, user_id: post.user_id, content: 'Generyczny komentarz')
 end
end

Presenter/Decorator

Aby znaleźć zastosowanie dla prezentera w naszej aplikacji, załóżmy, że chcemy by tytuł naszego postu był zawsze wyświetlany wielkimi literami.

Tworzymy plik w ścieżce app/presenters/post_presenter.rb.

Tutaj już bez folderu dla post, ponieważ nie mamy mniejszego kontekstu dla naszego prezentera.

Możemy tutaj stworzyć klasę podobną jak przy poprzednich obiektach

class PostPresenter
 def initialize(post)
   @post = post
 end

 def title
   @post.title.upcase
 end
end

ale możemy wykorzystać rozwiązanie, które dostarcza nam język Ruby, a mianowicie SimpleDelegator.

SimpleDelegator przekaże wszystkie metody czy atrybuty do konstruktora naszej klasy (metody initialize).

class PostPresenter < SimpleDelegator
 def parsed_title
   title.upcase
 end
end

Zmieniliśmy tylko nazwę metody, by nie nadpisywać title, ponieważ dzięki wykorzystaniu SimpleDelegator wszystkie metody, których nie zdefinowaliśmy w pliku zostaną wydelegowane do obiektu z metody initialize.

I taki prezenter wykorzystamy w następujący sposób.

Post.new(Post.last).parsed_title

4. Jak je wykorzystać

Przejdźmy do naszego PostController i wykorzystajmy nasze obiekty w odpowiednich miejscach.

class PostsController < ApplicationController
 def index
   @posts = Posts::Sort.new(Posts::Recent.call).call
 end

 ...

 def create
   Posts::Create.new(post_params).call
   redirect_to posts_path
 end

 ...

end

Jak możemy zauważyć, nasz command zmniejszył ilość wykorzystanych linijek, lecz przy okazji zabrał nam możliwość wyświetlania błędów oraz przekierowywania do formularza w przypadku ich wystąpienia. Naprawmy to korzystając z obsługi wyjątków.

class Posts::Create
 def initialize(params)
   @params = params
 end

 def call
   create_post_with_comment
 end

 private

 def create_post_with_comment
   post = Post.create!(@params)
   Comment.create(post_id: post.id, user_id: post.user_id, content: 'Generyczny komentarz')
 end
end

Musimy dodać do metody create dla postu !, by wymuszały zwrócenie wyjątku. Teraz pozostaje nam go tylko złapać.

class PostsController < ApplicationController
 def index
   @posts = Posts::Sort.new(Posts::Recent.call).call
 end

 ...

 def create
   Posts::Create.new(post_params).call
   redirect_to posts_path
 rescue ActiveRecord::RecordInvalid => invalid
   @post = invalid.record
   render :new
 end

 ...

end

I może w tym wypadku nie skróciliśmy naszej akcji create w znaczny sposób, ale gdy będziemy chcieli ją rozbudować to pracujemy wtedy tylko na naszym commandzie.

!!! (Aby poprawnie utworzyć obiekt klasy Post musimy wcześniej utworzyć sobie chociaż jednego użytkownika) !!!

Pozostaje nam teraz tylko wykorzystanie prezentera. Przejdźmy do app/views/posts/show.html.erb i go tam użyjmy.

<% presenter = PostPresenter.new(@post) %>
Title: <%= presenter.parsed_title %>
Body: <%= presenter.body %>

Praca własna

  1. Utworzyć, query, które będzie wyciągało komentarze dla danego postu.
  2. Utworzyć serwis, który będzie je sortować po dacie edycji.
  3. Przekazać do app/views/posts/show.html.erb posortowane komentarze dla danego postu.
  4. Stworzyć i zastosować command, który będzie usuwał wraz z postem wszystkie przypisane do niego komentarze.
  5. Stworzyć i zastosować prezenter dla komentarzy, który będzie wyświetlał jego treść zawsze z dużej litery

Przydatne linki

https://ruby-doc.org/stdlib2.5.1/libdoc/delegate/rdoc/SimpleDelegator.html
https://api.rubyonrails.org/classes/ActiveRecord/RecordInvalid.html
https://api.rubyonrails.org/classes/ActiveRecord/Persistence/ClassMethods.html#method-i-create-21

Przeczytaj również o...

Dołącz do naszego zespołu!

Zobacz oferty pracy