Application Events

5

Znany pewnie większości z Was Udi Dahan przedstawił koncepcję Domain Events, która zainspirowała mnie do implementacji rozwiązania opartego na podobnych założeniach. Ayende Rahien z kolei jakiś czas temu zachwycił się kawałkiem kodu z projektu Mass Transit… i po zastanowieniu się nad jego bardzo fajną konstrukcją postanowiłem przy okazji skorzystać z czegoś podobnego. Do tego dorzucimy mikro-fluent interface (bardziej dla zabawy niż prawdziwego pożytku) i zobaczymy czy w efekcie uzyskamy coś wartego uwagi.

ATTENTION! Lots of code approaching!

"Core" mechanizmu przemianowałem z Domain Events na Application Events z tego prostego względu, że w moim przypadku takie nazewnictwo bardziej oddaje cel pisanego kodu. Trudno chyba komunikat UserLoggingIn nazwać zdarzeniem domenowym, a aplikacji – jak najbardziej.

Ale o co tak naprawdę chodzi? Pokrótce: o to, aby w jednym miejscu zdefiniować zachodzące w systemie zdarzenia i umożliwić ich obsługę z poziomu różnych, niezwiązanych ze sobą modułów, które nie muszą nawet wiedzieć o swoim istnieniu. Wywołanie przykładowego, wspomnianego już zdarzenia UserLoggingIn może wystąpić w serwisie AuthenticationService, a jego obsługa w najróżniejszych częściach systemu. A po co nam takie zdarzenie? Cóż… po to, aby móc różnie na nie zareagować. A jeśli dodamy do niego funkcjonalność Cancel, to uzyskamy w efekcie bardzo elastyczną infrastrukturę pozwalającą na weryfikowanie warunków panujących w aplikacji podczas rozpoczynania akcji wraz z pominięciem jej faktycznego wykonania w razie niesprzyjających okoliczności.

Podejście klasyczne, "proceduralne" – fe

Przejdźmy do kodu. Standardowo metoda uwierzytelniająca użytkownika mogłaby wyglądać jakoś tak:

  1:  public void Authenticate(string userName, string password)
  2:  {
  3:  	User user = _usersRepository.GetUser(userName);
  4:  
  5:  	if (user == null || user.Password != password)
  6:  	{
  7:  		throw new AuthenticationException("Invalid credentials");
  8:  	}
  9:  
 10:  	// authentication logic...
 11:  }

Uwaga: mam nadzieję, że każdy zdaje sobie sprawę z konieczności zastąpienia instrukcji user.Password == password w prawdziwym systemie czymś bardziej wyrafinowanym, ale nie tym się teraz zajmujemy.

I ten kod jest właściwie… normalny. Co jednak w sytuacji, gdy dochodzi nam dodatkowe wymaganie, niezwiązane bezpośrednio z koniecznością podania poprawnego loginu i hasła? Dajmy na to… oprogramowanie sprzedajemy z licencją określającą liczbę jednocześnie zalogowanych użytkowników należących do roli Editors (co wcale nie jest bez sensu). Niby wystarczy w tej metodzie dopisać kolejnego if-a:

  1:  if (user.IsInRole("Editor") && CurrentLicense.EditorsLimit <= SystemData.EditorsCount)
  2:  {
  3:  	throw new AuthenticationException("License does not allow any more Editors!");
  4:  }

Ale można chyba z dużą dozą pewności stwierdzić, że jeśli w ten sposób będziemy podchodzić do rozwijania systemu to bardzo prędko uzyskamy jedynie lokalne programistyczne piekiełko, a poziom przyjemności czerpanej z życia codziennego, definiowany w sporym stopniu przez zawodowe obowiązki, ulegnie znacznemu obniżeniu.

Zdarzenia aplikacji – wywołanie zdarzenia

Ładnym rozwiązaniem wydaje mi się zastosowanie punktu, pod który może podpiąć się moduł odpowiadający za bieżącą licencję aplikacji… jak również jakakolwiek inna część wstrzykująca swoje reguły w proces uwierzytelniania, żeby całość metody uwierzytelniającej wyglądała tak:

  1:  public void Authenticate(string userName, string password)
  2:  {
  3:  	User user = _usersRepository.GetUser(userName);
  4:  
  5:  	if (user == null || user.Password != password)
  6:  	{
  7:  		throw new AuthenticationException("Invalid credentials");
  8:  	}
  9:  
 10:  	var e = new UserLoggingIn(user);
 11:  
 12:  	ApplicationEventsManager.Raise(e);
 13:  	if (e.Cancelled)
 14:  	{
 15:  		throw new AuthenticationException(e.CancelReason);
 16:  	}
 17:  
 18:  	// authentication logic...
 19:  }

Myślę, że jest to całkiem dobry przykład zasady Open-Closed Principle: możemy zmodyfikować proces logowania do systemu bez bezpośredniej ingerencji w kod za niego odpowiedzialny.

Implementacja zdarzenia

Zacznijmy analizować kod od linijki 10, czyli klasy UserLoggingIn:

  1:  public class UserLoggingIn : CancellableEvent
  2:  {
  3:  	public readonly User User;
  4:  
  5:  	public UserLoggingIn(User user)
  6:  	{
  7:  		User = user;
  8:  	}
  9:  }

Kod, jak widzimy, jest naprawdę banalny. Klasy obsługujące to zdarzenie dostają wszystkie niezbędne informacje, czyli użytkownika pragnącego zalogować się do systemu. Dajemy im możliwość prostego anulowania operacji poprzez dziedziczenie z CancellableEvent:

  1:  public abstract class CancellableEvent : IApplicationEvent
  2:  {
  3:  	public bool Cancelled { get; private set; }
  4:  	public string CancelReason { get; private set; }
  5:  
  6:  	public void Cancel(string reason)
  7:  	{
  8:  		if (Cancelled)
  9:  			return;
 10:  
 11:  		Cancelled = true;
 12:  		CancelReason = reason;
 13:  	}
 14:  }

Przyjęte przeze mnie rozwiązanie uznaje pierwszą instrukcję anulującą za nieodwołalną i obowiązującą, a jej powód (CancelReason) jest dostępny z poziomu klasy wywołującej zdarzenie. Można to oczywiście w prosty sposób zmienić zastępując CancelReason kolekcją agregującą przekazywane stringi.

IApplicationEvent to tylko tzw. marker interface:

  1:  public interface IApplicationEvent
  2:  {
  3:  	
  4:  }

Obsługa zdarzenia

No dobrze, a jak wygląda obsługa takiego zdarzenia? Tutaj właśnie lekko zapożyczyłem z Mass Transit (Consumes.cs) bawiąc się trochę konstrukcją klas zagnieżdżonych:

  1:  public abstract class Handles<T> where T : IApplicationEvent
  2:  {
  3:  	public abstract void Handle(T e);
  4:  
  5:  	public abstract class Selected : Handles<T>
  6:  	{
  7:  		public abstract bool Accept(T e);
  8:  		public abstract void PerformHandle(T e);
  9:  
 10:  		public override void Handle(T e)
 11:  		{
 12:  			if (Accept(e))
 13:  				PerformHandle(e);
 14:  		}
 15:  	}
 16:  }

Normalne klasy obsługujące zdarzenie po prostu dziedziczą z Handles i implementują metodę Handle, nic prostszego. W omawianym przypadku będą nas interesowały wyłącznie próby zalogowania użytkowników z przypisaną rolą Editor. Owszem, można by było bez problemu napisać jednego if-a po wejściu do Handle, jednak mi o wiele bardziej podoba się przedstawiona wyżej koncepcja. Zagnieżdżona, również abstrakcyjna, klasa Selected dziedziczy z Handles wymuszając na swoich dzieciach rozbicie obsługi zdarzenia na dwa etapy: weryfikacji czy w ogóle są tą konkretną instancją zdarzenia zainteresowane, oraz właściwymi instrukcjami na nie reagującymi. Takie rozbicie odpowiedzialności jest według mnie bardziej czytelne, a do tego jak fajnie wygląda deklaracja klasy CheckEditorsLimit:

  1:  public class CheckEditorsLimit : Handles<UserLoggingIn>.Selected
  2:  {
  3:  	public override bool Accept(UserLoggingIn e)
  4:  	{
  5:  		return e.User.IsInRole("Editor");
  6:  	}
  7:  
  8:  	public override void PerformHandle(UserLoggingIn e)
  9:  	{
 10:  		if (CurrentLicense.EditorsLimit <= SystemData.EditorsCount)
 11:  		{
 12:  			e.Cancel("License does not allow any more Editors!");
 13:  		}
 14:  	}
 15:  }

Uwaga: CurrentLicense i SystemData zaimplementowałem dla przykładu jako statyczne "helpery", żeby nie zaciemniać obrazu syfem  związanym z zależnościami i ich rozwiązywaniem.

Rejestracja zdarzenia

OKej, ale skąd aplikacja wie, że akurat w momencie wystąpienia UserLoggingIn ma uruchomić ten handler? Trzeba go oczywiście w jakiś sposób zarejestrować w systemie. Można to rozwiązać na kilka sposobów. Najbardziej naturalne wydaje się odpowiednie skonfigurowanie Inversion of Control, jednak z pewnych względów skręciłem z tej ścieżki i stanęło na czymś takim (z wykorzystaniem wspomnianego fluent interface):

  1:  public class LicensingModule : ILicensingModule
  2:  {
  3:  	public void Initialize()
  4:  	{
  5:  		ApplicationEventsManager.OnEvent<UserLoggingIn>().UseHandler<CheckEditorsLimit>();
  6:  	}
  7:  }

Za inicjalizację modułów odpowiedzialny jest osobny mechanizm wywoływany przy starcie aplikacji, a sama konstrukcja "on event X use handler Y" całkiem mi się podoba.

Zarządzanie zdarzeniami

Został do pokazania ostatni element układanki, czyli wykorzystany już dwukrotnie ApplicationEventsManager. Jest to "najbrudniejsza" część rozwiązania, głównie ze względu na udostępnienie składni rejestracji handlerów:

  1:  public static class ApplicationEventsManager
  2:  {
  3:  	private static readonly IList<object> _handlers = new SynchronizedCollection<object>();
  4:  
  5:  	public static IAddHandler<TEvent> OnEvent<TEvent>()
  6:  		where TEvent : IApplicationEvent
  7:  	{
  8:  		return new AddHandler<TEvent>();
  9:  	}
 10:  
 11:  	public static void Raise<TEvent>(TEvent e) where TEvent : IApplicationEvent
 12:  	{
 13:  		// hold proper handlers locally to avoid locking global collection
 14:  		var localHandlers = _handlers.OfType<Handles<TEvent>>().ToList();
 15:  
 16:  		localHandlers.ForEach(x => x.Handle(e));
 17:  	}
 18:  
 19:  	#region IAddHandlers, for fluent interface
 20:  
 21:  	public interface IAddHandler<TEvent> where TEvent : IApplicationEvent
 22:  	{
 23:  		void UseHandler<THandler>() where THandler : Handles<TEvent>;
 24:  	}
 25:  
 26:  	private class AddHandler<TEvent> : IAddHandler<TEvent> where TEvent : IApplicationEvent
 27:  	{
 28:  		public void UseHandler<THandler>() where THandler : Handles<TEvent>
 29:  		{
 30:  			var handler = ServiceLocator.Resolve<THandler>();
 31:  			_handlers.Add(handler);
 32:  		}
 33:  	}
 34:  
 35:  	#endregion
 36:  }

Bezpieczna do wielowątkowego użycia kolekcja przechowuje zarejestrowane instancje klas obsługujących zdarzenia. Handlery są instancjonowane w momencie rejestracji zamiast przed samym wywołaniem instrukcji Handle, co zostało podyktowane nieistotnymi z punktu widzenia tego posta przyczynami. Odpowiednie restrykcje nałożone na generyczne parametry zapewniają przyjemne wrażenia związane z Intellisense i wiele mówiące komunikaty błędów podczas kompilacji.

Feedback?

Zachęcam do bliższego zapoznania się z zaprezentowanym kodem. Jeśli komuś przyjdą do głowy uwagi z nim związane to bardzo chętnie je sobie poczytam. A może coś potraktowałem po macoszemu i wymaga to głębszego wyjaśnienia? Pytania równiez mile widziane.
Podoba mi się połączenie kilku odrębnych koncepcji, które chciałem zastosować od jakiegoś już czasu, w jedno działające i spełniające swoją rolę rozwiązanie. Podczas implementacji mogłem przez chwilę beztrosko pobawić się kodem, co jak wiadomo jest niezwykle radosną i przynoszącą wiele satysfakcji czynnością:). Może spodoba się komuś jeszcze.

Share.

About Author

Programista, trener, prelegent, pasjonat, blogger. Autor podcasta programistycznego: DevTalk.pl. Jeden z liderów Białostockiej Grupy .NET i współorganizator konferencji Programistok. Od 2008 Microsoft MVP w kategorii .NET. Więcej informacji znajdziesz na stronie O autorze. Napisz do mnie ze strony Kontakt. Dodatkowo: Twitter, Facebook, YouTube.

5 Comments

  1. Bardzo ciekawe rozwiązanie. Sama konstrukcja przypomina bardzo EventAggregator z Prism’a. Jednak tutaj dodatkowo eventy zostały wykorzystane do sterowania flow’em. Bardzo "szczwane". W takim scenariuszu jaki zaprezentowałeś powinno to się chyba nazywać Application Flow Events.

  2. Fajne rozwiązanie:) Ja bym tu widział jeszcze dodatkowo MEF-a jako sposób na ładowanie handlerów. O ile w "enterprajsowym" przypadku Udiego to pewnie zbędne, o tyle jeśli chodzi o w miarę lekką aplikację web, może być przydatne: wrzucasz dllkę do bin-a i masz.

    PS. Nie mogę się przyzwyczaić do tej nowej konwencji nazewniczej, którą propaguje Udi i ostatecznie moje eventy domenowe nazwałem IEventHandler<T>, a nie Handles<T>;)

  3. @jj:
    Prismowi sie nie przygladalem, wiec sie odniesc nie moge:). A co do flow – to tylko w tym konkretnym przypadku, gdy mozliwe jest anulowanie jakiejs akcji. Do tego stosowane sa normalne, ‘informacyjne’ zdarzenia typu OrderCreated, ktore juz z flow nie maja wiele wspolnego. Tak czy siak uznalem ze pokazanie cancellable bedzie bardziej interesujace.

  4. @simon:
    Celowo nie pokazalem tu zadnego mechanizmu tworzenia handlerow (ukrylem to za ServiceLocator.Resolve) zeby dodatkowo nie komplikowac sprawy. U mnie dojdzie zapewne niedlugo cos takiego jak OneTimeHandler, czy cos w ten desen, ktory bedzie sie po jednorazowej obsludze automatycznie derejestrowal i niszczyl; moze to byc przydatne, a nie myslalem jeszcze jak to mądrze zaimplementowac, wiec calkowicie pominalem tą kwestie. Ale fakt, w normalnych scenariuszach MEF moglby okazac git.

    P.S. Ja juz coraz wiecej klas nazywam czasownikami:)

  5. warto wspomniec tylko o tym ze mef jest dla .NET 4.0 to ze istnieje preview dla 3.5 to nic nie oznacza. z tego co wiem nie bedzie implementacji i rozwiazania dla 3.5 i tyle.

    wiec do poki nie wyjdzie 4.0 za bardzo nie ma co korzystac z mefa, ale mozna sie nim pobawic.

Newsletter: devstyle weekly!
Dołącz do 1000 programistów!
  Zero spamu. Tylko ciekawe treści.
Dzięki za zaufanie!
Do przeczytania w najbliższy piątek!
Niech DEV będzie z Tobą!