Spis treści:

Kategoria:Windows Communication Foundation


Organizacja kodu WCF w większej skali

Wyzwania przed aplikacjami korzystającymi z WCF

W większości artykułów poświęconych WCF prezentowane były najprostsze rozwiązania pozwalające wykonać postawione w tytule zadanie. Nie przejmowałem się tam zwalnianiem zasobów, rozwlekłym kodem, obsługą wyjątków, wydajnością bądź możliwością kontrolowania przepływu danych. Wszystko to nie jest istotne podczas nauki WCF, ale staje się niezwykle ważne podczas budowania aplikacji większej niż szkoleniowa. W kolejnych podpunktach postaram się pokazać przydatne techniki, które będą rozszerzały najprostszy schemat komunikacji z usługą WCF. Jeżeli początek będzie dla kogoś niejasny, polecam zapoznanie się wcześniej z artykułami na nieco niższym poziomie, tj.

  1. Rozpoczynamy przygodę z WCF
  2. Przygoda z WCF, prosty klient
  3. Kontrakty w WCF
  4. Konfiguracja WCF za pomocą plików app.config

Zanim zaczniemy wprowadzać różne ulepszenia, stwórzmy sobie jakiś prosty projekt.

Bazowy projekt

Starałem się, aby projekt początkowy był tak prosty jak to tylko możliwe. Uruchomione zostaną dwie usługi, każda z nich będzie się składała tylko z jednej metody, klient i serwery będą działały w ramach jednej aplikacji, konfiguracja natomiast będzie umieszczona w pliku app.config. Interfejsy będą zdefiniowane następująco:

namespace WcfProxy
{
    [ServiceContract]
    public interface ICalculator
    {
        [OperationContract]
        int Add(int a, int b);
    }
}

namespace WcfProxy
{
    [ServiceContract]
    public interface IMessenger
    {
        [OperationContract]
        string Hello(string name);
    }
}

Implementacja również nie będzie obszarem naszych udoskonaleń:

namespace WcfProxy
{
    public class Calculator : ICalculator
    {
        public int Add(int a, int b)
        {
            return a + b;
        }
    }
}

namespace WcfProxy
{
    public class Messenger : IMessenger
    {
        public string Hello(string name)
        {
            return string.Format("Hello, {0}", name);
        }
    }
}

Starałem się też nie grzebać za bardzo w pliku konfiguracyjnym i pozostawić chyba minimalną konfigurację:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <system.serviceModel>
    <services>
      <service name="WcfProxy.Calculator">
        <endpoint address="http://localhost:2222/Calculator"
                  binding="basicHttpBinding"
                  contract="WcfProxy.ICalculator"/>
      </service>
      <service name="WcfProxy.Messenger">
        <endpoint address="http://localhost:2222/Messenger"
                  binding="basicHttpBinding"
                  contract="WcfProxy.IMessenger"/>
      </service>
    </services>

    <client>
      <endpoint address="http://localhost:2222/Calculator"
                binding="basicHttpBinding"
                contract="WcfProxy.ICalculator"/>
      <endpoint address="http://localhost:2222/Messenger"
                binding="basicHttpBinding"
                contract="WcfProxy.IMessenger"/>
    </client>
  </system.serviceModel>
</configuration>

Początek aplikacji sprowadza się do uruchomienia serwera. Robi się to najczęściej raz i najczęściej nie ma potrzeby ułatwiania sobie tego zadaniaW złożonych aplikacjach, które wystawiają wiele usług, z różnym poziomem zabezpieczeń, z koniecznością logowania, z możliwością uruchomienia jako usługi Windows, w ramach aplikacji lub w środowisku IIS, również ten obszar wymaga odpowiednich konfiguracji. Jest to jednak temat na odrębny artykuł.. Postanowiłem jednak i ten proces przenieść do oddzielnej klasy, co pozwala zapisać początek w następujący sposób:

namespace WcfProxy
{
    class Program
    {
        static void Main(string[] args)
        {
            WcfHost<Calculator>.Run();
            WcfHost<Messenger>.Run();
            //Tutaj operacje wywołujące usługę WCF

Kod klasy jest zaskakująco prosty - polega na wykonaniu podstawowych czynności potrzebnych do uruchomienia serwera WCF w aplikacji konsolowej. Popatrzmy na przykładową implementację metody Run:

namespace WcfProxy
{
    public class WcfHost<T>
    {
        public static void Run()
        {
            ServiceHost host = new ServiceHost(typeof(T));
            host.Open();
            Console.WriteLine("Serwer {0} uruchomiony...", typeof(T).Name);
        }
    }
}

Wydzielanie metod to podstawowa technika pozwalająca na tworzenie abstrakcji. Otrzymujemy możliwość jednolitej obsługi uruchamiania wszystkich serwerów i możemy coś specjalnego tutaj wykonać. W tym przypadku jest to tylko i wyłącznie wypisanie komunikatu, ale w innych rozwiązaniach może tu być naprawdę spora ilość kodu. Najważniejsze jest wyrzucenie tego poza główny program - tam możemy wprost przeczytać: WcfHost<Calculator>.Run() - to jest: uruchom usługę kalkulatora.

Separacja kodu usługi klienckiej

Zamknęliśmy kod uruchamiający serwer w metodzie. Wydaje się, że to samo można zrobić z klientem usług WCF. Nie jest to jednak takie proste. Kod metody uruchamiającej serwer nie potrzebował stanu oraz zawierał tylko jeden obiekt. Kod klienta jest nieco inny - najpierw tworzy się fabrykę kanałów, potem dla każdego połączenia tworzy się odpowiedni kanał, różne usługi mogą przyjmować różne parametry i zwracać różne wartości. Popatrzmy na jedno z rozwiązań:

namespace WcfProxy
{
    public class Proxy<T>
    {
        static ChannelFactory<T> _factory = new ChannelFactory<T>("");

        public static TResult Call<TResult>(Func<T, TResult> operation)
        {
            var channel = _factory.CreateChannel();
            TResult result = operation(channel);
            return result;
        }

        public static void Call(Action<T> action)
        {
            Call(p => { action(p); return true; });
        }
    }
}

Postanowiłem wykorzystać tu pewną właściwość pól statycznych i generowania oddzielnych typów dla każdej konfiguracji. Jak to działa? Otóż każdy z typów: Proxy<int>, Proxy<bool>, Proxy<Calculator> będą miały oddzielne pole statyczne. W ten sposób każdy interfejs z którym się komunikujemy będzie miał swoje pole statyczne do przechowywania fabryki kanałówJest to szczególnie istotne w kontekście wydajności całego rozwiązania. Trzeba wiedzieć, że tworzenie obiektu ChannelFactory<T> jest bardzo kosztowne. Koszt tworzenia to przede wszystkim koszt budowania drzewa kontraktu (zawiera informacje o tym, w jaki sposób przebiega komunikacja ze światem zewnętrznym) oraz dynamicznego pobrania informacji o typach wykorzystywanych w komunikacji. Koszt tworzenia kanału jest stosunkowo niski i, o ile nie mamy stwierdzonych problemów wydajnościowych z tym związanych, nie należy się tym przejmować.. Co ważne, każda z fabryk korzysta z domyślnej konfiguracji ("") co zwalnia z potrzeby przekazywania tego dodatkowego parametruMożna wprawdzie stworzyć wiele końcówek ale rzadko zdarza się, aby jedna aplikacja komunikowała się z jedną usługą wybierając sobie dynamicznie sposób komunikacji. Jeszcze nigdy nie spotkałem się z takim rozwiązaniem i nawet trudno mi jest wyobrazić sobie taki schemat. Jeżeli część komunikacji jest szyfrowana a część jawna to najczęściej nie umieszcza się ich w jednym interfejsie i nie przełącza konfiguracji. Znacznie czytelniejszym rozwiązaniem jest wydzielenie dwóch interfejsów. Jeżeli zachodzi potrzeba stosowania wielu konfiguracji, klasa musi zostać przeprojektowana.

Oddzielnego wyjaśnienia wymaga budowa metody Call. Przyjmuje ona funkcję, której parametrem wejściowym jest interfejs wykorzystywany podczas komunikacji. Za jego pomocą będziemy mogli odwoływać się do wszystkich metod tegoż interfejsu. Dzięki zdolności wyrażeń lambda do przechwytywania zmiennych spoza swojego kontekstu otrzymujemy możliwość przekazywania wartości praktycznie w taki sam sposób jak w przypadku zwykłych metod. Typ TResult jest ponadto automatycznie rozpoznawany na podstawie wartości zwracanej przez wyrażenie lambda. Co więcej, mamy teraz takie miejsce, przez które będą przechodzić wszystkie wywołania WCF. Co jednak w przypadku metod, które nic nie zwracają? Nie ma przecież możliwości przekazania void jako typu ogólnego TResult? Tu potrzebna jest drobna sztuczka. Korzystamy z metody Call napisanej wcześniej, lecz zamiast po prostu wywoływać metodę zwracającą wartość, wywołujemy akcję, a następnie zwracamy true. W ten sposób typ zwrotny zostanie rozpoznany jako bool, ale przecież my nie musimy z tą wartością nic robić - ignorujemy ją. Takie obudowanie pozwala nam na wiele rzeczy, ale o tym za chwilę. Popatrzmy teraz na sposób wywoływania takich metod:

//Pobierz wartość
var result1 = Proxy<ICalculator>.Call(p => p.Add(2,4));
Console.WriteLine(result1);

//Wykonaj akcję
Proxy<ICalculator>.Call(p => Console.WriteLine("{0}", p.Add(1, 2)));

//Wykonaj szereg akcji i zwróć wynik
var result2 = Proxy<ICalculator>.Call(p =>
{
    var sum1 = p.Add(2, 4);
    var sum2 = p.Add(1, 3);
    return (sum1 + sum2)/2;
});
Console.WriteLine(result1);

Console.WriteLine(Proxy<IMessenger>.Call(p=>p.Hello("WCF")));

Wywołanie usługi WCF nie powinno być za bardzo ukryte, bo możemy utracić zdolność szacowania czasów wykonańPrzyjęło się, statystycznie jest to pewnie uzasadnione, że zwykłe odwołania do właściwości nie są kosztowne. Jeżeli jednak za tymi odwołaniami kryją się wywołania sieciowe (było to bardzo powszechne np. w .NET Remoting), możemy być zaskoczeni. Zwykłe odczyty i przypisania wymagały przecież wysłania komunikatu i odebrania odpowiedzi. Sprawiało to, że na przykład takie instrukcje jak p.A = p.B + p.C doprowadzały do powstania trzech komunikatów w jedną stronę i trzech w drugą (prośba o wartość C i odpowiedź, prośba o wartość D i odpowiedź, prośba o ustawienie A i potwierdzenie). To samo pojawia się w przypadku wywołań COM, o czym zapewne dobrze wiedzą programiści mający za sobą zadania związane z automatyzacją Word i Excel.. Metoda może wyglądać zwyczajnie, ale ukrywać odbywającą się pod spodem transmisję, konwersję typów i serializację, czasem szyfrowanie. Metoda wykonująca takie rzeczy powinna się jakoś odróżniać od zwykłych metod.

Rozwiązanie pokazane powyżej ma ponadto szereg innych zalet.

Jednolita obsługa wyjątków

Obsługa wyjątków związanych z kanałami i wywołaniami WCF nie jest łatwa. Same usługi mogą zwracać wyjątki, może być ustalony jakiś sposób przekazywania informacji o błędzie, serwer może być niedostępny, może upłynąć czas żądania, wielkość odpowiedzi może przekroczyć dozwolone limity, zabezpieczenia komunikacji mogą być nieprawidłowe, niezgodny protokół. Gdybyśmy chcieli umieszczać taką obsługę błędów wszędzie, namnożylibyśmy niepotrzebnego kodu, z którym ktoś kiedyś będzie sobie musiał radzić. Może nawet my sami. Popatrzmy na jedno z rozwiązań wykorzystujące fakt, że wszystkie wywołania przechodzą przez jedną metodę:

public static TResult Call<TResult>(Func<T, TResult> operation)
{
    T channel = default(T);
    try
    {
        channel = _factory.CreateChannel();
        TResult result = operation(channel);
        return result;
    }
    catch (Exception)
    {
        ((IClientChannel)channel).Abort();
        throw;
    }
}

W tym przypadku jedynym zadaniem bloku catch jest przechwycenie wyjątku i zwolnienie kanału do puli (ChannelFactory<T>, w celach wydajnościowych, tworzy pulę kanałów). Wyjątek przekazywany jest dalej, ale można go przetworzyć w dowolny sposób, można różne wyjątki sprowadzić do jednego typu, można przetworzyć techniczne komunikaty o błędach na coś bardziej czytelnego dla nietechnicznego użytkownika. W praktyce będzie się tutaj pojawiało wiele sekcji catch. Nie będę ich tu zamieszczał, bo niepotrzebnie skomplikowałyby przykład.

Pomiar czasu wykonywania się metod WCF

W złożonych systemach zdarza się, że największy spadek wydajności spowodowany jest koniecznością komunikacji pomiędzy wieloma systemami. Metoda jakiegoś serwisu internetowego może potrzebować jednego wywołania do pobrania danych o użytkowniku z centralnego systemu, kolejnego do wysłania maila z informacją o ofercie, jeszcze innego do weryfikacji uprawnień. Każde takie wywołanie, tak jak pobranie strony z Internetu, może potrwać. Często stosuje się również wymagania do usług w zakresie czasu odpowiedzi. Istnieje wiele technik wykonywania takich pomiarów, lepszych lub gorszych. Nie ma jednak wątpliwości, że przy pokazanym układzie da się to zrobić wyjątkowo łatwo:

try
{
    //Początek wywołania - możemy wliczyć czas tworzenia kanału
    channel = _factory.CreateChannel();
    TResult result = operation(channel);
    //Koniec wywołania, czas jest różnicą początku i końca
    return result;
...

Kilka wygodnych metod pomiaru czasu pokazanych jest w artykule Pomiar czasu wykonywania metod w C#, zachęcam do zapoznania się.

Uzupełnianie wspólnych nagłówków

Systemy, pomiędzy którymi pojawia się komunikacja WCF, mają często jakiś ustalony wzorzec komunikacji. Może istnieć jakiś nagłówek informujący o wywołującym, czasie wysłania komunikatu, jakiś unikatowy identyfikator transakcji pozwalający powiązać, sparować żądanie i odpowiedź w jakimś systemie śledzącym ruch w sieci. Może zawierać cokolwiek. Znów, jeden punkt, przez który przechodzą wszystkie żądania i wszystkie odpowiedzi, staje się doskonałym miejscem na uzupełnianie tego typu informacji. Nie będę zamieszczał przykładu, bo chyba nie wniesie on nic nowego.

Podsumowanie

Tworzenie wygodnej infrastruktury wywołań WCF jest niezwykle istotne podczas projektowania większego systemu. Gołe wywołania metod WCF porozrzucane po całym systemie stają się bardzo niewygodne w zarządzaniu. Starałem się pokazać jak w prosty sposób można sobie poradzić z tymi najważniejszymi. Trzeba jednak wiedzieć, że WCF ma mnóstwo punktów, w które możemy wstrzyknąć własne rozszerzenia. To elementy definiujące zachowanie się całej infrastruktury (ang. behavior). Możemy stworzyć centralny punkt obsługi wyjątków, wykonać pomiar czasu. Możemy również przechwycić wszystkie żądania i odpowiedzi, a następnie zapisać je w pliku logów. Pozwoli to w przyszłości na analizę błędów. O tym jednak innym razem. Wywołania WCF są tak wrażliwym punktem systemu, że warto mieć je wszystkie pod kontrolą. I o tym był dzisiejszy wpis.

Kategoria:Windows Communication Foundation

, 2016-06-22

Komentarze:

123 (2017-03-13 12:41:52)
Częsta wada poradników dotyczących wprowadzenia do nowych technologi. Dużo trywialnych rzeczy jest zostawionych domysłowi. Kiedy człowiek zastanawia się jak to działa chciałby mieć całościowy obraz a nie wycinek funkcjonalny. Powinien Pan zacząć od tego jaki projekt(ciągle robimy konsolowy?) dla czego nie predefiniowany WCF itp itd. Tego typu pierdoły są bardzo ważne kiedy ktoś widzi coś pierwszy raz na oczy
123 (2017-03-13 12:56:22)
Interfejsy ICalculator oraz IMessenger wskazujesz jakby były w jednym pliku a sam masz dwa różne. Takie nieścisłości są najgorsze...
123 (2017-03-13 14:52:34)
W tych poradnikach jeden jest praktyczny z widocznym efektem na koniec, a drugi bardziej teoretyczny(pokombinuj i sam zobacz efekty). Przydało by się trzymać jednej metody nauki przekazywania wiedzy
Dodaj komentarz
Wyślij
Ostatnie komentarze
chcę dodać kolumnę, która będzie połączeniem dwóch innych istniejących już kolumn, jak powinien wyglądać scrypt?
Przydałyby się jeszcze 2 rzeczy do cz. 3 i byłoby superanckie.
1. Na starcie sortuje wg jakiejś kolumny i tam jest już strzałeczka. Widok takiej strzałeczki daje znać użytkownikowi, że taką tabele można sortować, a na razie pojawia się ona tylko po kliknięciu.
2. Uwzględnienie polskich znaków, bo np. przy sortowaniu Nazwisk i Imion jest to bardzo uciążliwe.
Ogólnie bardzo fajnie i prosto.
PS. Jest ten artykuł z jQuery już dostępny.
bardo ciekawe , można dzięki takim wpisom dostrzec wędkę..
Bardzo dziękuję za jasne tłumaczenie z dobrze dobranym przykładem!