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.
Odnośnie opisu, to według mnie atrakcyjniejsze byłoby podejście top-down, w którym najpierw przedstawiana byłaby koncepcja całego rozwiązania (ot na przykład wyjaśnienie, co to jest pakiet, z czego jest zbudowany, czy można go rozszerzać), a dopiero dalej, jeśli w ogóle, poruszane byłyby zagadnienia związane z implementacją. Myślę, że to właśnie koncepcja jest istotna i może zachęcić do zerknięcia w kod, a nie odpowiedzialności poszczególnych klas. Proponowałbym również przenieść link do archiwum z kodem źródłowym na sam koniec notki, wydaje mi się, że byłby wtedy bardziej zauważalny. Mam pewną uwagę do organizacji kodu: byłoby dużo wygodniej, gdybyś wrzucił kod klientów i serwera… Read more »
Norrrrmalnie Olek jestem pod wrażeniem – że chce ci się robić takie rzeczy:) Dzięki za uwagi. Z pewnością każdy może wynieść z nich coś pożytecznego, o coś takiego mi chodziło.
Co do formy przedstawienia treści zamieszczonego posta… wiem że można to było zrobić dużo lepiej. Mam jednak na to tyle czasu, że od zaświecenia w głowie pomysłu stworzenia czegoś takiego do zamieszczenia gotowego materiału na blogu minęły aż 3 tygodnie. Gdybym starał się dopieścić każdy element tak jak mi się wymarzyło to pewnie minęłoby co najmniej drugie tyle:).
Może ktoś doda coś jeszcze (oprócz "nie zostało nic do dopisania":) )?
nie zostało nic do dopisania ;), apl wyczerpał temat, zostaje tylko kiwać głową i czekać na kolejny wpis na blogu ;)
a jeszcze coś dodam jednak ;) brakuje mi tutaj takiej formy jak jest na NeHe, gdzie jest jakiś kod dołączony, ale oprócz tego autor zamieszcza poszczególne fragmenty kodu i opisuje co konkretnie ten fragment robi i dlaczego to robi. Jakoś tak z czasów gdy robiłem w OpenGL ta forma mi bardziej odpowiada.
@tom:
dzięki za uwagi, zamieszczając następnym razem coś nadającego się do działu Samples postaram się bardziej rozwinąć część tekstową
Warto spojrzeć na webcast Ayende Rahien [u]http://www.ayende.com/97/section.aspx/download/217[/u] który opisuje szczegółowo tworzenie własnej enterprise service bus. W jego rozwiązaniu handlowanie wiadomości jest rozwiązane podobnie jak u macka gdzie każda wiadomość jest rozpoznawana na podstawie typu. Przy czym zaletą w rozwiązaniu Ayende jest wyeliminowanie rejestrowania poszczególnego MessageHandlera dlatego że jest on wyciągany z kontenera IOC. Zaś sama rejestracja MessageHandlera w kontenerze nie wymaga żadnego pliku konfiguracyjnego i dodawania poszczególnego handlera, gdyż każdy MessageHandler dziedziczy po AbstractMessageHandler(więcej na ten temat w kodzie prezentacji) Type msgHandlerType = typeof (AbstractMessageHandler<>) .MakeGenericType(msg.GetType()); var handler = (IMessageHandler) IoC.Resolve(msgHandlerType); handler.Handle(msg); Odnośnie wersjonowania zastanawia mnie sytuacja opisana przez Olka,… Read more »
Romek, dzięki za komentarz, chociaż nie zgodzę się z Tobą :). [i]Przy czym zaletą w rozwiązaniu Ayende jest wyeliminowanie rejestrowania poszczególnego MessageHandlera dlatego że jest on wyciągany z kontenera IOC.[/i] Osobiście nie widzę różnicy. Wszystkie rozwiązania – moje, Procenta, Ayende – zakładają, że jest gdzieś jakiś statycznie dostępny pojemnik (ja nazywam go fabryką, Procent menadżerem, zaś Ayende pojemnikiem IoC), w którym najpierw rejestrujemy handlery (bezpośrednio lub pośrednio, przez fabryki), a następnie używamy go do znajdowania handlerów do obsługi konkretnych typów wiadomości. Ayende w zasadzie jedynie zautomatyzował sobie proces rejestrowania handlerów w pojemniku. [i]Odnośnie wersjonowania zastanawia mnie sytuacja (…), gdzie dochodzi… Read more »