Programowanie aspektowe

Autor: Piotr Zieliński

Spis treści:

Czym jest programowanie aspektowe?

Większość programistów dzisiaj jest dobrze obeznana z programowaniem obiektowym i pojęciami z nimi związanymi takimi jak np. enkapsulacja. W OOP naturalne jest gromadzenie logiki ze sobą powiązanej w osobnych obiektach (klasach). Ten sam obiekt, nie powinien wykonywać dwóch innych zadań (single responsibility). Programowanie aspektowe (w skrócie AoP), proponuje zupełnie inny model. Na początku może wydawać się, że AoP wprowadza zamieszanie, ale jeśli tylko wykorzystuje się go w odpowiednich zadaniach, w rzeczywistości bardzo upraszcza kod.

Przede wszystkim należy wprowadzić i zrozumieć pojęcie cross-cutting concerns (zagadania przecinające). Stanowią one te fragmenty kodu, które mają zastosowanie w całej aplikacji, a nie tylko w konkretnej klasie lub metodzie. Doskonały przykład to wykonywanie logów. W zasadzie każda metoda w aplikacji wykonuje jakieś logi. Z tego względu nie da się tej logiki w łatwy sposób oddzielić za pomocą obiektu. Oczywiście sama logika odpowiedziana za zapis logu (np. do pliku) może zostać zaimplementowana w OOP. Problem pojawia się jednak w momencie, kiedy należy z niego skorzystać. W takiej sytuacji, trzeba byłoby w każdej klasie w projekcie, wywoływać metodę logger’a. Inne zagadanie cross-cutting to bezpieczeństwo, polegające na tym, że tylko zalogowani użytkownicy mogą wywoływać metody na danych klasach. Podejście OOP polegałoby na sprawdzaniu, czy użytkownik jest zalogowany w każdej metodzie – co oczywiście zaśmieca i komplikuje kod. Sytuacja jeszcze bardziej się komplikuje jak podane reguły (bezpieczeństwo, logging) są konfigurowalne globalnie i mogą zmieniać się w czasie działania aplikacji.

Jeśli nie jest to jeszcze jasne, to kolejny przykład mam nadzieję rozjaśni problem. Załóżmy, że chcemy sprawdzić ile czasu zajmuje wykonanie każdej z metod. Oczywiście moglibyśmy skorzystać z Stopwatch i ręcznie sprawdzać czas tzn.:

Zawsze cross-cutting concerns oznaczają, że jakaś część kodu należy wykonać dla wielu metod. Powyższy przykład pokazuje pobranie czasu, co pozwala zmierzyć ile każda z metod wykonywała się. Łatwo wyobrazić sobie projekt z 50 klasami i jak to zaśmieci kod. Oczywiście poszczególne fragmenty można opakować w osobne klasy, ale i tak należy je potem wywołać gdzieś. Jeśli mamy 50 metod to nawet czyste wykonanie osobnej klasy to zadanie monotonne. Potrzebny jest mechanizm globalny, który w jednym miejscu może zostać skonfigurowany.

Wiemy już, dlaczego AoP zostało wprowadzone i jakie problemy rozwiązuje. Kolejne pytani brzmi, w jaki sposób dokonuje tego? Odpowiedź jest prosta… Zagadanie implementuje się w osobnej klasie, a potem, dynamicznie wstrzykuje się je na podstawie zdefiniowanych filtrów. Możliwe jest, zatem wstrzykniecie kodu przed lub po wykonaniu jakiejkolwiek metody.

Podstawowe pojęcia

Pierwsze i tak naprawdę najważniejsze pojęcie zostało już wyjaśnione, a mianowicie cross-cutting concerns(zagadanie przecinające).

Zagadnienie (concern) to implementacja kodu, który następnie zostanie wstrzyknięty do wybranych metod i klas. Z kolei jointpoint (punkt złączenia), to nazwa tego miejsca, do którego chcemy doczepić concern, czyli zaimplementowane zagadnienie. Innym pokrewnym pojęciem jest punkt przecięcia (pointcut), który stanowi tak naprawdę kolekcję punktów złączeń. Zwykle jest to jakieś zapytanie, które określi, w których miejscach ma nastąpić przecięcie. W zależności od konkretnej implementacji, punkty przecięcia są rożnie definiowane, ale mają one formę zapytań np. „wszystkie konstruktory klas”.

Proces wstrzykiwania zagadnienia do punktów złączeń nazywa się wplątywaniem (weaving).

Połączenie punktów złączenia i zagadnienia (concern+pointcuts) stanowi aspekt (aspect).

Bardzo często zagadnienie jest określane radą (advice). Jest to po prostu kod, który definiuje zachowanie. Można je potem wstrzyknąć przed lub po wykonaniu danej metody. Trzecim sposobem jest modyfikacja istniejącego kodu. Jeśli projektujemy system loggowania to rada może zostać po prostu wstrzyknięcia przed lub po wykonaniu jakieś metody. Z kolei dla buforowania danych, niezbędne jest zmodyfikowanie już istniejącego kodu, na co również pozwala programowanie aspektowe.

Pojęcia mogą być trochę niejasne, ale po pokazaniu kilku przekładów, okaże się, że nie ma w nich nic nadzwyczajnego.
Podsumowując jednak:

  • Rada (advice) - implementacja zachowania, ktore zostanie wstrzyknięte.
  • Punkt złączenia (jointpoint) - miejsce, w którym rada zostanie wstrzyknięta.
  • Punkt przecięcia (pointcut) - zapytanie definiujące zbiór punktów złączenia.
  • Aspekt - stanowi radę + punkty złączenia
  • Wplątywanie (weaving) - proces wstrzykiwania

Załóżmy, że mamy metodę o nazwie CustomLogic. Wtedy wstrzykiwanie aspektów można opisać następującą:

Z powyższego przykładu wynika, że w programowaniu aspektowym można przechwycić moment przed i po wyjściu z metody. Ponadto istnieje możliwość stwierdzenia czy wystąpił wyjątek czy nie. Zachowanie można porównać do własnego wrapper’a dla każdego wywołania. Tym sposobem możliwa jest nawet manipulacja parametrami wejściowymi i wyjściowymi.

Postsharp – biblioteka AoP

Istnieje wiele bibliotek wspierających programowanie aspektowe. Cześć programistów również próbuje zaimplementować własne rozwiązanie, ale w praktyce jednak jest to dość skomplikowane. Postsharp występuje w dwóch wersach i jedna z nich jest darmowa. W artykule przedstawię właśnie PostSharp Express, ale nie mam na celu tutaj wyjaśniać konkretnego API. Przedstawione przykłady mają na celu pokazanie jak działa programowanie aspektowe. Moim zdaniem lepiej to wyjaśnić właśnie na praktycznych próbkach kodu niż teoretycznych podstawach. Bibliotekę można zainstalować z NuGet:

Kolejny krok to zdefiniowanie aspektu:

Każde API jest inne, ale zawsze powinno umożliwiać:

  • Wykonanie logiki przed wywołaniem danej motody,
  • Wykonanie logiki po zakończeniu metody,
  • Przechwycenie parametrów, wyjątków, itp.,
  • Możliwość wstrzyknięcia wartoście końcowej.

Załóżmy, że mamy następującą klasę:

Nastepnie użytkownik wykonuje Readdata:

Implementacja AoP powinna dostarczyć wrapper dla tego wywołania, w taki sposób, że możliwe jest wykonanie jakiekolwiek kodu przed, po jak i przekazanie dowolnie innych parametrów zarówno wejściowych jak i wyjściowych.

Postsharp dostarcza OnEntry oraz OnExit, które przechowują informacje o kontekście (tzn. wartości parametrów itp.).

Ciekawą właściwością jest FlowBehaviour. FlowBehaviour przyjmuje następujące wartości: Default, Continue, RethrowException, Return, ThrowException. Poniższy kod wyświetli tylko jedną wiadomość, ponieważ w aspekcie ustawiamy FlowBehaviour na Return, co spowoduje po prostu wykonanie return przed logiką metody:

RethrowExeption z kolei jest przydatny, gdy przeciążamy OnExeption:

RethrowException jak sama nazwa mówi, wyrzuci ponownie ten sam wyjątek. Najpierw on zostanie złamany w celu wywołania OnException na aspekcie, a potem przez RethrowException zostanie on ponownie wyrzucony.

Kolejną wartością jest ThrowExeption, któr wyrzuci nowy wyjątek zdefiniowany w aspekcie:

Jeśli nie ustawilibyśmy właściwości Exception wtedy FlowBehaviour.ThrowException i FlowBehaviour.RethrowException miałby takie same znaczenie.

Z kolei Continue zdławi wyjątek i należy tego w większości sytuacjach unikać:

Domyślną wartością jest FlowBehaviour.Default co oznacza, że dla OnException będzie to RethrowException a dla OnExit\OnEntry Continue.

Ostatnią właściwością, jaką warto teraz omówić jest MethodExceuctionTag. Jeśli zachodzi potrzeba dzielenia stanu pomiędzy poradami (OnEntry, OnExit) możemy właśnie z tego skorzystać. Na przykład aspekt sprawdzający jak długo metoda była wykonywana można zaimplementować następująco:

Wyłącznie w powyższy sposób należy przekazywać dane pomiędzy zdarzeniami – nie można polegać na prywatnych polach jak to byśmy zrobili w klasycznej klasie!

Obsługa błędów za pomocą programowania aspektowego

Obsługa błędów i wykonywanie logów to jedno z klasycznych zastosowań AoP. Zadanie jest proste – napisać uniwersalny kod, który będzie sprawdzał czy wyjątki są wyrzucane w danych metodach i w przypadku gdy są, zarejestruje kontekst (parametry wejściowe, treść błędu itp.). Jeśli byśmy wybrali obiektowe podejście, każda z metod musiałaby wyglądać następującą:

Jak widać, OOP bardzo zaśmieca kod i trzeba ręcznie poddawać nazwy parametrów, co jest zadaniem monotonnym.

W AoP można zdefiniować po prostu aspekt (zmodyfikowany przykład z oficjalnej dokumentacji Postsharp):

Powyższy aspekt można zastosować dla dowolnej metody. W Postsharp punkty złączeń definiuje się za pomocą atrybutów. Można je stosować zarówno na metodach jak i globalnie. Jeśli chcemy użyć danego aspektu wyłącznie dla konkretnej metody wtedy należy opatrzyć ją po prostu atrybutem:

W praktyce jednak, dużo lepiej skorzystać z bardziej globalnego mechanizmu tzn.:

Powyższa linijka kodu zaaplikuje aspekt na każdej metodzie. Można również określić filtr, np. poprzez zdefiniowanie namespace:

Jeśli jest to niewystarczające to można określić również widoczność typu (private, public itp.) czy typ metody:

Inne popularne scenariusze użycia

Programiści, szczególnie korzystający z WPF i MVVM są dobrze obeznani z interfejsem INotifyPropertyChanged. Jednym z nudnych zadań jest wywoływanie OnPropertyChanged w momencie zmiany danej właściwości tzn.:

A co jeśli dodamy kolejne właściwości takie jak LastName oraz FullName, które powinny być odświeżane w momencie zmiany imienia lub nazwiska? Kod bardzo szybko zostanie zaśmiecony logiką, która nie jest biznesowa, zatem stanowi tylko infrastrukturę. Jest to doskonały przykład na zastosowanie aspektów i końcowa klasa mogłaby wyglądać następującą:

Walidacja danych jest często również zagadnieniem przecinającym. Załóżmy, że wartość jakieś klasy, nigdy nie powinna być przekazywana, jako null. Wtedy w obiektowym podejściu kod wyglądałby następującą:

W AoP uprości się to do:

Wspomniana już wcześniej obsługa logów i wyjątków jest jednak najpopularniejszym zastosowaniem. Logi stanowią instrumentację aplikacji a nie logikę biznesowa. Z tego względu, nie ma sensu zaśmiecania obiektów biznesowych wywołaniami do logger’a. Odwraca to wyłącznie uwagę programistów, którzy próbują zrozumieć dany kod.

Zakończenie

Programowanie aspektowe upraszcza kod i pozwala skupić się programistom na logice biznesowej a nie na narzędziach wokoło niej.

Czasami jednak programiści idą o krok za daleko i używają aspekty do zastosowań, które mogą być sporne. Należy mieć na uwadze, że aspekty mogą powodować zamieszanie wśród programistów, ponieważ nie są czymś dobrze znanym wśród nich. Punkty przecięć mogą być definiowane globalnie i często są trudne w identyfikacji, jeśli programista nie jest obeznany z innym modelem programowania. Sprawa jest dość prosta, jeśli chodzi o aspekty niezwiązane z logiką biznesową (obsługa błędów, wykonywanie logów, INotifyPropertyChanged). Jeśli wszystkie implementowane zagadnienia dotyczą infrastruktury wtedy nie ma problemów, ponieważ nie są to informacje niezbędne dla programisty, aby zrozumieć workflow aplikacji. W innych przypadkach, należy zadać sobie pytanie czy korzyści z AoP są na tyle duże, że warto przenosić logikę biznesową do kompletnie innego modelu programowania.