Spis treści:

Kategoria:C#Wzorce projektowe


Wzorzec dekoratora w praktyce

Na początku były wzorce projektowe

Niedźwiedź, jak to niedźwiedź, zdenerwował się pewnego dnia okrutnie i sporządził listę zwierząt, które z tej złości zje. A było tych zwierząt wiele! Taka informacja dość szybko rozeszła się po okolicy i zasiała uzasadniony niepokój.
Pierwsza dowiedziała się sarna. Przybyła do niedźwiedzia i rzecze:
- Niedźwiedziu kochany, daj mi chociaż dzień, mam liczne potomsto, dozwól mi pożegnać się z nimi i powiedzieć im, jak bardzo ich kocham!
Niedźwiedź, ugodowy obywatel lasu, zgodził się. I rzeczywiście, zjadł sarnę dopiero na następny dzień.
Kolejny przyszedł lis i pyta, czy i on na tej liście widnieje.
- A i owszem - odparł niedźwiedź.
- Czy mogę cię zatem prosić, przez wzgląd na moją rodzinę, o ostatnie życzenie? Pragnąłbym pożegnać się oraz przeprosić tych, których skrzywdziłem.
Ugodowy niedźwiedź zgodził się i doczekał cierpliwie następnego dnia, w któym spożył lisa.
Pojawił się u niedźwiedzia zajączek i pyta:
- A czy ja też jestem na liście?
- A i owszem, jesteś - odparł niedźwiedź po chwili.
- A czy nie mógłbyć mnie skreślić?
- Nie ma sprawy!

Wzorce projektowe to bardzo popularne hasło. Z definicji są to pewne sprawdzone, uniwersalne i często wykorzystywane sposoby rozwiązywania określonych problemów programistycznych. Z przykrością patrzę nieraz na złożone systemy, które korzystają z wzorców (co jest dobre), ale korzystają z nich bez ziązku z konkretnym problemem (co jest złe). Nie bez powodu zamieściłem obok krótką historyjkę. Wiele problemów, programistycznych i życiowych, da się rozwiązać łatwiej przy pomocy zwykłych konstrukcji obiektowych, niż przy pomocy wzorca. To, że w projekcie wykorzystuje się dużo wzorców nie świadczy o tym, że jest on dobry. Świaczy o nim dobre i uzasadnione wykorzystanie tych wzorców. Zawsze należy zważyć, czy zastosowanie wzorca da nam jakieś konkretne, wymierne korzyści. Wzorzec wprowadza do kodu złożoność, która w dużych systemach potrafi mocno frustrować, a jeżeli dodatkowo mamy przeczucie, że jest to niepotrzebne...

Widziałem już fabryki klas, które zawierały wielką instrukcję switch z operatorami new zupełnie niepowiązanych ze sobą klas oraz wstrzykiwane interfejsy (bo to elastyczne), które miały kilkaset metod. Czy ktoś kiedyś podmieni implementację tak wielkiego interfejsu? Taki interfejs nic nam nie daje, a wymusza na nas pisanie tego samego po dwakroć! Raz w interfejsie, a raz w klasie. Według mnie jest to sztuka dla sztuki. Nie mówiąc o dodatkowych właściwościach, czy konstruktorach, które te niby elastyczne i dowolne implementacje muszą przyjmować. W zasadzie nie implementacje, a implementację, jedną, na którą przy kilkuset metodach jesteśmy skazani. Pomyślmy, jaki największy cudzy interfejs lub klasę bazową nadpisalimy? Było to kilka metod? Kilkanaście? I pewnie już wtedy myśleliśmy, że to skomplikowane.

Wzorce powinny nam coś ułatwiać, czynić kod prostszym, czytelniejszym, ale też nie za wszelką cenę. Dużo zależy od konkretnego przypadku i... intuicji. To wzorce powinny nam służyć, a nie my im. Nie naginajmy kodu do wzorców, bo ktoś nam powiedział, że one są fajne.

Po co nam wzorzec dekoratora

Wzorzec dekoratora, zgodnie z definicją, pozwala na dodanie nowej funkcjonalności do metod już istniejących. Najczęściej jest to realizowane przez napisanie nowej klasy, która opakowuje klasę dekorowaną. Aby udekorowana klasa mogła działać wszędzie tam, gdzie klasa bazowa, obie muszą mieć wspólny interfejs lub pozostawać w relacji dziedziczenia. Musi zatem istnieć, najlepiej niejawne, rzutowanie klasy udekorowanej na klasę nieudekorowaną. Definicja zawiera też wskazanie, że wszystko powinno się dziać dynamicznie! Czyli jak?

Łatwiej napisać niż zrobić. I to zrobić tak, aby nie zrobić sobie krzywdy. Zbyt luźny, dynamiczny i pozbawiony kontroli typów program jest wyjątkowo trudny do opanowania. Zwłaszcza jak się zacznie rozrastać. Jak wspomniałem wcześniej - wzorce mają nam pomagać a nie przeszkadzać. Wspomniałem też, żeby nie naginać kodu do wzorców. Nie powiedziałem natomiast, że nie można dostosować wzorca do własnych potrzeb. I to w taki sposób, aby zrobić i się nie narobić.

Wywołanie własnych metod przed i po metodach gotowej klasy

Wiele gotowych komponentów pozwala nam wykonać akcje przed niektórymi zdefiniowanymi zdarzeniami. Taki pierwszy z brzegu WCF pozwala nam wykonać akcje przed wywołaniem usługi (metoda BeforeCall) oraz po (metoda AfterCall). Metody zgrupowane są w interfejsie IParameterInspector. Według nazwy interfejsu to dobry moment na kontrolę parametrów, ale nic nie stoi na przeszkodzie, aby zaimplementować w tych metodach inne operacje, na przykład kontrolę uprawnień, zliczanie wywołań, czy logowanie aktywności użytkowników. Okna wielu aplikacji dostają komunikat, że niedługo zostaną zamknięte. Mamy zatem mozliwość wykonania pewnych operacji zanim fizyczne zamknięcie nastąpi. W takim miejscu można umieścić zapis ustawień aplikacji lub poinformować użytkownika, że niektóre z edytowanych dokumentów nie są jeszcze zapisane. Można też, co ważne, zablokować wykonywaną operację.

Czy da się coś takiego zrobić z gotowymi obiektami? Z takimi, które nie udostępniają gotowych metod PRZED i PO? Nie rozpisywałbym się tak bardzo, gdyby się nie dało. Zaraz pokażę jak.

Plan działania i podstawowy interfejs

Plan jest następujący: chcemy przechwycić wywołania wszystkich (lub wybranych) metod i wykonać jakieś dodatkowe operacje przed i po tych wywołaniach. Aby dało się realizować dowolne operacje, najlepiej będzie zaprojektować prosty interfejs z dwona metodami: PRZED i PO. Nazewnictwo przyjąłem takie samo jak WCF, to jest BeforeCall i AfterCall. Obie przyjmą po jednym parametrze, który będzie określał kontekst wywołania. Przyda się nam do określenia co robimy i gdzie. Typem prametru będzie interfejs IMethodCallMessage. Co to za interfejs i co dokładnie ma? O tym później. Popatrzmy na interfejs.

public interface IProxyListener
{
    void BeforeCall(IMethodCallMessage callCtx);
    void AfterCall(IMethodCallMessage callCtx);
}

Interfejs jest bardzo prosty, bo taki powinien być. Taki, żeby komuś chciało się go implementować. Obiekt dekorujący będzie mógł być wyposażony w zestaw klas implementujących pokazany powyżej interfejs, a klasy zostana powiadomione o zaistniałym zdarzeniu. Niby proste, tylko jak się wcisnąć pomiędzy gotowe instrukcje? Należy wygenerować jakiś kod posrednika (proxy), który zamiast wywoływać metodę A(), wywoła sekwencję Przed()-A()-Po(). Należy to zrobić tak, aby użytkownik końcowy nie musiał o tym wiedzieć, albo mógł to całkowicie zignorować.

Tworzenie pośrednika

Skąd wziąć takiego pośrednika? Okazuje się, że .NET robił już coś bardzo podobnego wewnątrz jednego z przodków WCF, a mianowicie w .NET Remoting. Tam przekazywanie obiektów pomiędzy procesami realizowane było właśnie przez pośrednika, który wyglądał tak samo jak obiekt oryginalny, a w rzeczywistości przechwytywał wywołania, przesyłał parametry i wykonywał operacje po drugiej stronie kanału komunikacyjnego. Krótkie omówienie .NET remoting nie jest możliwe, więc przejdę od razu do przykładu, a potem omówię ważniejsze fragmenty.

public class ObjectProxy<T> : RealProxy where T : MarshalByRefObject
{
    private T baseObject;
    private IProxyListener[] listeners;

    public static T CreateInstance(params IProxyListener[] listeners)
    {
        var obj = new ObjectProxy<T>();
        obj.listeners = listeners;
        // Zwróć pośrednika jako typ podstawowy
        // Użytkownik niech myśli, że to typ T
        // W rzeczywistości dostaje pośrednika
        return (T)obj.GetTransparentProxy();
    }

    private ObjectProxy()
        : base(typeof(T))
    {
        baseObject = Activator.CreateInstance<T>();
    }

    public override IMessage Invoke(IMessage msg)
    {
        IMethodCallMessage methodMessage = (IMethodCallMessage)msg;

        // Wykonaj akcje przed
        for (int i = 0; i < listeners.Length; i++)
            listeners[i].BeforeCall(methodMessage);

        // Wykonaj akcję właściwą
        object returnValue = methodMessage.MethodBase.Invoke(baseObject, methodMessage.Args);

        //Wykonaj akcje po
        for (int i = 0; i < listeners.Length; i++)
            listeners[i].AfterCall(methodMessage);

        // Zwróć rezultat akcji właściwej
        return new ReturnMessage(returnValue, methodMessage.Args,
            methodMessage.ArgCount, methodMessage.LogicalCallContext, methodMessage);
    }
}

Klasa naszego pośrednika dziedziczy z klasy RealProxy. To klasa, która potrafi wygenerować pośrednika do dowolnej innej, która może uczestniczyć w .NET Romoting i przechwycić wywołanie każdej metody takiej klasy. Nie tylko metody, ale także właściwości (właściwości też są metodami, tylko ukrytymi). Dziedzicząc z klasy RealProxy przejmujemy jej zdolności. Te zdolności to:

  • tworzenie pośrednika - metoda GetTransparentProxy(),
  • przechwytywanie wywołań = wirtualna metoda Invoke().

Jak przebiega cały proces? W konstruktorze tworzony jest oryginalny, podstawowy, rzeczywisty obiekt i zapamiętywany w zmiennej baseObject. Potrzebny jest nam on do wykonania tego, czego chce użytkownik. Pamiętajmy, że akcje PRZED i PO powinny być maksymalnie ukryte przed wywołującym. On oczekuje tylko wartości oryginalnej metody. Aby go zatem maksymalnie oszukać, można wystawić statyczną metodę tworzącą obiekt ObjectProxy<T>, ale widoczny jako T. W międzyczasie zapamiętujemy jeszcze wszystkie klasy gotowe do słuchania.

Od tego momentu każde wywołanie klasy pośrednika T będzie przechodziło przez metodę Invoke. Tam pobieramy kontekst, wywołujemy metodę PRZED wszystkich klas nasłuchujących, metodę właściwą klasy bazowej, oraz metody PO wszystkich klas nasłuchujących. Na koniec zwracamy wynik wywołania metody z obiektu klasy oryginalnej w postaci wymaganego typu ReturnMessage.

Warto jeszcze zwrócić uwagę na typ MarshalByRefObject, który również pochodzi z .NET Remoting i określa sposób przekazywania obiektu uczestniczącego w komunikacji. Typy bez MarshalByRefObject przekazywane są w postaci kopii, więc nie ma niczego pośrodku i nie ma się gdzie wcisnąć w łańcuch wywołań. Typy dziedziczące z MarshalByRefObject przekazywane są w .NET Remoting przy pomocy pośrednika, a ten pośrednik zajmuje się wywoływaniem dodatkowych operacji PRZED i PO.

Skoro mamy już metodę do przechwytywania, można ją wypróbować.

Klasy do nasłuchu - zabezpieczenia i log

Jako przykład operacji, które warto umieścić w łańcuchu, proponuję coś do sprawdzania uprawnień (operacja PRZED) oraz logowania (operacja PO). Można je zaimplementować w jednej klasie, ale czytelniej jest je rozdzielić. Jedna z metod każdej klasy będzie pusta, ale nie jest to wielka strata. Popatrzmy na przykładowe implementacje:

public class SecurityListenerIProxyListener
{
    public void BeforeCall(IMethodCallMessage proxyContext)
    {
        var attributes = proxyContext.MethodBase.GetCustomAttributes<SecurityAttribute>();
        var rights = attributes.SelectMany(a => a.Rights).Distinct();
        if (rights.Any())
            Console.WriteLine(" +-Kontrola uprawnień: "string.Join(", ", rights));
    }

    public void AfterCall(IMethodCallMessage proxyContext)
    {
    }
}

public class LogListenerIProxyListener
{
    public void BeforeCall(IMethodCallMessage proxyContext)
    {
    }

    public void AfterCall(IMethodCallMessage proxyContext)
    {
        var attributes = proxyContext.MethodBase.GetCustomAttributes<LogAttribute>();
        foreach (var item in attributes)
            Console.WriteLine(" +-Log: " + item.Message);
    }
}

Uznałem, że nie wszystkie metody powinny być sprawdzane w celu weryfikacji uprawnień i nie wszystkie powinny logować. Obie klasy wykorzystują atrybuty przypisane do metod klasy dekorowanej. Klasa do zabezpieczeń SecurityListener korzysta z atrybutu SecurityAttribute, natomiast klasa do logu LogListener z atrybutu LogAttribute. Klasa do zabezpieczeń grupuje wszystkie wymagane uprawnienia i weryfikuje dostęp jednorazowo (atrybuty są sumowane), klasa do logów wykonuje logowanie dla każdego z atrybutów oddzielnie. Przyjrzyjmy się jeszcze samym atrybutom:

[AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)]
public class LogAttribute : Attribute
{
    public string Message { getset; }
    public LogAttribute(string message)
    {
        Message = message;
    }
}

[AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)]
public class SecurityAttributeAttribute
{
    public string[] Rights { getset; }
    public SecurityAttribute(params string[] rights)
    {
        Rights = rights;
    }
}

Jesteśmy prawie gotowi do wykorzystania wszystkich mechanizmów - potrzebna nam jeszcze klasa testowa.

Klasa do testowania wzorca dekoratora

Nasz dekorator, o czym już wspomniałem, przechwytuje wszystkie wywołania metod. To co decyduje o fizycznym sposobie działania dekoratora to implementacje klas z interfejsem IProxyListener. Te klasy, realizujące funkcje zabezpieczeń i logowania, wykorzystują atrybuty metod i na ich podstawie wykonują swoje akcje. W przykładzie jest to wypisanie komunikatów w oknie konsoli. W rzeczywistym systemie operacje mogą być bardziej złożone. Przyjrzyjmy się klasie testowej pokazanej na poniższym listingu:

public class TestMarshalByRefObject
{
    [Log("Log metody testowej")]
    [Log("Log dodatkowy")]
    [Security("Prawo 1")]
    [Security("Prawo 2""Prawo 3")]
    public void TestString(string s)
    {
        Console.WriteLine("Method TestString: " + s);
    }

    [Security("Prawo 1")]
    public void TestInt(int i)
    {
        Console.WriteLine("Method TestInt: " + i.ToString());
    }
}

Każda z metod wypisuje coś na ekranie, abyśmy mogli prześledzić kolejność wykonywanych funkcji. Pierwsza z metod klasy wymaga sprawdzenia trzech uprawnień (zdefiniowanych w dwóch atrybutach) i powinna wygenerować dwa logi. Druga metoda sprawdza jedno uprawnienie i nie wymaga logowania. Warto zwrócić uwagę na klasę MarshalByRefObject, z której muszą dziedziczyć wszystkie dekorowane obiekty.

Warto zwrócić uwagę na oddzielenie logiki zabezpieczeń i logowania od kodu samej metody. W tak małym przykładzie nie jest to widoczne, ale w dużych klasach, z dużą ilością metod potrafi naprawdę zrobić różnicę.

Test dekoratora

Popatrzmy teraz na możliwości dekoratora. W zasadzie wszystkie potrzebne elementy układanki są już gotowe i można ich użyć bezpośrednio, co jest pokazane poniżej:

// gdzieś w kodzie...
Test t = ObjectProxy<Test>.CreateInstance(new SecurityListener(), new LogListener());
t.TestInt(1);
Console.WriteLine();
t.TestString("String1");

Oprócz linijki, w której tworzony jest obiekt klasy Test, nie widać żadnych różnic pomiędzy zwykłymi wywołaniami tradycyjnych metod, a wywołaniami niezwykłymi, przechodzącymi przez naszego pośrednika. Nie jest też wymagany żaden dodatkowy interfejs. Popatrzmy jeszcze na wyniki, bo w końcu ważniejsze od wyglądu jest działanie:

  +-Kontrola uprawnień: Prawo 1
Method TestInt: 1

  +-Kontrola uprawnień: Prawo 1, Prawo 2, Prawo 3
Method TestString: String1
  +-Log: Log dodatkowy
  +-Log: Log metody testowej

Kontrola uprawnień i logowanie działa, ale można to jeszcze trochę uprościć. Chodzi mi głównie o konstruktor, bo jest trochę długi i może sprawiać problemy.

Upraszczanie konstruktora

Tworzenie obiektu pośrednika można oddelegować do innej klasy. Daje to nie tylko możliwość skrócenia zapisu, ale pozwala na zastosowanie kilku innych technik uelastyczniających całe rozwiązanie. Przyjrzyjmy się dodatkowej klasie:

public class Proxy
{
    public static T Create<T>() where T : MarshalByRefObject
    {
        // Obsługa dodatków (uprawnienia, log) może być konfigurowana przez App.config
        return ObjectProxy<T>.CreateInstance(new SecurityListener(), new LogListener());
    }
}

Takie jedno magiczne miejsce pozwala uwspólnić proces konfiguracji dodatków dekoratora i umieścić go w pliku konfiguracyjnym. W komentarzu napisałem, że może to być App.config. Tam można wskazać wszystkie dodatki wykorzystywane przez konkretny typ (przez dodatki rozumiem implementacje interfejsu IProxyListener) i prawdziwie, dynamicznie, zmienić tryb działania programu.

Popatrzmy jeszcze na sposób wykorzystania dodatkowej klasy:

// gdzieś w kodzie...
Test proxy = Proxy.Create<Test>();
proxy.TestInt(2);

Console.WriteLine();
proxy.TestString("String2");

Zapis się skrócił, a my mamy większą kontrolę nad procesem tworzenia obiektów. Wynik działania programu będzie taki sam, poza różnicami w parametrach:

  +-Kontrola uprawnień: Prawo 1
Method TestInt: 2

  +-Kontrola uprawnień: Prawo 1, Prawo 2, Prawo 3
Method TestString: String2
  +-Log: Log dodatkowy
  +-Log: Log metody testowej

Podsumowanie

Zawsze po zakończeniu pisania jakiegoś automatycznego mechanizmu zastanawiam się nad paroma rzeczami. Jak inni będą go używać? Ile czasu zajmie wyjaśnienie mu sposobu pracy z tym mechanizmem? Jakie problemy były przed wprowadzeniem rozwiązania, a jakie pojawiły się po wprowadzeniu? Jakie są konsekwencje wydajnościowe takiego rozwiązania? Czy kod stanie się bardziej przejrzysty, czy bardziej złożony? Jaki jest nakład pracy? Tutaj nieszczególnie duży, ale w innych przypadkach także należałoby to wziąć pod uwagę. Tworzenie armaty na wróble nie ma sensu - są przyjemniejsze rzeczy w życiu. Odpowiedź na powyższe i podobne pytania nie jest zawsze taka oczywista.

Kończąc ten wpis zdałem sobie sprawę, że mimochodem wykorzystałem także inne wzorce projektowe. Pojawił się więc pomysł małego testu. Polega on na wypisaniu w komentarzach wszystkich wzorców namierzonych w tekście, ze wskazaniem, w którym miejscu się znajduje. Wzorzec dekoratora mozna pominąć. Będzie to miało znakomity walor poznawczy. Zdarza się, a wiem to po sobie, że korzystam z jakiegoś wzorca, a nie potrafię go nazwać.

Kategoria:C#Wzorce projektowe

, 2013-12-20

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
bardo ciekawe , można dzięki takim wpisom dostrzec wędkę..
Bardzo dziękuję za jasne tłumaczenie z dobrze dobranym przykładem!
Dzieki za te informacje. były kluczowe
Dobrze wyjaśnione, dzięki !