Wyobraźcie sobie, że z kontrolera wywołujecie obiekt A, który woła obiekt B, który woła obiekt C. Obiekt C odpowiada za walidację danych, które otrzymuje obiekt B i obiekt C dochodzi do wniosku, że obiekt B nie będzie w stanie z tymi danymi pracować, więc rzuca wyjątek – po tej walidacji miał zamiar wykonać inne, ale w przypadku takiej niezgodności reszta walidacji nie ma sensu.
Gdzie złapalibyście ten wyjątek? Generalnie kiedy jest on rzucony, to chcę, żeby obiekty C, B i A zakończyły swoją pracę, a kontroler zwrócił odpowiedni kod HTTP.
Do głowy przychodzą mi trzy opcje:
Złapać ten wyjątek w obiekcie B, po czym reraisować go, później złapać go w A i zrobić to samo. Dzięki temu kontroler nie wie, co siedzi pod spodem i nie musi łapać wyjątku A::C::FilesizeTooBig, a jedynie wyjątek A::FilesizeTooBig (od obiektu, z którym bezpośrednio współpracuje). Nie podoba mi się to z tego względu, że musiałbym ten wyjątek łapać na każdej warstwie i później przy zmianie tego wyjątku (np. oprócz message chcę jeszcze przekazywać ID wadliwych plików) musiałbym listę z ID przekazywać również na każdym poziomie. Poza tym traci się kontekst (stacktrace pokazuje, że błąd pochodzi z A, a nie z C).
Złapać wyjątek w kontrolerze i zwrócić odpowiedni kod. Problem jest taki, że kontroler musi wtedy wiedzieć, że A korzysta z B, a B z C i obiekt C może rzucić określonym wyjątkiem – kontrargumentem może być to, że kontroler i tak musi wiedzieć o wszystkich wyjątkach, jakie mogą pojawić się przy użyciu obiektu A (żeby je jakoś obsłużyć). Drugi problem: rzucanie wyjątków może być nawet 10 razy wolniejsze od zwykłego ifa.
Nie używać wyjątków, tylko zastąpić je czymś w stylu status objects, które będą mówiły, czy operacja się udała. Problem z tym widzę taki, że kiedy coś w C się popsuje, to jak już pisałem, chciałbym natychmiastowo skończyć pracę C, B i A. Wtedy obiekt C musiałby w przypadku błędów zwrócić status object (i określić w nim, co poszło nie tak) oraz zrobić jakiegoś returna, następnie obiekt B po wywołaniu C znów musiałby sprawdzić, czy wszystko jest okej i ewentualnie zwrócić status object, później obiekt A zrobiłby to samo i na końcu kontroler musiałby rozróżnić, co poszło nie tak, tylko no właśnie – jak? Do głowy przychodzi mi jedynie operowanie na stringach lub symbolach, co jest podatne na literówki.
Osobiście najbardziej przemawia do mnie opcja numer dwa, ale ciągle gryzie mnie to, że ten biedny kontroler musi się zagłębiać w tajniki działania obiektu A.
Co myślicie na ten temat?
PS Chciałbym tylko dodać, że przy jednozdaniowych wypowiedziach w stylu “Don’t use exceptions for control flow” lub “Leave exceptions for exceptional situations” mile są widziane objaśnienia mówiące o tym, jak zastąpić wyjątki w tym przypadku czymś mniej wyjątkowym.
Wydaje mi się, że wszystko sprowadza się do tego ile taki wyjątek kosztuje. W Pythonie naturalne jest rzucanie wyjątkami i łapanie ich tam gdzie trzeba, bo jest to dosyć tanie i wygodne rozwiązanie. W C rzucasz wyjątek i łapiesz go w A, a patrząc na to jaki to typ wyjątku, albo po tym jaki jest komunikat błędu wiesz dokładnie co poszło nie tak.
W Rubym tanie to nie jest, więc po prostu w przypadku błędu walidacji trzeba zwrócić odpowiedni kod kontrolny w C, wyłapać go w B i zwrócić dalej do A. Ale ja bym pewnie został przy wyjątkach bo “it’s easier to ask for forgiveness than permission” i kod jest wtedy czytelniejszy.
W kodzie który będzie miał szansę go z gracją obsłużyć, czyli przygotować i wyświetlić klientowi (przeglądarce) komunikat o błędzie.
Czyli pewnie w kontrolerze.
Kontroler nie musi wiedzieć (ba, nie powinien) jaka jest hierarchia klas i kompozycja obiektów. Od tego jest backtrace, jeśli już potrzebujesz tę informację wyświetlić, albo przekazywanie “opisu” wyjątku.
W tym przypadku często zagnieżdżam klasy, na przykład use case korzysta z value objectu, który raczej nie jest reużywalny, a value object z kolei definiuje ValidationError, przez co mam w kontrolerze dużo kodu w stylu rescue A::B::ValidationError, A::C::NotEnoughSpace, … i właśnie dlatego trochę kręcę nosem na to, że w kontrolerze strasznie “głęboko” w przestrzeni nazw sięgam po klasy tych wyjątków.
Jeśli ktoś będzie chciał skorzystać z mojej klasy, to ryzykuje otrzymaniem kilku różnych wyjątków, co nie jest oczywiste na pierwszy rzut oka (taka osoba musiałaby zajrzeć do testów tych kilku różnych klas lub musiałbym udokumentować te wyjątki w klasie A).
Mimo wszystko i tak wydaje mi to się lepsze niż wspomniane reraisowanie wyjątków. Póki co staram się stosować w miarę pragmatyczne podejście i reraisować wyjątki tylko w miejscach, gdzie ma to sens (na przykład adapter korzystający z jakiegoś gema).
Według mnie takie rzeczy jak walidacja nie powinny być obsługiwane przez wyjątki! Zaraz ktoś powie: ale przecież railsy rzucają wyjątek ActiveRecord::RecordInvalid gdy obiekt nie jest poprawny w przypadku wywołania save!. Tylko, że ten wyjątek zostawiłbym na sytuacje typu:
def do_smth(foo)
do_other_thing(foo)
foo.bar = :bla
foo.save!
end
To gdzie tu ten wyjątek? Ano nigdzie. Pisząc ten kod zakładamy, że wszystko jest ok. Ale gdyby jakimś cudem metoda do_other_thing zaczęła robić niebezpieczne rzeczy (np. ustawiać atrybut obiektu foo na coś niepoprawnego) to lepiej żeby poszedł wyjątek niż nie (dlatego zawsze używamy save! zamiast save w takich sytuacjach, prawda?).
Inaczej mówiąc są sytuacje kiedy sprawdzamy poprawność danej operacji bo taka jest logika, a innym razem nie robimy tego bo nie bylibyśmy i tak w stanie nic ze zwróconą wartością zrobić.
Jeśli chodzi o Twój przypadek to widzę 2 wyjścia:
piszemy tak obsługę walidacji w A, B, C by była zgodna z Twoimi oczekiwaniami
może jednak potrzebujesz zupełnie innej klasy obok (jakiś FormObject), która na 1 poziomie dokona walidacji, a gdy wszystko jest ok to utworzy odpowiednie obiekty (Twoje A -> B -> C pachnie mi nested_attributes ;))