Jedną z prób ogarnięcia asynchroniczności było wykorzystanie mechanizmu zdarzeń (event). Onegdaj istniały takie technologie jak WCF – służący do wystawiania na świat API po http oraz Silverlight, który miał robić to co dzisiaj Flash javascript. I jak się generowało klasy pozwalające korzystać z API WCF w SL to wynikowe klasy przypominały taki kod:
public class Proxy { void Method1(int argument); event EventHandler<EventArgs<Result1>> Method1Completed; event EventHandler<FailureEventArgs> Method1Failed; void Method2(); event EventHandler<EventArgs<Result2>> Method1Completed; event EventHandler<FailureEventArgs> Method1Failed; }
Ale poczułem się staro. Podobny wzorzec był stosowany też w innych technologiach. Ma on wiele wad w porównaniu do kolbaków. Na początek nie istnieje żadna gwarancja że jeżeli wywołam Method1 z argumentem 1 po czym wywołam Method1 z argumentem 2 to event Method1Completed dla 1 przyjdzie przed tym samym eventem dla 2. Na potrzeby dalszych rozważań uznam, że opakowanie w Task i użycie słów async i await sprawi że wywołanie z 2 nie nastąpi przez eventem dla 1, co nie musi być prawdą ale zazwyczaj jest.
Podejście to sprawdza się nie źle w jednym przypadku: akcja się wywołuje jak się uda podejmowane jest jedno zawsze to samo działanie, jak nie to drugie.
// Constructor public ClientClass() { _eventApi = new EventApi(); _eventApi.GetNextIntCompleted += EventApiOnGetNextIntCompleted; _eventApi.GetNextIntFailed += EventApiOnGetNextIntFailed; } private void EventApiOnGetNextIntCompleted(object sender, ResultEventArgs resultEventArgs) { MessageBox.Show(resultEventArgs.Result); } private void EventApiOnGetNextIntFailed(object sender, FailureEventArgs failureEventArgs) { MessageBox.Show("Something gone wrong"); }
Sytuacja jednak komplikuje się znacząco gdy w momencie zakończenia zadania potrzebna jest informacja znana przy jego rozpoczęciu:
public void GetNextIntForClient(Client client) { EventHandler<ResultEventArgs> successHandler = null; EventHandler<FailureEventArgs> failureHandler = null; successHandler = (sender, args) => { _eventApi.GetNextIntCompleted -= successHandler; _eventApi.GetNextIntFailed -= failureHandler; MessageBox.Show(string.Format("{1} was chosen for client {0}", client.Name, args.Result)); }; failureHandler = (sender, args) => { _eventApi.GetNextIntCompleted -= successHandler; _eventApi.GetNextIntFailed -= failureHandler; MessageBox.Show(string.Format("Unable to choose value for client {0}", client.Name)); }; _eventApi.GetNextIntCompleted += successHandler; _eventApi.GetNextIntFailed += failureHandler; _eventApi.GetNextInt(); }
Dla porównania kod oparty o async/await
public async void GetNextIntForClient(Client client) { try { await _taskApi.GetNextInt(); MessageBox.Show(string.Format("{1} was chosen for client {0}", client.Name, args.Result)); } catch (Exception exception) { MessageBox.Show(string.Format("Unable to choose value for client {0}", client.Name)); } }
Opakowanie zdarzeń w zadania nie jest proste i opiera się na kodzie podobnym do tego w metodzie GetNextIntForClient:
public Task<int> GetNextInt() { var completionSource = new TaskCompletionSource<int>(); EventHandler<ResultEventArgs> successHandler = null; EventHandler<FailureEventArgs> failureHandler = null; successHandler = (sender, args) => { _eventApi.GetNextIntCompleted -= successHandler; _eventApi.GetNextIntFailed -= failureHandler; completionSource.TrySetResult(args.Result); }; failureHandler = (sender, args) => { _eventApi.GetNextIntCompleted -= successHandler; _eventApi.GetNextIntFailed -= failureHandler; completionSource.TrySetException(args.Error); }; _eventApi.GetNextIntCompleted += successHandler; _eventApi.GetNextIntFailed += failureHandler; _eventApi.GetNextInt(); return completionSource.Task; }
Sporo kodu… wypadałoby go skrócić. Napisanie uniwersalnego helpera jest jednak trudniejsze niż się wydaje. Jednym z problemów jest, wspomiany w poprzednim artykule, brak mapowania delegat „w locie”. Z moich doświadczeń wynika jednak, że twórcy API zazwyczaj korzystają z typów: EventHandler oraz EventHandler<T>, a nawet jeżeli używają innych typów to są spójni w zakresie swojego API. Więc całkiem rozsądne okazuje się napisanie klasy będzie zawierać powtarzający się kod
sprawi że opakowanie API opartego o eventy w zadania skróci się do:
public async Task<int> GetNextIntWithArg(string arg) { var eventToTask = new EventToTask<ResultEventArgs>( handler => _eventApi.GetNextIntCompleted += handler, handler => _eventApi.GetNextIntCompleted -= handler, handler => _eventApi.GetNextIntFailed += handler, handler => _eventApi.GetNextIntFailed -= handler, () => _eventApi.GetNextInt()); var eventArgs = await eventToTask.Invoke(); return eventArgs.Result; }
Znacznie lepiej ale dalej fatalnie. Problemem, który widać na pierwszy rzut oka, jest konieczność pisania powtarzalnego kodu w którym łatwo się pomylić. W dodatku w przypadku pomyłki programista nie zostanie ostrzeżony podczas kompilacji, ten błąd może zostać także niezauważony przez długi czas przez wszystkie formy testu i objawić się wyciekiem pamięci albo trudnym do odtworzenia crashem. Drugim problemem, którego nie widać, jest fakt iż kompilator wygeneruje na potrzeby tego kodu 6 klas których obiekty będzie tworzył przy wywołaniu tej metody. Zazwyczaj nie jest to duży problem ale można go łatwo ograniczyć zmieniając implementację EventToTask.
Użycie wygląda podobnie:
var eventToTask = new EventToTask<EventApi, ResultEventArgs>( _eventApi, (context, handler) => context.GetNextIntCompleted += handler, (context, handler) => context.GetNextIntCompleted -= handler, (context, handler) => context.GetNextIntFailed += handler, (context, handler) => context.GetNextIntFailed -= handler, context => context.GetNextInt()); var eventArgs = await eventToTask.Invoke(); return eventArgs.Result;
Sposobem na zwiększenie wykrywalności i zmniejszenie występowania problemów związanych z pomyłkami programisty w tym kodzie wydaje się być użycie klasy Expression. Przykładowy kod jest trochę przy długi zapraszam do zapoznania się znim na Githubie
Użycie tej klasy jest dość proste:
var eventToTask = EventToTask .Create(_eventApi) .WithTrigger(context => context.GetNextInt()) .WithResultEvent<ResultEventArgs>((context, handler) => context.GetNextIntCompleted += handler) .WithFailureEvent((context, handler) => context.GetNextIntFailed += handler) .Build(); var eventArgs = await eventToTask.Invoke(); return eventArgs.Result;
jest tylko jeden taki malutki problemik: ten kod się nie kompiluje. Niestety parametr typu Expression<T> nie może przyjąć lambdy zawierającej operator +=. Z drugiej strony po za klasą w której zdarzenie jest deklarowane jedynymi dozwolonymi operatorami które mogą go modyfikować są += i -=.
Podsumowując opieranie asynchroniczności o zdarzenia jest zazwyczaj pomyłką architektoniczną i rodzi problemy. Daje się jednak z nią żyć.
Kody źródłowe
Kod źródłowy projektu stworzonego na potrzeby tej serii udostępniam na Githubie