sockets

Komunikacja z socket’ami

Gniazda (sockets)


Socket’y to bramy internetowe służące do komunikacji z np. innymi aplikacjami przez internet. Socket’y mogą odbierać i wysyłać dane. Istnieje kilka rodzajów socket’ów, najczęściej używane to UDP oraz TCP.

 

TCP vs UDP


Ważną rzeczą jest wybór socketu odpowiadającego twoim wymaganiom.

Podstawową różnicą pomiędzy socketami w przypadku TCP jest to, że gdy jesteś podłączony z jakimś socketem to nie możesz nawiązać połączenia z kolejnym. Połączenie tutaj odbywa się na koncepcji klient-serwer, najpierw klient łączy się z serwerem i dopiero wtedy może wymieniać z nim dane. Jeżeli serwer chce się połączyć z klientem to nasłuchuje połączenia.

UDP może odbierać/wysyłać dane poprzez połączenie z wieloma komputerami za pomocą jednego socketa.

Drugą różnicą jest to że TCP jest bardziej niezawodne niż UDP, tzn. w przypadku TCP masz pewność, że dane zawsze zostaną dostarczone w poprawny sposób (nieuszkodzone i w właściwej kolejności). W przypadku UDP dane są mniejszą ilość razy sprawdzane, więc możesz otrzymać te same informacje np. kilka razy, w innej kolejności lub zostaną gdzieś zagubione, ale za to dużo szybciej. Co ważne te dane też są zawsze poprawne (nieuszkodzone). UDP może brzmieć strasznie i mało praktycznie, ale w większości przypadków wszystkie dane są odbierane prawidłowo i we właściwej kolejności.

Ostatnią różnicą jest to w jaki sposób są dane transportowane. TCP jest strumieniowym protokołem komunikacyjnym, co oznacza że jeżeli prześlesz wiadomość „Hello”, a następnie „SFML” to klient może otrzymać: „HelloSFML”, „Hel” + „loSFML”, lub nawet „He” + „loS” + „FML”. Czyli te dane możesz otrzymać za jednym razem, w 2 częściach lub 3, itp.

UDP to protokół pakietów, a więc dane nie mogą mieć mieszane. Czyli to co wyślesz zostanie otrzymane dokładnie w takiej samej formie jak zostało wysłane.

Ostatnia rzecz o której częściowo wspomniałem, ponieważ UDP nie jest podłączone, to zezwala nam na wysyłanie wiadomości do wielu odbiorców, a nawet do sieci. TCP z racji, że jest połączone tylko z jednym klientem (komunikacja one-to-one) nie może tego robić.

 

Połączenie TCP


Jak można się domyślić ta część jest specyficzna dla socketów TCP. Istnieją tutaj 2 strony połączenia: pierwsza, która czeka na połączenie (serwer) oraz druga która je nawiązuje (klient).

Jeżeli chodzi o klienta to sprawa jest prosta, użytkownik potrzebuje jedynie klasy sf::TcpSocket oraz metody connect.

Aby sprawdzić swoje IP możesz użyć klasy sf::IpAdress, która jest także pierwszym argumentem funkcji connect. Jako IP możemy podać adres URL, albo właśnie normalny adres IP. Drugim argumentem jest port do którego mamy się podłączyć. Połączenie uda się tylko wtedy gdy na tym samym porcie czeka na nas serwer.

Jest także trzeci opcjonalny argument, który określa ile czasu ma czekać klient na nawiązanie połączenia.

Po stronie serwera jest więcej rzeczy do zrobienia. Potrzebne jest do tego wiele socketów: jeden do nasłuchiwania połączenia oraz po jednym dla każdego klienta.

Do nasłuchiwania połączeń używa się klasy sf::TcpListener, jej jedynym zadaniem jest nasłuchiwanie połączenia na określonym porcie. Nie może wysyłać i odbierać danych.

 

Przypisanie portu UDP


W UDP jest nieco prościej bo nie trzeba tworzyć klienta i serwera, nasłuchiwać połączeń itd. Należy stworzyć sobie gniazdo sf::UdpSocket, w przypadku odbierania należy za pomocą funkcji bind określić na jakim porcie chcemy odbierać dane.

Po przypisaniu socketu do portu wszystko jest już gotowe do obierania danych. Jeżeli chcesz aby twój OS przypisał port automatycznie możesz użyć sf::Socket::AnyPort oraz sprawdzić otrzymany port za pomocą socket.getLocalPort().

Gniazda UDP nie potrzebują nic więcej do rozpoczęcia wysyłania danych.

 

Wysyłanie i odbieranie danych


Wysyłanie i odbieranie danych wygląda tak samo dla obu typu gniazd. Jedyną różnicą jest to że UDP posiada 2 dodatkowe argumenty: adres oraz port nadawcy/odbiorcy. Istnieją dwie funkcje dla każdej operacji: nisko poziomowe, które wysyłają/obierają surowe tablice danych oraz wysoko poziomowe, które korzystają z sf::Packet, zobacz tutorial o pakietach aby dowiedzieć się więcej na ten temat. Tutaj zajmiemy się nisko poziomowymi metodami.

Aby wysłać dane musisz wywołać metodę send z wskaźnikiem na wysyłane dane oraz ilością bajtów do wysłania.

Jako argument danych jest użyty wskaźnik void*, co oznacza że jako dane możesz wysłać cokolwiek, jednakże uważa się za zły pomysł wysyłania danych, które nie są tablicami bajtów, ponieważ miejscowe typy większe niż 1 bajt nie muszą zajmować tyle samo na innych urządzeniach. Takie typy jak int czy long mogą mieć inny rozmiar, nie mogą być także wysyłane pomiędzy różnymi systemami. Ten problem został rozwiązany w poradniku o pakietach.

Za pomocą UDP możesz wyemitować specjalną wiadomość do całej podsieci za pomocą specjalnego adresu: sf::IpAdress::Broadcast.

Jest jeszcze jedna rzecz, którą powinieneś wiedzieć o UDP. Ponieważ dane są wysyłane w datagram’ach oraz mają one swój limit, którego nie możesz przekroczyć. Każde wywołanie send musi wysłać mniej bajtów niż sf::UdpSocket::MaxDatagramSize, czyli nieco mniej niż 2^16 (65536) bajtów.

Aby odbierać dane musisz użyć metody receive:

Warto zapamiętać, że jeżeli socket jest w trybie blokowania to receive będzie czekało aż jakieś dane zostaną odebrane.

Pierwsze dwa argumenty to bufor do którego są kopiowane dane oraz maksymalna wielkość przyjmowanych danych. Ostatni argument to zmienna, który sprawdza ile danych zostało przyjętych.

W przypadku UDP 2 ostatnie argumenty to IP oraz port nadawcy. Mogą być wykorzystane później, np aby mu odpowiedzieć.

Te funkcje są nisko poziomowe i powinieneś ich używać jedynie wtedy gdy masz dobry powód do tego. Lepszym rozwiązaniem jest użycie pakietów.

 

Blokowanie grupy socket’ów


Blokowanie pojedynczego gniazda może szybko stać się irytujące, ponieważ zazwyczaj musisz obsłużyć wielu klientów. I nie chcesz używać gniazda A dopóki gniazdo B przyjmuje jakieś dane. To czego chcesz to blokowanie wielu socketów naraz, czyli czekanie aż któryś z nich otrzyma jakieś dane. Może być to osiągnięte za pomocą klasy sf::SocketSelector.

Selektor może obserwować wszystkie typy gniazd: sf::TcpSocket, sf::UdpSocket sf::TcpListener. Aby dodać gniazdo do selektora użyj metody add.

Selektor nie jest kontenerem gniazd. Jeżeli podasz jedynie wskaźniki na sockety to on ich nie przechowam, a więc nie możesz pobierać lub liczyć socketów, które włożyłeś do środka, aby to robić musisz utworzyć własny kontener na sockety (np std::Vector lub std::list).

Gdy już zapełniłeś selektor gniazdami, które chcesz obserwować musisz wezwać metodę wait dopóki czegoś nie odbierzesz (lub dostaniesz błąd). Możesz także ustawić opcjonalny czas oczekiwania, a więc jeżeli zostanie zwrócony błąd jeżeli nie zostaną odebranie żadne dane w określonym czasie. Dzięki temu będziesz mógł uniknąć czekania.

Jeżeli wait zwraca true to oznacza, że przynajmniej w jednym gnieździe odebrano jakieś dane oraz możesz bez obawy uruchomić receive na tym sockecie, który przestanie być blokowany. Jeżeli tym socketem jest sf::TcpListener to oznacza, że socket jest gotowy do połączenia i możesz użyć metody accept.

Ponieważ selektor nie jest kontenerem socketów to nie może podać ci wskaźnika na socket, który jest gotowy, dlatego też musisz sprawdzić wszystkie gniazda i sprawdzić, który z nich jest gotowy do użycia.

Polecam spojrzeć do dokumentacji sf::SocketSelektor aby zobaczyć jak należy odbierać i wysyłać dane od/do wielu klientów.

Jako bonus możliwość czekania Selector::wait, która umożliwia implementację funkcji do odbierania danych z użyciem timeout, która nie jest dostępna bezpośrednio w klasie.

 

Nie-blokowane sockety


Wszystkie sockety są standardowo blokowano jednak możesz użyć setBlocking do zmiany ich zachowania.

Gdy gniazdo ustawione jest na non-blocking to jego funkcje zawsze zwracają się bezzwłocznie. Na przykład receive zwróci sf::Socket::NotReady jeżeli nie ma dostępnych danych. Lub accept zwróci ten sam status jeżeli nie ma żadnego połączenia.

Takie ustawienie socketów jest najprostszym rozwiązaniem w przypadku gdy ich używamy w głównej pętli co określony czas. Dzięki temu nie blokujemy pętli, ponieważ sprawdzamy czy socket jest gotowy do działania przy każdym obiegu pętli.

Oryginalny artykuł


  • Marian Kb

    Myślałeś może o odpowiedniku tego poradnika w Qt?

    • Nie, ale najprawdopodobniej się pojawi, z tej racji że obecnie moje zainteresowania przeszły na wydawanie programów.