Automatyczne zwracanie z serwisu informacji o błędzie

Zadaniem, które wziąłem na warsztat w poprzednim tygodniu, była refaktoryzacja obsługi optimistic concurrency. Do tej pory obsługa konfliktu wersji przy aktualizacji rekordu była obsługiwana za pomocą wyjątku rzucanego z serwisu i łapanego w filtrze MVC. To oczywiście był błąd projektowy, bo przecież wystąpienie optimistic concurrency to spodziewany efekt edycji rekordu, a nie żaden wyjątek. Poprawiając to, stwierdziłem że dobrze będzie przy okazji w ogóle poprawić komunikację na linii serwis-kontroler, tak aby wyjątki z aplikacji nigdy nie docierały do GUI.

Po co?

Jest to potrzebne chociażby dlatego, że w przyszłości warstwa aplikacyjna może przecież pracować na innej fizycznej maszynie niż warstwa prezentacji, więc rzucenie wyjątku na taką odległość niekoniecznie się uda. Z tego powodu lepiej od razu mieć możliwość przesłania informacji o sukcesie/błędzie operacji w postaci obiektu, no i oczywiście najlepiej by było, jeśli będzie się to działo automatycznie. Poza tym, użytkownikowi warto wyświetlać jakieś lokalizowane komunikaty o błędach. Lokalizowane przez nas, a nie zlokalizowaną wersję .NET Frameworka z jego mądrościami ludowymi w stylu: “Odwołaniem do obiektu nie zostało ustawione na wystąpienie obiektu” albo “Do członka wystąpienia klasy nie można odwołać się z obrębu udostępnionej metody lub udostępnionego inicjatora członka bez jawnego wystąpienia klasy”. ;)

Dobra, dość drwin z Microsoftu, bo nie wygram XBoxa.

Typy do przesyłania odpowiedzi

Do tej pory metody ICrudServiceBase zwracały obiekt (GetJakiśtamModel), ID obiektu (Create) , DataPageResult<T> albo niczego nie zwracały (w przypadku operacji Update i Delete). To było złe, więc zacząłem od poprawienia tego.

Najpierw utworzyłem taki enum opisujący możliwe statusy odpowiedzi:

1 public enum CrudOperationState
2 {
3     Undefined,
4     Success,
5     DatabaseError,
6     AccessDeniedError,
7     OtherError,
8     OptimisticConcurrencyError
9 }

Następnie stworzyłem taką prostą hierarchię typów:

 1 public abstract class CrudOperationResultBase
 2 {
 3     public CrudOperationState State { private set; get; }
 4     public string ErrorMessage { get; private set; }
 5 
 6     public bool Succeed
 7     {
 8         get { return this.State == CrudOperationState.Success; }
 9     }
10 
11     protected CrudOperationResultBase(CrudOperationState state, string errorMessage)
12     {
13         this.State = state;
14         this.ErrorMessage = errorMessage;
15     }
16 }
17 
18 public sealed class CrudOperationResult<TResultType> : CrudOperationResultBase
19 {
20     public TResultType Data { get; private set; }
21 
22     public CrudOperationResult(CrudOperationState state, string errorMessage)
23         : base(state, errorMessage)
24     {
25     }
26 
27     public CrudOperationResult(TResultType data)
28         : base(CrudOperationState.Success, null)
29     {
30         this.Data = data;
31     }
32 }
33 
34 public sealed class CrudOperationResult : CrudOperationResultBase
35 {
36     public CrudOperationResult(CrudOperationState state, string errorMessage)
37         : base(state, errorMessage)
38     {
39     }
40 
41     public static CrudOperationResult Success
42     {
43         get { return new CrudOperationResult(CrudOperationState.Success, null); }
44     }
45 }

Jak widać - mamy abstrakcyjną wersję bazową, a do tego dwie konkretne - jedną dla metod bez danych wynikowych, drugą mogącą zwrócić wynik typu generycznego. Mogłem oczywiście zrobić tak, żeby generyczna wersja dziedziczyła z niegenerycznej, ale:

  1. Po pierwsze (o czym wielu programistów nie pamięta) nie dziedziczy się z klas nieabstrakcyjnych (o ile nie jest to konieczne, czyli np. nie hakujemy cudzego frameworka).
  2. Do faktycznych zastosowań stosuje się klasy z końca hierarchii, nigdy bazowe.
  3. Dziedziczenie z klasy abstrakcyjnej wymusza na klasie pochodnej zaimplementowanie konkretnych konstruktorów. Dzięki czemu mamy pewność, że każdy obiekt klasy pochodnej będzie mógł zostać stworzony w ten sam sposób. To bardzo ułatwia np. atuomatyzację tworzenia obiektów.

Ostatni punkt jest dość istotny, bo CrudOperationResultBase stało się klasą bazową dla DataPageResult<T>, czyli klasy zawierającej dane wypełniające grida. Klasa ta wygląda teraz tak:

 1 public sealed class DataPageResult<T> : CrudOperationResultBase
 2 {
 3     public IList<T> Items { get; private set; }
 4     public PagingInfo PagingInfo { get; private set; }
 5 
 6     public DataPageResult(IList<T> items, int pageNumber, int pageSize, int totalItemsCount)
 7         : base(CrudOperationState.Success, null)
 8     {
 9         this.Items = items;
10         this.PagingInfo = new PagingInfo(pageNumber, pageSize, totalItemsCount);
11     }
12 
13     public DataPageResult(CrudOperationState state, string errorMessage) 
14         : base(state, errorMessage)
15     {
16     }
17 
18     public override string ToString()
19     {
20         var sb = new StringBuilder("DataPage");
21         sb.AppendLine("PagingInfo: " + this.PagingInfo);
22         sb.AppendLine("Items:");
23         foreach (var item in this.Items)
24         {
25             sb.AppendLine(item.ToString());
26         }
27 
28         return sb.ToString();
29     }
30 }

Czyli poza starym konstruktorem obsługującym “pozytywny” przypadek otrzymania danych z bazy, doszedł nowy konstruktor z klasy bazowej, który pozwala na zwrócenie informacji o błędzie.

Zastanawiałem się przez pewien czas, czy to dobry pomysł, czy nie lepiej byłoby tworzyć CrudOperationResult<DataPageResult<T>>, ale stwiedziłem, że wymusi to zmianę interfejsów serwisowych, a co za tym idzie - konieczność poprawienia połowy aplikacji i większości testów, nie dając w zamian nic.

Nowa sygnatura ICrudServiceBase

Po tych zmianach trzeba poprawić sygnaturę ICrudServiceBase oraz klasy go implementującej na taką:

 1 public interface ICrudServiceBase<TGridModel, TDetailsModel, TEditModel, TCreateModel>
 2     where TGridModel : IGridModelBase
 3     where TDetailsModel : IDetailsModelBase
 4     where TEditModel : IEditModelBase
 5     where TCreateModel : ICreateModelBase
 6 {
 7     DataPageResult<TGridModel> GetDataPage(DataRequest<TGridModel> request);
 8     CrudOperationResult<TCreateModel> GetCreateModel();
 9     CrudOperationResult<TEditModel> GetEditModel(int id);
10     CrudOperationResult<TDetailsModel> GetDetailsModel(int id);
11     CrudOperationResult<int> Create(TCreateModel createModel);
12     CrudOperationResult Update(TEditModel editModel);
13     CrudOperationResult Delete(int id);
14 }

Poprawka TransactionManagingInterceptora

Kontrakty za mną, teraz czas na implementacje. Ponieważ od zawsze uwazam, że w aplikacji powinno być jedno miejsce na komunikację z bazą oraz jedno miejsce na łapanie wyjątków, od początku w PizzaMVC stosuję takie podejście. Dzięki uprzejmości Autofaca i jego kompatybilności z Windsorowymi interceptorami powstała klasa TransactionManagingInterceptor. Autofac opakowuje wywołania wszystkich metod serwisów oznaczonych atrybutem Transactional właśnie tym interceptorem. (To jest przyczyna, dla której metody CrudServiceBase są wirtualne.) Interceptor zaś:

  1. Sprawdza, czy jest już otwarta transakcja w ramach obecnego UoW, jeśli nie, to ją otwiera.
  2. Wykonuje kod opakowanej metody.
  3. Zatwierdza transakcję (a przynajmniej próbuje).

Jeśli podczas powyższych operacji nastąpił wyjątek, to do tej pory interceptor go łapał, logował… i rzucał dalej. Przy okazji StaleObjectException z NHibernate opakowywał w mój własny OptimisticConcurrencyException. Szczerze mówiąc, nie wiem, co piłem, gdy to wymyśliłem, ale na wszelki wypadek od dzisiaj niczego mocniejszego od herbaty nie ruszam.

Teraz czas na dobrą zmianę - w przypadku wystąienia wyjątku należy:

  1. Jeśli metoda zwraca CrudOperationResult, CrudOperationResult<T> albo DataPageResult<T> - utworzyć obiekt tego typu z odpowiednim statusem błędu oraz komunikatem.
  2. W przypadku innych metod (a takie mogą się przecież znaleźć w serwisie), po prostu rzucić wyjątek dalej.

Wygląda to tak:

 1 try
 2 {
 3     iAmTheFirst = this.BeginTransactionIfNeeded();
 4     invocation.Proceed();
 5     this.TryCommit(iAmTheFirst, invocation);
 6 }
 7 catch (Exception ex)
 8 {
 9     this.logger.Error(ex, "Exception in method: {0}", invocation.Method.GetFullMethodName());
10     this.RollbackTransaction(iAmTheFirst, invocation);
11 
12     if (InvocationHelper.IsKnownCrudOperation(invocation))
13     {
14         invocation.ReturnValue = InvocationHelper.BuildReturnValueForError(invocation, ex);
15     }
16     else
17     {
18         throw;
19     }
20 }

Bądźmy szczerzy - to nie jest piękny kod. Ale też trudno to zrobić dużo lepiej, bo throw niestety musi być w catch. :( W każym razie - nieważne, czy czytamy dane, wstawiamy, kasujemy czy aktualizujemy, InvocationHelper ma utworzyć odpowiedni obiekt wynikowy. Robi to tak:

 1 public static object BuildReturnValueForError(IInvocation invocation, Exception ex)
 2 {
 3     var returnType = invocation.Method.ReturnType;
 4 
 5     var resultTypeToCreate = returnType;
 6     if (returnType.IsGenericType)
 7     {
 8         var genericTypeDefinition = returnType.GetGenericTypeDefinition();
 9         var genericArguments = returnType.GetGenericArguments();
10         resultTypeToCreate = genericTypeDefinition.MakeGenericType(genericArguments);
11     }
12 
13     var state = GetCrudOperationStateForException(ex);
14 
15     object crudOperationResult = Activator.CreateInstance(resultTypeToCreate, state, ex.Message);
16     return crudOperationResult;
17 }

Piękno prostoty w całej okazałości - parę linijek banalnej refleksji i nie trzeba mieć try-catcha powielającego praktycznie ten sam kod (otwarcie transakcji, właściwa akcja, commit transakcji, obsługa błędów i ifologia) w każdej metodzie serwisu. Zaoszczędzone dziesiątki liniek nikomu niepotrzebnego kodu.

Warstwa prezentacji

Do tej pory warstwa prezentacji otrzymywała wyjątek, więc w przypadku zwykłych akcji łapał go UniversalExceptionFilter, a w przypadku żądań służących do pobrania danych do grida albo usunięcia rekordu, zajmowało się tym jQuery. Po zmianie interceptora, nie ma już wyjątków, więc trzeba ręcznie obsłużyć zwrócone statusy. Zmiany w warstwie prezentacji nie były duże. Po prostu dodałem kilka komunikatów o błędach do zasobów aplikacji. A w akcjach kontrolera CrudControllerBase sprawdzam, czy operacja się powiodła. Jeśli tak, to pokazuję dane/przekierowanie. Jeśli nie, to wyświetlam komunkat o błedzie. W przypadku JSONa, zwracam odpowiedni komunikat i ustawiam Response.StatusCode na HttpStatusCode.BadRequest, aby jQuery wiedziało, że ma wyświetlić błąd.

Opublikowano: