ASP.NET MVC: Method chaining in controllers
Zaczynając swoją przygodę z ASP.NET MVC (oraz w ogóle z programowaniem) miałem sporo problemów z utrzymaniem porządku w moich akcjach na kontrolerach. Bardzo często pojawiało się tam mnóstwo warunków i niepotrzebnej logiki. Ten problem trzeba było sensownie rozwiązać, więc z kolegami wypracowaliśmy sobie pewną konwencję, której twardo się trzymaliśmy. Nazywaliśmy ją “biznes akcjami”. Polegało to na tym, że cała logika oddelegowana była do metody napisanej w serwisie, który był wstrzykiwany do konstruktora kontrolera. To podejście miało jednak kilka wad, więc dostałem zadanie które polegało na usprawnieniu tego mechanizmu. Zapraszam do przeczytania tego posta, w którym przedstawię swój sposób na utrzymywanie porządku w kontrolerach.
“Business actions”
Wywoływanie akcji zaczęło sprowadzać się do trzech kroków:
- Wywołanie głównej metody serwisowej
- Wykonanie odpowiedniego zdarzenia w momencie, gdy metoda nie wyrzuciła żadnego wyjątku – sukces
- Wykonanie odpowiedniego zdarzenia w momencie, gdy metoda serwisowa wyrzuciła jakiś wyjątek – fail
Pojawiło się pytanie, jak ugryźć taki problem – wymyśliliśmy “biznes akcje”. Poniżej przykładowy pseudokod:
1 2 3 4 5 6 7 |
public ActionResult DoSomething(SomeModel model) { return BusinessAction( () => _someService.DoSomeOperation(model), () => ActionRedirect("SomeAction", "SomeController", Alert.Create(AlertType.Success, SomeResources.SuccessMessage)), () => ActionView("SomeView", model, Alert.Create(AlertType.Fail, SomeResources.FailMessage))); } |
Niby fajnie, ale moim zdaniem nie jest to czytelne. Przy bardziej skomplikowanych akcjach na pierwszy rzut oka nie widać co tu się tak na prawdę dzieje. W dodatku łatwo się pomylić, wystarczy przypadkiem zamienić kolejność wywoływania metod i trzeba debugować.
Pewnego dnia postanowiliśmy z kolegą usprawnić ten proces. Moim zadaniem było napisanie czytelnego mechanizmu, który w jasny sposób będzie umożliwiał programiście obsłużenie wszystkich trzech kroków w poprawny sposób. Długo myślałem nad tym jak to zrobić i przypomniałem sobie o wzorcu, który powszechnie znany jest z języka JavaScript – Chain of Promises. Dlaczego by nie napisać podobnego mechanizmu dla kontrolerów? No to do dzieła.
Method chaining – podstawowa implementacja
Zacznijmy od zdefiniowania interfejsów które pozwolą nam na wywołanie odpowiednich metod w ustalonej kolejności. Na moje potrzeby zdefiniowałem cztery interfejsy. Pierwszy z nich odpowiada za wykonanie całego mechanizmu. Metoda ta będzie kończyła nasz łańcuch:
1 2 3 4 |
public interface IExecute { ActionResult Execute(); } |
Następnie zdefiniowałem kolejne trzy interfejsy pozwalające wywołać takie akcje jak Invoke (do naszej metody serwisowej), OnSuccess oraz OnFail:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public interface IPromise : IExecute { ISuccess Invoke(Action callback); } public interface ISuccess : IExecute { IFail OnSuccess(Func callback) where T : ActionResult; } public interface IFail : IExecute { IFail OnFail(Func callback) where T : ActionResult; IFail Always(Action callback); } |
Każdy z tych interfejsów dziedziczy po IExecute. Dzięki takiemu zabiegowi możemy wywołać metodę serwisową bez dodatkowych operacji OnSuccess lub OnFail (lub z tylko jedną). Dodatkowo w IFail znalazła się deklaracja metody Always. Jak nazwa wskazuje, możemy przekazać tam akcje, która wykona się niezależnie od tego, czy metoda serwisowa wyrzuci wyjątek, czy nie.
Powyższe interfejsy zaimplementowałem w abstrakcyjnej klasie BaseController, która służy mi jako kontroler bazowy, po którym dziedziczą wszystkie inne kontrolery:
1 2 3 4 |
public abstract partial class BaseController : Controller, IPromise, ISuccess, IFail { // ... } |
Nie chcąc robić za dużego bałaganu w naszej klasie bazowej, utworzyłem osobną klasę Chain, której rolą jest utrzymywanie odpowiednich delegatów dla metod, które chcę wywołać. Dodatkowo zaimplementowałem tam krótkie metody umożliwiające ustawienie odpowiednich operacji:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
public class Chain { private Delegate _failOperation; private Delegate _successOperation; private readonly IEnumerable _alwaysOperations; public Chain(Action operation) { MainOperation = operation; _alwaysOperations = new List(); } public Action MainOperation { get; } public void SetFailOperation(Func operation) { _failOperation = operation; } public Func GetFailOperation() { return (Func)_failOperation; } public void SetSuccessOperation(Func operation) { _successOperation = operation; } public Func GetSuccessOperation() { return (Func)_successOperation; } public void SetAlwaysOperations(IEnumerable operations) { _alwaysOperations.ToList().AddRange(operations); } public void SetAlwaysOperations(params Action[] operations) { _alwaysOperations.ToList().AddRange(operations); } public IEnumerable GetAlwaysOperations() { return _alwaysOperations; } } |
Chain – wykorzystanie
Teraz przyszedł czas na implementację metod z wyżej wymienionych interfejsów, zacznijmy od metody Invoke:
1 2 3 4 5 6 7 8 9 10 11 12 |
public abstract partial class BaseController : Controller, IPromise, ISuccess, IFail { private Chain _chain; public ISuccess Invoke(Action operation) { _chain = new Chain(operation); return this; } // ... } |
Tworzę nową instancję klasy Chain i ustawiam główną operację do wykonania. Metoda zwraca obiekt implementujący interfejs ISuccess (czyli nasz kontroler), więc kolejna metoda jaką wywołamy to OnSuccess:
1 2 3 4 5 6 7 8 9 10 11 |
public IFail OnSuccess(Func success) where T : ActionResult { if (_chain == null) { throw new NoChainWithMainOperationInvokedException(); } _chain.SetSuccessOperation(success); return this; } |
Pojawił nam się tutaj customowy wyjątek. Jest to HttpException, który zabezpiecza nas przed wywołaniem metody OnSuccess lub OnFail bez użycia głównej metody Invoke. Bo jaki jest sens wykonywania jakiejś operacji OnSuccess, jeżeli żadna główna operacja nie została przed tym wykonana? 🙂 Programista, który się zagapi powinien zobaczyć odpowiedni komunikat w swojej przeglądarce informujący go o tym, że o czymś zapomniał… Analogicznie zaimplementowane zostały metody interfejsu IFail:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public IFail OnFail(Func fail) where T : ActionResult { if (_chain == null) { throw new NoChainWithMainOperationInvokedException(); } _chain.SetFailOperation(fail); return this; } public IFail Always(Action callback) { if (_chain == null) { throw new NoChainWithMainOperationInvokedException(); } _chain.SetAlwaysOperations(callback); return this; } |
Czas na implementację metody Execute. Będzie ona odpowiadać za wykonanie się całego mechanizmu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
public ActionResult Execute() { if (_chain == null) { throw new NoChainWithMainOperationInvokedException(); } var failOpertion = _chain.GetFailOperation() ?? (() => default(ActionResult)); var successOperation = _chain.GetSuccessOperation() ?? (() => default(ActionResult)); if (!ModelState.IsValid) { return failOpertion(); } try { _chain.MainOperation.Invoke(); _chain.GetAlwaysOperations() .ForEach(x => x.Invoke()); } catch (BusinessException exception) { ModelState.AddModelError(string.Empty, exception.Message); } if (!ModelState.IsValid) { return failOpertion(); } return successOperation(); } |
Kilka razy wspominałem już o wyrzucanych biznesowych wyjątkach. W moim projekcie zaimplementowałem BusinessException, po którym dziedziczą wyjątki biznesowe. Dzięki takiemu zabiegowi możemy wychwycić złamanie jakiejś reguły biznesowej w aplikacji i na złapaniu takiego wyjątku wyświetlić odpowiedni alert z wiadomością dla użytkownika aplikacji. Inne wyjątki będą łapane i logowane np. przez moduł HTTP i log4net’a.
Wynik końcowy – wykorzystanie zaimplementowanego mechanizmu
Czas na wykorzystanie tego w praktyce. Napiszmy sobie SampleController, który dziedziczy po naszym kontrolerze bazowym, a jego akcje będą wykorzystywały nasz mechanizm:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class SampleController : BaseController { private readonly ISampleService _sampleService; public SampleController(ISampleService sampleService) { _sampleService = sampleService; } public ActionResult DoSomething(SomeModel model) => Invoke(() => _sampleService.DoSomeOperation(model)) .OnSuccess(() => ActionView("SomeView", Alert.Create(AlertType.Success, SomeResources.SuccessMessage))) .OnFail(() => ActionRedirect("SomeAction", Alert.Create(AlertType.Fail), SomeResources.FailMessage))) .Always(() => _sampleService.DoSomeRegularOperation()) .Execute(); // ... } |
Wygląda to zdecydowanie lepiej. Teraz można odczytać, że akcja wywołuje główną metodę z serwisu DoSomeOperation z modelem jako jej parametr. Jeśli się powiedzie, wykona operację ActionView, jeśli wyrzuci wyjątek biznesowy, to wykona się ActionRedirect. Metoda DoSomeRegularOperation wykonana zostanie niezależnie od wyniku głównej operacji.
Oczywiście w bazowym kontrolerze MVC nie ma takich metod jak ActionView oraz ActionRedirect. Są to udekorowane metody podstawowe z dodatkowymi funkcjonalnościami jak wyświetlanie odpowiednich alertów, ale to może być temat na osobny post.
A czy Wy macie jakieś ciekawe sposoby na trzymanie porządku w swoich kontrolerach? Może mój przykład totalnie nie pasuje do Twojej konwencji i masz pomysł na lepszy i ciekawszy mechanizm? Może masz pomysł jak jeszcze usprawnić przedstawiony tutaj kod? Zapraszam do dyskusji 🙂
Świetny pierwszy artykuł, oby takich więcej 🙂
Miło widzieć nowy blog, wpis również interesujący, ale zastanawia mnie po co kontrolerowi wszystkie interfejsy dla execute i po co trzymać chain w kontrolerze, niech metoda invoke zwraca odpowiedni obiekt (fluent interface)
Ostatnio miałem bardzo podobne zadanie do wykonania lecz tyczyło się to Web Api. Pierwsze podejście zrobiłem podobnie jak Ty czyli w Base miałem wszsystkie mozliwe metody (Invoke, Success, Fail ….). Lecz moj przelozony (swietny specialista) nie dal mi approve gdyż jak on stwierdzil po co wprowadzac programiste w blad wystawiajac metody (Success, Fail …) w Base one powinny być dopiero dostępne po wywołaniu Invoke. Tak też zrobiłem, że w Base miałem jedynie metode Invoke ktora to dopiero zawracała interfejs w metodaki (Success, Fail…). Ogolnie fajnie sie czyta i gratuluje bloga 🙂
Można też pozbyć się obiektów i zostawić same funkcje: http://paweltymura.pl/2016/01/13/chain-of-respnsibility-bo-po-co-ci-switch/