Wiele osób uczy się Rubiego zaczynając od frameworka Rails i niestety jest to najgorszy możliwy sposób nauki tego języka. Nie zrozum mnie źle: Railsy są świetne, pomagają budować aplikacje internetowe szybko i wydajnie bez konieczności zagłębiania się w wiele szczegółów technicznych.
Miło cię poznać!
Wiele osób uczy się Rubiego zaczynając od frameworka Rails i niestety jest to najgorszy możliwy sposób nauki tego języka. Nie zrozum mnie źle: Railsy są świetne, pomagają budować aplikacje internetowe szybko i wydajnie bez konieczności zagłębiania się w wiele szczegółów technicznych. Zapewnia wiele "magii Railsów", która sprawia, że rzeczy po prostu działają. Dla początkującego programisty jest to naprawdę świetne, ponieważ najprzyjemniejszym momentem procesu jest ten, w którym możesz powiedzieć "to żyje!" i zobaczyć, że wszystkie części pasują do siebie, a ludzie używają twojej aplikacji. Lubimy być "twórcami" 🙂 Ale jest jedna rzecz, która odróżnia dobrych programistów od przeciętnych: ci dobrzy rozumieją, jak działają narzędzia, których używają. I przez "zrozumienie narzędzi" nie mam na myśli znajomości wszystkich metod i modułów dostarczanych przez framework, ale zrozumienie, jak to działa, zrozumienie, jak dzieje się "magia Railsów". Tylko wtedy możesz czuć się komfortowo używając obiektów i programując w Railsach. Podstawą programowania obiektowego i tajną bronią, która sprawia, że skomplikowana aplikacja Railsowa staje się łatwiejsza, jest wspomniane już w tytule PORO, czyli Plain Old Ruby Object.
Co tak naprawdę kryje się pod tą nazwą? Czym jest ta wielka tajna broń? Jest to prosta klasa Ruby, która nie dziedziczy po niczym. Tak, tylko tyle i aż tyle.
class AwesomePoro
koniec
Jak mogę ci pomóc?
Nieustannie rozwijasz swoją aplikację i dodajesz nowe funkcjonalności wraz ze wzrostem liczby użytkowników i ich oczekiwań. Dochodzisz do punktu, w którym napotykasz coraz więcej ciemnych miejsc o wyjątkowo pokręconej logice, miejsc, których unikają jak zarazy nawet najodważniejsi deweloperzy. Im więcej takich miejsc, tym trudniej zarządzać i rozwijać aplikację. Standardowym przykładem jest akcja rejestracji nowego użytkownika, która wywołuje całą grupę innych akcji powiązanych z tym zdarzeniem:
- sprawdzanie adresu IP w bazie danych spamu,
- wysłanie wiadomości e-mail do nowego użytkownika,
- dodanie bonusu do konta polecającego użytkownika,
- tworzenie kont w powiązanych usługach,
- i wiele innych...
Próbka kod odpowiedzialny za rejestrację użytkownika może wyglądać następująco:
class RegistrationController < ApplicationController
def create
user = User.new(registration_params)
if user.valid? && ip_valid?(registration_ip)
user.save!
user.add_bonuses
user.synchronize_related_accounts
user.send_email
end
end
end
Okej, masz to zakodowane, wszystko działa, ale... czy cały ten kod jest naprawdę w porządku? Może moglibyśmy napisać go lepiej? Po pierwsze, łamie on podstawową zasadę programowania - Single Responsibility, więc z pewnością moglibyśmy napisać go lepiej. Ale jak? Tutaj z pomocą przychodzi wspomniane już PORO. Wystarczy wydzielić klasę RegistrationService, która będzie odpowiedzialna tylko za jedną rzecz: powiadamianie wszystkich powiązanych usług. Przez usługi będziemy rozumieć poszczególne akcje, które wyodrębniliśmy już powyżej. W tym samym kontrolerze wystarczy utworzyć obiekt RegistrationService i wywołać na nim metodę "fire!". Kod stał się o wiele bardziej przejrzysty, nasz kontroler zajmuje mniej miejsca, a każda z nowo utworzonych klas jest teraz odpowiedzialna tylko za jedną akcję, dzięki czemu możemy je łatwo zastąpić, jeśli zajdzie taka potrzeba.
class RegistrationService
def fire!(params)
user = User.new(params)
if user.valid? && ip_validator.valid?(registration_ip)
user.save!
after_registered_events(user)
end
użytkownik
end
private
def after_registered_events(user)
BonusesCreator.new.fire!(user)
AccountsSynchronizator.fire!(user)
EmailSender.fire!(użytkownik)
end
def ip_validator
@ip_validator ||= IpValidator.new
end
end
class RegistrationController < ApplicationController
def create
user = RegistrationService.new.fire!(registration_params)
end
end
Jednak Plain Old Ruby Object może okazać się przydatny nie tylko dla kontrolerów. Wyobraźmy sobie, że tworzona przez nas aplikacja korzysta z miesięcznego systemu rozliczeń. Dokładny dzień utworzenia takiego rozliczenia nie jest dla nas istotny, musimy jedynie wiedzieć, że dotyczy on konkretnego miesiąca i roku. Oczywiście możesz ustawić dzień na pierwszy dzień każdego miesiąca i przechowywać tę informację w obiekcie klasy "Date", ale ani nie jest to prawdziwa informacja, ani nie potrzebujesz jej w swojej aplikacji. Za pomocą PORO można utworzyć klasę "MonthOfYear", której obiekty będą przechowywać dokładnie te informacje, których potrzebujesz. Co więcej, po zastosowaniu w niej modułu "Comparable" możliwe będzie iterowanie i porównywanie jej obiektów, tak jak w przypadku korzystania z klasy Date.
class MonthOfYear
include Comparable
attr_reader :year, :month
def initialize(month, year)
raise ArgumentError unless month.between?(1, 12)
@year, @month = rok, miesiąc
end
def (other)
[year, month] [other.year, other.month]
end
end
Przedstaw mi Railsy.
W świecie Railsów jesteśmy przyzwyczajeni do tego, że każda klasa jest modelem, widokiem lub kontrolerem. Mają one również swoją dokładną lokalizację w strukturze katalogów, więc gdzie można umieścić naszą małą armię PORO? Rozważmy kilka opcji. Pierwsza myśl jaka przychodzi do głowy to: skoro tworzone klasy nie są ani modelami, ani widokami, ani kontrolerami, to powinniśmy je wszystkie umieścić w katalogu "/lib". Teoretycznie jest to dobry pomysł, jednak jeśli wszystkie pliki PORO wylądują w jednym katalogu, a aplikacja będzie duża, katalog ten szybko stanie się ciemnym miejscem, które strach będzie otworzyć. Dlatego bez wątpienia nie jest to dobry pomysł.
AwesomeProject
├──app
│ ├─kontrolery
│ ├─modele
│ └─widoki
│
└─lib
└─services
#all poro here
Możesz również nazwać niektóre ze swoich klas nie-ActiveRecord Models i umieścić je w katalogu "app/models", a te, które są odpowiedzialne za obsługę innych klas, nazwać usługami i umieścić je w katalogu "app/services". Jest to całkiem dobre rozwiązanie, ale ma jedną wadę: podczas tworzenia nowego PORO za każdym razem będziesz musiał zdecydować, czy jest to bardziej model czy usługa. W ten sposób może dojść do sytuacji, w której mamy dwa ciemne miejsca w aplikacji, tylko mniejsze. Jest jeszcze trzecie podejście, a mianowicie: używanie klas i modułów z przestrzenią nazw. Wystarczy utworzyć katalog o takiej samej nazwie jak kontroler lub model i umieścić w nim wszystkie pliki PORO używane przez dany kontroler lub model.
AwesomeProject
├──app
│ ├─kontrolery
│ │ ├─registration_controller
│ │ └─registration_service.rb
│ │ └─registration_controller.rb
│ ├─models
│ │ ├─settlement
│ │ │ └─month_of_year.rb
│ │ └─settlement.rb
│ └─views
│
└─lib
Dzięki takiemu rozwiązaniu nie trzeba poprzedzać nazwy klasy przestrzenią nazw. Zyskujesz krótszy kod i bardziej logicznie zorganizowaną strukturę katalogów.
Sprawdź mnie!
Miłą niespodzianką jest to, że podczas korzystania z PORO testy jednostkowe aplikacji są szybsze i łatwiejsze do napisania, a później bardziej zrozumiałe dla innych. Ponieważ każda klasa jest teraz odpowiedzialna tylko za jedną rzecz, można szybciej rozpoznać warunki brzegowe i łatwo dodać do nich odpowiednie scenariusze testowe.
describe MonthOfYear do
subject { MonthOfYear.new(11, 2015) }
it { should be_kind_of Comparable }
describe "tworzenie nowej instancji" do
it "inicjalizuje z poprawnym rokiem i miesiącem" do
expect { described_class.new(10, 2015) }.to_not raise_error
end
it "zgłasza błąd, gdy podany miesiąc jest nieprawidłowy" do
expect { described_class.new(0, 2015) }.to raise_error(ArgumentError)
expect { described_class.new(13, 2015) }.to raise_error(ArgumentError)
end
end
end
Mam nadzieję, że jeszcze się spotkamy!
Zaprezentowane przez nas przykłady wyraźnie pokazują, że stosowanie PORO poprawia czytelność aplikacji i czyni je bardziej modułowymi, a w konsekwencji łatwiejszymi w zarządzaniu i rozbudowie. Przyjęcie zasady Single Responsibility ułatwia wymianę poszczególnych klas w razie potrzeby i to bez ingerencji w inne elementy. Sprawia również, że ich testowanie jest prostsze i szybsze. Co więcej, w ten sposób utrzymanie modeli i kontrolerów Rails jest znacznie łatwiejsze, a wszyscy wiemy, że mają one tendencję do niepotrzebnego powiększania się w procesie rozwoju.