Obiekty usługowe: ujarzmianie grubych modeli Rails
Chude kontrolery wepchnęły całą logikę w grube modele, a teraz są nie do opanowania. Obiekty usługowe to najpopularniejsza poprawka — oto jak używać ich dobrze, kiedy pomagają i jak zamieniają się w gorszy bałagan, gdy przesadzisz.
Rails nauczył pokolenie programistów „chude kontrolery, grube modele”, i to była
dobra rada — aż do momentu, gdy modele zrobiły się za grube. Dojrzała aplikacja
Rails niezawodnie wyhodowuje model User na dwa tysiące linii: trwałość, walidacje,
callbacki, reguły biznesowe, logika powiadomień, logika płatności, helpery
raportowe, wszystko zwalone do jednej klasy, bo „to dotyczy użytkownika”. Ta klasa
jest nie do utrzymania w głowie, przerażająca do zmiany i wolna do testowania.
Obiekty usługowe to najpopularniejsza odpowiedź na ten problem, i dobra — ale tylko
jeśli rozumiesz, do czego naprawdę służą. Oto jak ich używamy i jak idą źle.
Problem: gdzie idzie logika biznesowa?
Rails daje jasny dom dwóm rodzajom kodu: trwałość i reguły danych idą do modelu,
obsługa żądania/odpowiedzi do kontrolera. Czego wyraźnie nie daje, to dom dla
procesów biznesowych — wielokrokowych operacji będących faktycznym sensem twojej
aplikacji. „Złóż zamówienie” to nie właściwość rekordu Order; to proces, który
tworzy zamówienie, obciąża kartę, zmniejsza stan magazynu, wysyła potwierdzenie i być
może powiadamia magazyn. Wepchnij to w model, a puchnie; wepchnij w kontroler, a nie
da się tego reużyć ani przetestować w izolacji. Nie ma oczywistego miejsca, więc się
rozłazi.
Obiekt usługowy to po prostu zwykła klasa Ruby, która daje temu procesowi dom. Nic bardziej egzotycznego niż to.
Podstawowy kształt
Obiekt usługowy to PORO (zwykły obiekt Ruby) nazwany od akcji, którą wykonuje, z jedną publiczną metodą, która ją wykonuje:
class PlaceOrder
def initialize(cart, payment_method)
@cart = cart
@payment_method = payment_method
end
def call
ActiveRecord::Base.transaction do
order = create_order
charge_payment(order)
decrement_stock(order)
order
end
end
private
attr_reader :cart, :payment_method
def create_order
Order.create!(line_items: cart.line_items, total: cart.total)
end
def charge_payment(order)
PaymentGateway.charge(order.total, payment_method)
end
def decrement_stock(order)
order.line_items.each { |li| li.product.decrement!(:stock, li.quantity) }
end
end
Kontroler staje się naprawdę chudy — zbiera dane wejściowe, woła usługę i renderuje wynik:
def create
order = PlaceOrder.new(current_cart, params[:payment_method]).call
redirect_to order
rescue PaymentError => e
redirect_to cart_path, alert: e.message
end
Konwencje, które ustaliły się wokół tego: nazwij klasę frazą czasownikową
(PlaceOrder, CancelSubscription, ImportCsv), wystaw jedną publiczną metodę
(call jest idiomatyczne), trzymaj resztę prywatną i pozwól konstruktorowi wziąć
współpracowników. Częstą wygodą jest metoda klasowa, by wołający pisali
PlaceOrder.call(cart, pm).
Dlaczego to lepsze
Trzy konkretne wygrane, nie tylko schludność:
- Proces biznesowy ma nazwę i miejsce. „Jak składamy zamówienie?” ma jedną
odpowiedź: przeczytaj
PlaceOrder. Logika nie jest rozrzucona po callbacku modelu, kontrolerze i obserwatorze. - Jest testowalny w izolacji. Możesz unit-testować
PlaceOrderwprost z koszykiem i metodą płatności — bez kontrolera, bez HTTP, bez widoku. Szybkie testy dla logiki, która liczy się najbardziej. - Model wraca na dietę.
Orderstaje się odpowiedzialny za bycie zamówieniem — swoje dane i niezmienniki — a nie za całą choreografię zamawiania. Taniec wielu modeli żyje w usłudze, która je koordynuje.
Transakcje i porażka są pierwszoklasowe
Obiekty usługowe to naturalny dom dla granic transakcji, i to jedna z ich najlepszych
cech. Proces biznesowy dotykający kilku rekordów zwykle musi być wszystko-albo-nic —
a usługa to dokładnie właściwy zakres do owinięcia w blok transaction, by porażka w
połowie wycofała wszystko. Usługa daje też jedno jasne miejsce do zdefiniowania, jak
proces zawodzi: podnieś błąd domenowy albo zwróć obiekt wyniku, który wołający
inspekcjonuje. Skłaniamy się ku jawnemu wynikowi dla oczekiwanych porażek zamiast
wyjątków-jako-sterowania-przepływem:
Result = Struct.new(:success?, :order, :error)
def call
order = nil
ActiveRecord::Base.transaction do
order = create_order
charge_payment(order)
decrement_stock(order)
end
Result.new(true, order, nil)
rescue PaymentError => e
Result.new(false, nil, e.message)
end
Wołający robi result = PlaceOrder.call(...); if result.success? — porażka jest
częścią interfejsu, nie niespodzianką uciekającą w górę.
Gdzie idzie źle
Obiekty usługowe łatwo kopiować bezmyślnie, a przesadzone produkują bazę kodu gorszą niż gruby model, przed którym uciekałeś. Tryby awarii, których nauczyliśmy się wypatrywać:
- Jedna usługa na akcję kontrolera, odruchowo. Jeśli
UpdateUserNametylko wołauser.update(name: ...), dodałeś warstwę pośredniości, która nic nie kupuje. Obiekty usługowe są do procesów — wiele kroków, wiele obiektów, realna koordynacja. Jednolinijkowe opakowanie metody modelu to czysta ceremonia; pozwól kontrolerowi wołać model. - Anemiczne modele. Wypchnij wszystko do usług, a twoje modele stają się workami kolumn bazy bez zachowania, podczas gdy równoległy wszechświat klas usług trzyma całą logikę. To nie projektowanie obiektowe; to kod proceduralny w kostiumie Ruby. Zachowanie, które naprawdę należy do pojedynczego rekordu (metoda nad jego własnymi atrybutami), powinno zostać na modelu.
- Usługi-bogowie.
PlaceOrder, który rośnie do 400 linii, właśnie znów stał się grubym modelem, przeniesionym. Gdy usługa się rozrasta, rozłóż ją na mniejsze usługi, które woła, a nie na więcej prywatnych metod. - Współdzielony zmienny stan i niejasne wejścia. Trzymaj usługi bezstanowymi poza argumentami konstruktora, z jawnymi wejściami i jednym jasnym wyjściem. Usługa sięgająca do globali albo mutująca swoje wejścia jest trudna do ogarnięcia jako rzecz, którą obiecała być: funkcja z nazwą.
Werdykt
Obiekty usługowe to właściwe narzędzie do realnego i konkretnego problemu: procesów
biznesowych obejmujących wiele modeli i niemających naturalnego miejsca w czystym
Railsie. Użyte do tego — nazwana klasa, jedno call, granica transakcji, jasny wynik
— czynią aplikację dramatycznie łatwiejszą do zrozumienia, testowania i zmiany, i
trzymają twoje modele skupione na byciu modelami. Dyscypliną jest znajomość granicy:
obiekt usługowy jest do procesu, nie do każdej akcji. Sięgaj po niego, gdy logika
obejmuje obiekty i kroki; zostaw modelowi to, co tylko on potrafi. Złap ten osąd
dobrze, a problem grubego modelu po prostu przestaje się zdarzać.