Wstęp
Zdecydowałem się uruchomić kolejną “ścieżkę” na tym blogu. Polega ona na implementacji pewnego problemu i zaprezentowaniu tutaj rozwiązania w całości, jako solution Visual Studio. Jest to mój pierwszy taki post, jednak w przyszłości planuję dalej “podążać za białym królikiem” aż do jego nory ozdobionej kratką #.
Uważam, że przeglądanie cudzego kodu to najlepszy sposób na poznanie możliwości języka, naukę pewnych praktyk programistycznych oraz weryfikację własnych przyzwyczajeń, dlatego też chcę podzielić się moim sposobem kodowania. Z drugiej strony liczę na komentarze Czytelników dotyczące przedstawionych rozwiązań. Co jest źle pomyślane, co można ulepszyć, co się podoba a co nie? W ten sposób może wszyscy się nauczymy czegoś nowego. Dodatkowo – czuję perwersyjną satysfakcję gdy jeździ się po mnie jak po burej suce, więc zapraszam do ostrej krytyki i podzielenia się lepszymi propozycjami :). Poziom szczegółowości – dowolny, od pojedynczych metod do całego podejścia do problemu.
Ale koniec pierdół, przejdźmy do właściwej części:
.ZIP
Plik z kodem źródłowym jest dostępny do pobrania na stronie SAMPLES.
Treść
Na forum CodeGuru regularnie pojawiają się pytania o architekturę rozwiązań klient/serwer, zwykle w oparciu o zwykłe Sockety lub klasy pomocnicze z przestrzeni nazw System.Net.Sockets. W tym poście zaprezentuję przykładowe rozwiązanie takiego problemu.
Elementy wykorzystane w tym programie
- mechanizm gniazd w C#
- wątki
- zdarzenia
- serializacja
- typy ogólne (generics)
- metody rozszerzające (extension methods)
- wyrażenia lambda (lambda expressions)
Fizyczna struktura projektów
Całość składa się z kilku projektów:
- TcpIp.Server – biblioteka oferująca nasłuchiwanie na połączenia przychodzące od klientów oraz komunikację z nimi
- TcpIp.Client – biblioteka z zaimplementowanym klientem łączącym się do uruchomionego wcześniej serwera
- TcpIp.Common – struktury współdzielone pomiędzy obie strony komunikacji
- TcpIp.Extensions – miejsce na dodawanie własnych rozszerzeń do oferowanej funkcjonalności
- TcpIp.Server.Console – konsolowa aplikacja uruchamiająca serwer
- TcpIp.Client.WinForms – okienka wykorzystujące funkcjonalność klienta
Na największą atencję zasługują pierwsze cztery biblioteki, ponieważ to one będą ewentualnie wykorzystane gdzieś indziej. Testowa konsola i okna są zrobione ot tak, żeby można było po prostu zobaczyć jak działa całość. Jak widać – nie ma testów jednostkowych. Moim zdaniem dołączenie ich jako milczącego dodatku spowodowałoby więcej szkód niż korzyści.
Na co zwrócić uwagę
Ten post nie ma być dokumentacją całego rozwiązania, jedynie wskazywać pewne punkty w implementacji warte szczególnego zainteresowania. Więc po kolei:
Przesyłane dane: MessageBase
Wszystkie przesyłane informacje grupowane są w serializowane klasy dziedziczące z MessageBase (np LoginMessage zawiera Id, a TextMessage nadawcę, odbiorcę, datę oraz treść wiadomości). We wspomnianych wątkach na CG często radą jest implementacja własnego protokołu tekstowego, co oczywiście działa, ale wymaga dużo więcej zachodu i jest o wiele trudniejsze w utrzymaniu
Dodawanie dodatkowych informacji: Package, PackageHeader
Klasa Package to po prostu opakowanie właściwych informacji przesłanych pomiędzy klientem i serwerem. Składa się ona jedynie z nagłówka (PackageHeader) oraz samej wiadomości. Nagłówek w obecnej postaci nie jest zbytnio przydatny, ponieważ przechowuje on tylko czas utworzenia wiadomości przed jej wysłaniem. Można tam jednak dodać kolejne pola wymagane przez konkretne zapotrzebowanie biznesowe, jak na przykład informacje o aktualnym użytkowniku w którego kontekście wykonywana jest operacja wysłania wiadomoćci.
Przetwarzanie odebranych danych: IMessageHandler, MessageHandlerManager
Po raz kolejny odwołam się do forum CG, na którym często polecanym sposobem na poradzenie sobie z komunikacją jest odebranie pierwszej linii wiadomości (zawierającej tekstowy identyfikator operacji do wykonania), a następnie w mega-ogromnej instrukcji SWITCH wykonywanie odpowiednich czynności. Pozwolę sobie nie wymieniać wad takiego rozwiązania tylko przejdę od razu do mojej propozycji. Po pierwsze – klasy serwera i klienta mają służyć do obsługi KOMUNIKACJI, bez zagłębiania się w szczegóły przesyłanych danych. Do tego założeniem moim było udostępnienie możliwości rozszerzenia istniejącej implementacji bez konieczności zmian w kodzie klienta i serwera – czyli taki minifrejmłorczek.
Dlatego też postanowiłem, że każdemu typowi przesyłanych wiadomości będzie odpowiadać klasa implementująca interfejs IMessageHandler. Deklaruje on tylko jedną metodę, HandleMessage, i typ poradzenia sobie z wiadomością jest całkowicie niezależny od reszty systemu. Przykładowo, zaimplementowany przeze mnie LoginMessageHandler przypisuje odpowiednie Id komponentowi odbierającemu tą wiadomość, a serwerowa implementacja TextMessageHandler przekazuje wiadomość tekstową do dalszego przesłania pod numer odbiorcy.
Delegacją wiadomości do odpowiednich handlerów, jak też zachowaniem mapowania pomiędzy typem wiadomości a instancją handlera, zajmuje się klasa MessageHandlerManager.
Zaproponowana implementacja posiada ograniczenie – tylko jeden handler może obsługiwać jeden typ wiadomości. Nic nie stoi jednak na przeszkodzie, aby doimplementować mechanizm łączenia handlerów w łańcuchy wykonujące po kolei cały zestaw czynności. W ten sposób można by było zaplanować całą skomplikowaną procedurę wywoływaną krok po kroku przy nadejściu danych informacji.
Słowo o serwerze: klasy Server i SingleClientChannel
Architektura serwera jest raczej standardowa. Głównym jej składnikiem są dwie klasy, Server oraz SingleClientChannel, służące odpowiednio do oczekiwania na nowe nadchodzące połączenia oraz do komunikacji z jednym konkretnym klientem. Odpalenie wątku i zapisywanie/odczytywanie ze strumienia – koniec.
Automatyczne wykrywanie serwera: Client.AutoReconnect
W klasie Client zaimplementowałem możliwość automatycznego łączenia się z serwerem aż do skutku, co X milisekund. W ten sposób użytkownik klasy nie musi martwić się sprawdzaniem połączenia – wywołuje Connect() na kliencie i to tyle.
Jak poinformować system o nadejściu wiadomości: MessageNotifications
O ile nadejście wiadomości na serwerową stronę systemu zawsze będzie w całości obsłużone przez odpowiednie handlery, o tyle strona kliencka powinna oferować mechanizm powiadamiania całego systemu o tym fakcie. Do tego służy oczywiście mechanizm zdarzeń. Dlatego też w projekcie TcpIp.Client.WinForms proponuję statyczną klasę MessageNotifications, która umożliwia wywołanie własnych zdarzeń przez handlery znajdujące się w tej samej bibliotece. Odpowiednie komponenty, jak na przykład główne okno aplikacji, może podpiąć się do nich i wyświetlać treść wiadomości do pola tekstowego – co też właśnie czyni.
Rozszerzalnośc rozwiązania: TcpIp.Extensions
Dodałem do projektu kilka elementów ukazujących możliwości rozszerzania tego mechanizmu własnym kodem. Przykładowa biblioteka Extensions zawiera implementację nowego typu wiadomości (LoginWithPasswordMessage), a konsolowa aplikacja rejestruje do niej handler całkowicie niezależnie od standardowo odbywających się rejestracji. Dodatkowo w konsolowej aplikacji serwerowej dopisałem nowy handler (IgnoringAnonymousHandler), który powoduje zignorowanie wiadomości tekstowych nadesłanych od klienta nieposiadającego prawidłowego numeru Id. Tam też podmieniłem standardowo rejestrowany handler.
“A teraz coś z zupełnie innej beczki”: ControlExtensions.InvokeIfRequired
Nie ma to bezpośredniego związku z komunikacją klient/serwer, jednak pozwolę sobie zwrócić tutaj uwagę na fajny sposób radzenia sobie z aktualizacją UI z wątku innego niż główny. Chodzi o metodę Procent.Samples.TcpIp.Client.WinForms.Utils.ControlExtensions.InvokeIfRequired.
Podsumowanie
Moim celem była prezentacja sposobu rozwiązania problemu komunikacji TCP/IP pomiędzy serwerem i klientem. Można mieć wątpliwości co do sensowności niektórych elementów (np. brak obsługi hasła podczas logowania), jednak nie o to tutaj chodziło. Chętnie poznam opinie i uwagi dotyczące załączonego kodu, więc komentarze jak zawsze mile widziane. Mam jednocześnie nadzieję, że chociaż kilka osób znalazło w tym projekcie trochę inspiracji i nauczyło się czegoś nowego.