Drodzy, dziś mam dla Was tutorial, który możecie wykonać na dowolnej dystrybucji Linuxa. Omawiać będziemy szczegóły niezależne od tego, jak bardzo koszerne, Linuxowe ustrojstwa macie zainstalowane na Waszych komputerach, a mianowicie – Unix Sockets.

Czym są „gniazda”?

Jest to – możemy powiedzieć – abstrakcyjny twór, przedstawiający zakończenie połączenia TCP, do którego możemy zapisywać i z którego możemy coś (informację) odczytać.

Dla uogólnienia gniazda możemy traktować jak gniazdko w ścianie, do którego możemy podłączyć urządzenie i pobierać z niego prąd, z tą różnicą, że Unix Sockets działa dwukierunkowo (odczyt i zapis).

Klient, serwer, gniazda i porty

Komunikacja za pomocą gniazd może odbywać się na dwa sposoby:

  • Zwykłe połączenie Klient-Serwer lub Multiklient-Serwer
  • Połączenie broadcast nadające do wszystkich odbiorców w sieci (echo lokalne).

Gniazda posiadają jeszcze jedną własność – port. W trakcie zestawiania komunikacji z sockets definiujemy dodatkowy parametr komunikacji czyli jej port. Użycie portów powoduje separację usług oraz procesów z nich korzystających w taki sposób, by zapewnić w miarę możliwości ich jednoznaczne określenie. Jest to pewien sposób organizacji komunikacji przyjęty na świecie. Wiemy, że przeglądarka Internetowa komunikuje się z serwerem (usługą HTTPD), który nasłuchuje na porcie 80 protokołu TCP. Demon RSyncd standardowo nasłuchuje połączeń na porcie 873 itd. Dodatkowo brnąc w bezpieczeństwo komunikacji możemy wyróżnić porty wysokie i niskie – niskie niedostępne do bindowania przez zwykłych użytkowników oraz często blokowane przez zapory sieciowe. Wysokie porty to porty powyżej numeru 1024, które możemy bindować niezależnie od zalogowanego użytkownika oraz jego uprawnień.

Jak odbywa się komunikacja?

W przypadku połączeń klient-serwer komunikacja odbywa się w następujący sposób:

a) Serwer (usługa) otworzył lokalny port na fizycznym urządzeniu i nasłuchuje połączenia.

b) Klient (najczęściej program użytkownika) łączy się za pomocą protokołu TCP/IP i fizycznym urządzeniem, próbując otworzyć port, na którym nasłuchuje serwer (usługa).

c) Połączenie jest zestawiane (SYN/ACK) przez system operacyjny serwera i rozpoczyna się wymiana danych.

W zależności od konfiguracji usługi dialog może rozpocząć klient lub serwer. Ponieważ oba gniazdo umożliwia komunikację dwukierunkową, zarówno klient jak i serwer mają możliwość pisania i czytania do gniazda zapewniając właśnie pełen dupleks komunikacji.

Przełączenie portów

W niektórych usługach komunikacja została zaplanowana w taki sposób, aby po nawiązaniu połączenia z określonym portem przez klienta, automatycznie przełączyć się na jeden z „wysokich portów” i zwolnić miejsce dla kolejnych klientów. Tego typu rozwiązania sprawiają, że komunikacja staje się o wiele bardziej bezpieczna oraz możliwy jest podział komunikacji na poszczególne „podserwery”, które zapewniają wsparcie odpowiednich elementów składowych całości świadczonej usługi. Przykładem takiej usługi może być np. Samba, która bazuje na kilku portach wymiany danych (osobny dla SMBD, osobny dla NMBD), ale inicjacja komunikacji odbywa się poprzez jeden port.

Porty klientów

W trakcie zestawiania połączenia swój port otrzymuje również klient. Są mu przypisywane porty wysokie, rozpoczynające swoją numerację powyżej portu 10000, często o wiele wyżej. Nie zdziwi Was więc informacja o otwarciu wielu portów wysokich na różnych maszynach wdzwaniających się do Waszych lokalnych gniazd lub w drugą stronę, gdy korzystacie np. z przeglądarki Internetowej, wysyłanie zapytań z portu wysokiego na waszej maszynie.

unix, sockets, tutorial, protokół, gniazda, komunikacja, programowanie, c++

Jak widzicie, połączenia z lokalnych portów wysokich (w moim przypadku powyżej 37 000) odwołują się do kilku serwerów, których usługi nasłuchują na portach 80 (http) i 443 (https).

 

Jak działa aplikacja serwerowa

Aplikacja serwerowa MUSI bazować na najprostszym schemacie:

1. Próbujemy zbindować port lokalny (np. 8080) – zwracany jest nam deskryptor gniazda.2. Ustalamy parametry połączeń klienckich.
3. Nasłuchujemy na połączenia od klientów.
4. Nadchodzi połączenie z klientem – otrzymujemy jego deskryptor.
4. Komunikacja z klientem.
5. Zamknięcie komunikacji poprzez otrzymany deskryptor połączenia klienta.
6. Odłączenie od portu lokalnego (np. 8080) dzięki deskryptorowi gniazda.

Oczywiście proces ten może być rozbudowywany o możliwość ciągłego nasłuchu na porcie, którego zamknięcie odbywa się dopiero w trakcie zamykania aplikacji (destruktor klasy maybe). Użytkownicy mogą również być obsługiwani przez osobne wątki dzięki wykorzystaniu POSIX Threads. Przedstawiona zasada działania jest więc uogólnionym zarysem a nie standardem pracy wszystkich usług w sieci.

Kodujemy

Jak zwykle, korzystając z dobrodziejstw programowania obiektowego w języku C++ możemy przygotować sobie stosowną klasę do obsługi połączeń nadchodzących od klientów.

Konieczne nagłówki, z których będziemy korzystać:

#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

Interfejs klasy

class MatblogServerClass {

private:
// zmienne statyczne
const char* class_name = „MatblogServerClass.h”;

// obsluga komunikatow
const void error_push(const char* error_txt);
const void info_push(const char* info_txt);

// zmienne globalne
char buffer[4096];
short binded_port;
int sock_desc;
int sock_polaczenia;
struct sockaddr_in adres_serwera;
struct sockaddr_in adres_klienta;

public:
// konstruktor
MatblogServerClass();

// destruktor
~MatblogServerClass();

// pozostałe funkcje
bool bindPort(short port);
void listener(char* push_string);
};

W związku z tym, że całą klasę możecie pobrać bezpośrednio na końcu tego tutoriala, nie będę wchodził w szczegóły funkcji obsługi błędów ani znaczenia zmiennej statycznej class_name.

 

Implementacja

Implementację naszej klasy rozpoczynamy od prostego konstruktora.

MatblogServerClass::MatblogServerClass() {
this->binded_port = 0;
bzero(this->buffer, 4096);
}

Konstruktor w trakcie tworzenia nowego obiektu klasy ustawi nam wartość zmiennej „binded_port” obiektu na 0 oraz wyzeruje bufor „buffer” długości 4096 znaków (char).

Destruktor musi trochę posprzątać po naszych działaniach:

MatblogServerClass::~MatblogServerClass() {
if (this->binded_port !=0) {
close(this->sock_desc);
}}

W naszym przypadku w chwili gdy port został otwarty w trakcie działania programu, destruktor ma za zadanie go zamknąć, gdy klasa nie będzie już używana.

 

Tworzenie gniazda i dowiązanie

 MatblogServerClass::bindPort(short port) {

    if (this->binded_port == 0) {
        if (port > 0) {
            this->info_push(„Probuje utworzyc gniazdo!”);

            // tworze desktyptor gniazda
            this->sock_desc = socket(AF_INET, SOCK_STREAM, 0);
            if (this->sock_desc < 0) {                 this->error_push(„Nie moge utworzyc gniazda!”);
                return false;
            }

            bzero((char*) &this->adres_serwera, sizeof(this->adres_serwera));

            this->adres_serwera.sin_family = AF_INET;
            this->adres_serwera.sin_addr.s_addr = INADDR_ANY;
            this->adres_serwera.sin_port = htons(port);

            if (bind(this->sock_desc, (struct sockaddr *) &this->adres_serwera,
                sizeof(this->adres_serwera)) < 0) {                     this->error_push(„Nie moge powiazac gniazda!”);
                    return false;
                } else {
                    this->info_push(„Wszystko OK! Oczekuje na polaczenia!\n”);
                    this->binded_port = port;
                    return true;
                }

        } else {
            this->error_push(„Nieprawidlowy numer portu!”);
            return false;
        }
    } else {
        this->error_push(„Port zostal juz zbindowany”);
        return false;
    }
    return false;
}

Gniazdo tworzymy wykorzystując do tego funkcję SOCKET znajdującą się w pliku nagłówkowym socket.h oraz types.h. Funkcja przyjmuje 3 parametry – pierwszy to tzw. domena komunikacji czyli określenie protokołu, za pomocą którego będziemy się komunikować.

 

Domeny

AF_UNIX, AF_LOCAL – komunikacja lokalnaAF_INET – komunikacja z wykorzystaniem protokołu IP wersji 4
AF_INET6 – komunikacja z wykorzystaniem protokołu IP wersji 6
AF_IPX – komunikacja z wykorzystaniem protokołu IPX firmy Novell
AF_PACKET – niskopoziomowa komunikacja z wykorzystaniem pakietów danych
Inne mniej znaczące – AF_NETLINK, AF_X25, AF_AX25, AF_ATMPVC, AF_APPLETALK

 

Drugi Parametr to typ gniazda, które chcemy utworzyć. Wyróżniamy również kilka typów gniazd.

 

Typy gniazd

SOCK_STREAM – komunikacja dwukierunkowa oparta o strumienie bajtów. Umożliwia odczyt sekwencyjny danych.
SOCK_DGRAM – komunikacja oparta o datagramy, bezpołączeniowa, w których maksymalna długość danych jest z góry określona.
SOCK_SEQPACKET – kombinacja SOCK_DGRAM (datagramów) jednak z wykorzystaniem komunikacji z zestawionymi połączeniami. Użytkownik musi odczytać jednak cały pakiet.SOCK_RAW – Dostęp bezpośredni – w trybie RAW – do protokołu sieciowego. Przydatny w chwili, gdy chcemy stworzyć różnego rodzaju niekoszerne rzeczy :).
SOCK_RDM – Komunikacja z użyciem datagramów jednak bez gwarancji zachowania kolejności.

Musimy pamiętać jednak, że gniazda zależy od użytej domeny (protokołu), bowiem nie każdy protokół implementuje obsługę podobnych gniazd – czy to ze względu na różnice projektowe czy przestarzałość konstrukcji protokołu.

Trzeci parametr funkcji SOCKET to protokół. Zwykle dla kombinacji domena-typ gniazda istnieje jeden protokół  (wartość INT = 0). Taką też wartość przyjmiemy w tym tutorialu.

Funkcja SOCKET zwraca nam wartość INT, która stanowi deskryptor (identyfikator) gniazda. Musimy przechwycić tą wartość aby sprawdzić, czy możliwe było utworzenie gniazda (deskryptor mniejszy od 0 stanowi o błędzie podczas wykonywania funkcji SOCKET) oraz później wykorzystać do komunikacji.

Po otrzymaniu deskryptora utworzonego gniazda musimy się do niego podłączyć. Wcześniej jednak wyzerujemy sobie stan struktury mówiącej o właściwościach połączenia – w moim przypadku jest to struktura adres_serwera. Do zerowania wartości struktury adres_serwera używamy funkcji bzero.

bzero((char*) &this->adres_serwera, sizeof(this->adres_serwera));

Następnie w wyzerowanej strukturze ustawiamy kilka wartości:

this->adres_serwera.sin_family = AF_INET;

Ustawiamy domenę identyczną z tą deklarowaną przy funkcji SOCKET.

this->adres_serwera.sin_addr.s_addr = INADDR_ANY;

Ustalamy, że połączenie będzie nawiązywane na dowolnym adresie IP (0.0.0.0).

this->adres_serwera.sin_port = htons(port);

Ustalamy port, na którym będziemy nasłuchiwać (wartość INT).

Po dokonaniu zmian w strukturze adres_serwera możemy przystąpić do bindowania utworzonego gniazda (podłączania serwera). Do tego celu wykorzystujemy funkcję BIND.

bind(this->sock_desc, (struct sockaddr *) &this->adres_serwera, sizeof(this->adres_serwera));

Funkcja BIND przyjmuje następujące parametry – deskryptor gniazda (int) uzyskany dzięki poleceniu SOCKET, wskaźnik na strukturę adres_serwera, rzutowany na strukturę sockaddr oraz rozmiar struktury adres_serwera.

Funkcja zwraca wartość INT stanowiącą o tym, czy gniazdo zostało powiązane czy nie. W przypadku wartości niższej niż 0 jesteśmy informowani o błędzie próby dowiązania do gniazda.  W przypadku innej wartości otrzymujemy informację o tym, że gniazdo zostało podłączone poprawnie.

Nasłuchiwanie

Po dowiązaniu do gniazda możemy rozpocząć proces nasłuchiwania – oznacza to nic innego niż oczekiwanie na połączenie od klientów, celem jego późniejszego obsłużenia.

Do nasłuchiwania wykorzystujemy funkcję listen, przyjmującą tylko dwa argumenty.

listen(this->sock_desc, 5);

Pierwszy parametr to deskryptor gniazda, drugi to tzw. backlog czyli długość kolejki, jaką przeznaczamy dla naszych klientów. W przypadku gdy kolejka połączeń jest pełna a kolejny klient stara się połączyć z naszą aplikacją poprzez otwarty socket, otrzyma on wartość zwrotną ECONNREFUSED czyli odrzucenie połączenia.

Akceptacja połączenia

Komunikacja poprzez gniazdo odbywa się w momencie nawiązania połączenia. Aby wiedzieć, kiedy takie połączenie nadchodzi musimy użyć funkcji accept, która zwróci nam kolejny deskryptor – tym razem deskryptor połączenia (wartość INT).

this->sock_polaczenia = accept(this->sock_desc, (struct sockaddr *) &this->adres_klienta, &rozmiar_struktury);

Deskryptor połączenia konieczny jest nam aby móc bez problemu czytać i zapisywać do gniazda – podobnie z resztą jak w przypadku pliku.

 

Zapis i odczyt

Standardowo do zapisu i odczytu – jak w przypadku pliku – wykorzystujemy funkcje read i write.

int n = read(this->sock_polaczenia, buffer, 4095);
int m = write(this->sock_polaczenia, push_string, strlen(push_string)+1);

Jak widzicie, użycie obu funkcji jest analogiczne jak w przypadku pliku – mamy deskryptor, bufor, jego wielkość, mamy też string wejściowy przy zapisie.

Zmiennym n oraz m przypisywana jest wartość świadcząca o błędzie lub jego braku. W przypadku gdy zmienna n posiada wartość mniejszą niż 0 mamy do czynienia z błędem odczytu. W przypadku gdy zmienna m posiada wartość mniejszą niż 0 mamy do czynienia z błędem zapisu.

Na początek jednak sprawdźcie, czy otrzymaliście deskryptor połączenia:

        if (this->sock_polaczenia < 0) {
this->error_push(„Nie moge zaakceptowac polaczenia!”);
} else {
this->info_push(„Nowe polaczenie od adresu IP: „);
this->info_push(inet_ntoa(this->adres_klienta.sin_addr));
[…]
}

Oraz wyzerujcie bufor wejściowy korzystając z funkcji bzero:

bzero(this->buffer, 4096);

Posprzątaj po sobie

Na koniec połączenia zamknij jego deskryptor aby zwolnić ten zasób!

close(this->sock_polaczenia);

W destruktorze możesz także zamknąć gniazdo:

close(this->sock_desc);

Podsumowanie

Jak widać, „nie taki diabeł straszny jak go malują”. Unix Sockets to proste rozwiązanie na komunikację w myśl „old good looking Unix style”. W załączonym przykładzie znajdziecie serwer, do którego możecie wbić się przez swoją przeglądarkę via adres: http://localhost:8080

unix, sockets, tutorial, protokół, gniazda, komunikacja, programowanie, c++

Zachęcam Was do eksperymentowania z tym kodem. Jeśli będziecie mieli jakiekolwiek pytania, zapraszam do komentowania pod wpisem.

Zasoby do pobrania

Zip zawierający projekt Code::Blocks oraz plik Makefile do prostej kompilacji znajdziecie tutaj: evmipl_mateuszm-socks-server.zip [45.3 KB]

Pozdrawiam, M.M.