Projektowanie oprogramowania systemów KOMUNIKACJA SIECIOWA I SYSTEMY RPC
plan programowanie sieciowe BSD/POSIX Socket API systemy RPC interfejsy obiektowe CORBA DCOM RMI WebServices WSDL/SOAP XML-RPC REST
programowanie sieciowe Termin programowanie sieciowe (network programming) odnosi się do tworzenia aplikacji, które komunikują się ze sobą za pośrednictwem sieci komputerowej Komunikacja sieciowa to specyficzna forma komunikacji międzyprocesowej (IPC), w której komunikujące się procesy znajdują się na osobnych maszynach Nie używamy więc obiektów IPC, których zasięg ograniczony jest do pojedynczej maszyny (pamięci współdzielonej, semaforów, mutexów ) Komunikacja odbywa się za pośrednictwem gniazd sieciowych (network sockets) i protokołów komunikacyjnych Najbardziej powszechnym API komunikacji sieciowej jest BSD (POSIX) Sockets API (AKA Berkeley sockets) stąd, programowanie sieciowe nazywamy również programowaniem gniazd
BSD Socket API nazewnictwo i historia Tradycyjnie, API gniazd wywodzi się z dystrybucji Unixa BSD, stąd nazwa BSD Socket (lub Berkeley sockets, bo BSD to Berkeley Software Distribution Unix opracowany na UCal w Berkeley) Na bazie API BSD powstał standard gniazd POSIX (POSIX sockets) API gniazd BSD było oryginalną implementacją protokołów TCP/IP, które są podstawą działania Internetu (tzw. Internet Protocol Suite) W praktyce wszystkie współczesne systemy operacyjne posiadają API gniazd przynajmniej częściowo spójne z BSD włącznie z Windows (tzw. Winsock API)
BSD Socket API - alternatywy Konkurencyjna w stosunku do BSD implementacja Unixa System V używała innego API komunikacji sieciowej: STREAMS (Transport Layer Interface) TLI ściśle opiera się na modelu OSI/ISO ścisła separacja warstw STREAMS było wykorzystywane m.in. w Novell NetWare, Windows NT Wiele implementacji Unixa używa równolegle TLI oraz BSD sockets Wraz z Windows for Workgroups Microsoft promował protokół i API NetBEUI (NetBIOS Extended User Interface) sieć peer-to-peer oryginalnie działająca niezależnie od TCP/IP, w tej chwili praktycznie zawsze bazująca na TCP/IP
gniazda sieciowe słowniczek pojęć gniazdo punkt końcowy (endpoint) komunikacji IPC w oparciu o sieć komputerową adres gniazda kombinacja adresu IP komputera i numeru portu (numer usługi) typ gniazda gniazda datagramowe (protokół: UDP), typ SOCK_DGRAM gniazda strumieniowe (protokół: TCP), typ SOCK_STREAM gniazda surowe, SOCK_RAW użytkownik jest odpowiedzialny za implementację własnego protokołu
tryb nieblokujący I/O nowoutworzone gniazda są blokujące, co oznacza że operacje odczytu/zapisu blokują działanie programu tak długo, aż operacja się powiedzie gniazdo można przestawić w tryb nieblokujący (nonblocking mode), wówczas operacje odczytu/zapisu nie blokują działania programu ale zwracają kod błędu EWOULDBLOCK w momencie, kiedy operacja nie może być wykonana natychmiast (brak danych w buforze odczytu gniazda, przepełniony bufor zapisu gniazda) tryb nieblokujący umożliwia obsługę wielu gniazd równocześnie w pojedynczym wątku poprzez multipleksację I/O (i nie tylko gniazd również plików i wszystkiego co wygląda jak plik, włacznie z UI) w trybie blokującym zwykle każde gniazdo wymaga osobnego wątku, co prowadzi do nieefektywnego wykorzystania zasobów tryb nieblokujący jest zalecany dla wszystkich, poza najbardziej prymitywnymi aplikacjami sieciowymi
multipleksacja I/O rejestrujemy deskryptory plików w specjalnej strukturze fd_set, specyfikując w jakich zdarzeniach związanych z gniazdem/plikiem jesteśmy zainteresowani (możliwość odczytu, możliwość zapisu, sytuacja wyjątkowa) podajemy strukturę jako parametr funkcji systemowej select(), poll() lub podobnej ww. funkcja blokuje działanie programu do momentu aż w którymkolwiek z gniazd wystąpi którekolwiek zdarzenie, a następnie zwraca informacje o rodzaju zdarzenia które wystąpiło obsługujemy zdarzenie np. odczyt/zapis danych z gniazda, które w tej sytuacji nie mają prawa zablokować programu powracamy do pętli select()/poll() oczekujemy na kolejne zdarzenie deskryptor może również odnosić się do źródła zdarzeń UI wówczas możliwa jest obsługa komunikatów UI w tej samej pętli tak działa m.in. X-Windows
gniazda serwerowe i klienckie W przypadku gniazd typów połączeniowych (SOCK_STREAM) zwykle wyróżnia się 2 modele ich użycia gniazda serwerowe powiązane z określonym portem i adresem, nasłuchują w oczekiwaniu na połączenia przychodzące od klientów gniazda klienckie inicjują połączenia z nasłuchującymi gniazdami serwerowymi, nie muszą mieć przypisanego a priori adresu sieciowego Nie jest możliwe nawiązanie sesji pomiędzy dwoma gniazdami klienckimi W przypadku gniazd typów bezpołączeniowych nie ma potrzeby tworzenia gniazd nasłuchujących i oczekiwania na połączenie możliwa jest natychmiastowa komunikacja każdy-z-każdym Podział na gniazda serwerowe i klienckie wynika ze sposobu ich użycia (wywoływanych funkcji) tworzymy je identycznie
BSD API socket() int socket(int domain, int type, int protocol); socket() tworzy nowe gniazdo określonego rodzaju, alokuje dla niego zasoby i zwraca deskryptor pliku parametry domena określa rodzinę protokołów danego gniazda: AF_INET protokoły oparte na IPv4 AF_INET6 protokoły IPv6 AF_UNIX gniazda lokalne (domeny Unixa tylko w systemach Unix) typ rodzaj gniazda (SOCK_DGRAM, SOCK_STREAM, SOCK_RAW, ) protokół specyficzny protokół warstwy transportowej, lub 0 np. IPPROTO_TCP TCP (domyślny dla SOCK_STREAM) IPPROTO_UDP UDP (domyślny dla SOCK_DGRAM) IPPROTO_SCTP IPPROTO_DCCP
bind() int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); bind() wiąże utworzone gniazdo z adresem sieciowym lokalnego komputera nowoutworzone gniazdo nie jest związane z żadnym adresem, za pomocą bind() możemy mu przypisać adres, dzięki czemu będziemy mogli oczekiwać na przychodzące połączenia na określonym adresie/porcie parametry sockfd - deskryptor gniazda addr struktura określająca adres do którego przypisujemy gniazdo (adres IP + numer portu). Jeśli nie podamy adresu IP, gniazdo zostanie powiązane ze wszystkimi adresami IP komputera. Jeśli nie podamy numeru portu, zostanie on wybrany losowo przez OS addrlen rozmiar w bajtach struktury addr (kompatybilność IPv4/IPv6 itp.)
listen() int listen(int sockfd, int backlog); listen() rozpoczyna nasłuchiwanie przychodzących połączeń na danym gnieździe serwerowym o określonym adresie tylko dla gniazd typu strumieniowego (połączeniowych) parametry sockfd deskryptor gniazda backlog rozmiar kolejki nadchodzących połączeń; przychodzące połączenia są umieszczane w kolejce, z której są usuwane poprzez ich zaakceptowanie funkcją accept(); połączenia nadmiarowe będą automatycznie odrzucane
accept() int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen); accept() akceptuje przychodzące połączenie na nasłuchującym gnieździe serwerowym dla zaakceptowanego połączenia przychodzącego tworzone jest nowe gniazdo, które jest połączone w sesję ze zdalnym gniazdem zwraca deskryptor nowego gniazda klienckiego parametry sockfd deskryptor nasłuchującego gniazda serwerowego cliaddr struktura, w której zostanie zapisany adres zdalnego połączonego gniazda addrlen rozmiar struktury cliaddr
connect() int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen); connect() inicjuje połączenie gniazda klienckiego ze zdalnym, nasłuchującym gniazdem serwerowym w przypadku gniazd bezpołączeniowych jedynie określa domyślny cel (endpoint) dla komunikacji parametry sockfd deskryptor klienckiego gniazda, które łączymy ze zdalnym systemem serv_addr adres zdalnego, nasłuchującego gniazda serwerowego addrlen rozmiar struktury serv_addr
łączenie gniazd serwerowych i klienckich typu połączeniowego
odczyt/zapis gniazd w zależności od typu gniazda istnieją 2 zestawy funkcji służących do odczytu/zapisu danych z/do gniazd połączeniowe (SOCK_STREAM) odczyt: ssize_t recv(int sockfd, void *buf, size_t len, int flags); zapis: ssize_t send(int sockfd, const void *buf, size_t len, int flags); bezpołączeniowe (SOCK_DGRAM) odczyt: ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen); zapis: ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
odczyt/zapis gniazd API połączeniowe jest tożsame z wywołaniem standardowych funkcji read()/write() używanych z plikami kiedy parametr flags jest równy 0 (można ich używać zamiennie) Parametr flags umożliwia określenie dodatkowych, zaawansowanych opcji odczytu/zapisu (najczęściej nieużywanych) API połączeniowe może być używane również z gniazdami typu bezpołączeniowego, których domyślny adres określono za pomocą connect() API bezpołączeniowe recvfrom()/sendto() umożliwia podanie docelowego adresu datagramu lub uzyskanie informacji o adresie źródłowym datagramu odebranego
użycie API bezpołączeniowego
zamykanie gniazd gniazda zamykamy i zwalniamy zasoby za pomocą standardowej funkcji close() (na Windows closesocket()) w przypadku połączonych sesją gniazd połączeniowych powoduje to również zerwanie połączenia jest to tzw. abrupt (non-graceful) shutdown i jest nieeleganckie (zwykle oznacza błąd) zaleca się przeprowadzenie procedury graceful shutdown poprzez użycie funkcji shutdown() (co jednak wydłuża czas zamknięcia i komplikuje maszynę stanów)
graceful shutdown shutdown(sd_rdwr) recv() zwraca 0 close() recv() zwraca 0 połączenie zamknięte shutdown(sd_rdwr) close()
odnajdywanie adresów sieciowych maszyny w sieci identyfikowane są za pomocą adresów IP liczb 32- bitowych (IPv4) lub 128-bitowych (IPv6) adresy IP są trudne do zapamiętania i mogą się zmieniać w czasie, dlatego stworzono system nazw i serwerów nazw (domain name system, DNS) będący rozproszoną bazą danych wiążącą nazwy z adresami API BSD umożliwia wyszukiwanie adresów i nazw maszyn w sieci poprzez odpytywanie serwerów DNS i innych baz danych funkcjonalność ta nosi nazwę resolver podstawowy resolver w API BSD jest blokujący nie istnieje standardowy, przenośny mechanizm tworzenia nieblokujących zapytań systemu DNS
resolver struct hostent *gethostbyname(const char *name); gethostbyname() zwraca listę znanych adresów dla hosta o podanej nazwie struktura hostent zawiera w sobie pola określające typ adresu (np. AF_INET, AF_INET6), adres hosta, jego długość oraz nazwę kwalifikowaną powiązaną z tym adresem; wskaźnik do następnego elementu listy z jedną nazwą może być powiązane kilka adresów różnych typów oraz kilka nazw (aliasów) aby poznać wszystkie adresy/nazwy danego hosta należy trawersować listę struktur hostent aż kolejny element będzie pusty
gethostbyaddr() struct hostent *gethostbyaddr(const void *addr, int len, int type); gethostbyaddr() odnajduje inne znane adresy oraz nazwy hosta o podanym adresie i zwraca ich listę identycznie jak gethostbyname() parametry addr adres hosta, którego wyszukujemy len rozmiar adresu type type (np. AF_INET) adresu
resolver POSIX funkcje gethostbyname() i gethostbyaddr() są bardzo rozpowszechnione, ale generalnie są przestarzałe i nie powinny być używane w standardowym API POSIX zostały zastąpione nowszymi funkcjami getaddrinfo() i getnameinfo(), które są bardziej elastyczne, (m.in. umożliwiają wyszukiwanie usług o określonych nazwach) i niezależne od domeny/protokołu w nowych aplikacjach zaleca się korzystanie z nowego API (które jest bardziej rozbudowane ale równocześnie mniej wygodne do prostych zastosowań)
getaddrinfo() int getaddrinfo(const char *hostname, const char *service, const struct addrinfo *hints, struct addrinfo **res); getaddrinfo() umożliwia wyszukanie hosta o określonej nazwie lub sformatowanym w tekst adresie oraz usługi o określonej nazwie lub numerze portu hint pozwala dodatkowo określić kryteria wyszukiwania protokół, etc. wynik zwracany jest w postaci listy dynamicznie alokowanych struktur addrinfo, które muszą być zwolnione za pomocą freeaddrinfo()
plan programowanie sieciowe BSD/POSIX Socket API systemy RPC interfejsy obiektowe CORBA DCOM RMI WebServices WSDL/SOAP XML-RPC REST
mechanizmy RPC RPC Remote Procedure Call zdalne wywołanie procedury oryginalnie termin RPC to nazwa protokołu stworzonego przez firmę Sun do budowy aplikacji rozproszonych (standard RFC1057), m.in. implementacji systemu plików NFS współcześnie terminem tym określa się wszelkie rozwiązania, służące do uruchamiania usług w środowiskach rozproszonych, bez konieczności zaprogramowania niskopoziomowych szczegółów komunikacji w idealnym przypadku wywołanie funkcji/procedury poprzez RPC wygląda z punktu widzenia programisty identycznie jak wywołanie jej na lokalnie, zaś cała komunikacja niezbędna w tym celu odbywa się niejawnie
typowy scenariusz RPC program-klient RPC wywołuje lokalną funkcję wydmuszkę (stub) z poziomu języka programowania stub zamienia przekazane mu parametry wywołania w wiadomość zgodną ze stosowanym protokołem RPC (serializacja parametrów) wiadomość jest wysyłana do serwera RPC oferującego daną usługę po odebraniu wiadomość jest deserializowana poprzez server stub server stub wywołuje lokalną funkcję z odczytanymi parametrami z poziomu języka programowania, odczytuje odpowiedź odpowiedź jest serializowana w wiadomość
tworzenie wydmuszek interfejs wywołań RPC jest opisywany za pomocą standardowego języka IDL interface description language na podstawie opisu IDL generator kodu systemu RPC tworzy wiązanie (binding) pomiędzy wydmuszkami a biblioteką RPC w danym języku programowania, dostarczając niezbędnego kodu serializacji/deserializacji parametrów kod wydmuszki jest kompilowany i łączony do kodu klienta i serwera implementacja wydmuszek po stronie klienta i serwera może być w innym języku programowania, dopóki zachowany jest wspólny protokół RPC (heterogeniczna)
przykład IDL użycie wydmuszki w C++ implementacja serwera w Javie
interfejsy obiektowe współczesne implementacje systemów RPC nie modelują pojedynczych funkcji tylko interfejsy w modelu obiektowym podejście zorientowane obiektowo umożliwia tworzenie zdalnych obiektów przechowujących określony stan oraz ułatwia ukrywanie szczegółów implementacyjnych systemu RPC obiektowo-zorientowane systemy RPC to m.in. CORBA Common Object Request Broker Architecture (standard zdefiniowany przez Object Management Group) DCOM Distributed Component Object Model (Microsoft adaptacja modelu komponentowego COM do środowisk rozproszonych) RMI Remote Method Invocation (Java podsystem wbudowany w każdej implementacji języka)
CORBA jako przykład OO-RPC
usługi internetowe usługi internetowe (Web Services) to podejście opierające się na zastosowaniu standardowych protokołów i formatów danych internetowych (HTTP, XML, JSON) do realizacji wywołań RPC zaletą jest łatwość wdrożenia i debugowania znane, powszechnie stosowane formaty i narzędzia nie ma konieczności wdrażania pełnej, skomplikowanej infrastruktury serwerowej jak w przypadku CORBA aplikacje serwerowe WebServices działają na standardowym serwerze WWW mniejsza szansa że wywołania HTTP zostaną zablokowane przez firewalle, możliwość użycia standardowych serwerów proxy podobnie jak w przypadku tradycyjnego RPC istnieją narzędzia, umożliwiające generowanie wydmuszek z języka IDL lub specjalnego języka WSDL (Web Service Definition Language dialekt XML)
podejścia web services sformalizowane opis interfejsu WSDL protokół komunikacyjny SOAP wsparcie narzędzi, generatorów Java,.NET przerost formy nad treścią inwokacja naszego sayhello() i przesłanie kilkunastu znaków wymaga kilku tysięcy znaków w formacie XML luźne, ad hoc wszystko co wygląda i działa jak web service to jest web service XML-RPC proste fragmenty XML przesyłane za pomocą HTTP JSON-RPC analogicznie jak wyżej, ale dane w formacie JSON RESTful interoperatybilność najczęściej jedynie pomiędzy wydmuszkami generowanymi przez tą samą bibliotekę wsparcie języków skryptowych Python, PHP, JavaScript