Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autor: 'Złośliwiec'
Biblioteki C++

Winsock

[lekcja]
Jeśli kiedykolwiek zdarzyło ci się kiedyś, że przez cały dzień nie miałeś dostępu do internetu, to pewnie wiesz, że sieć jest dziś niezbędna jak powietrze ;-). Z tego powodu warto byłoby opanować również programowanie w sieci. Windows posiada swój interfejs do tego celu – to właśnie Winsock. Jest on oparty na koncepcji tzw. gniazd (ang. socket). Dzięki Winsock możemy tworzyć programy korzystające z architektury klient-serwer, na przykład po to, by stworzyć tryb multiplayer do naszej gry.

Klient i serwer

Żeby nie przedłużać tego wstępu do granic cierpliwości użytkowników, zaczynamy od stworzenia dwóch nowych projektów. Dwóch, ponieważ serwer i klient to, jak by nie patrzeć, dwie osobne aplikacje. We właściwościach każdego z nich musimy podać linkerowi plik WS2_32.lib. Poza tym w każdym z tych projektów będziemy potrzebowali pliku nagłówkowego winsock2.h oraz kilku innych nagłówków. Przyda się też, oczywiście, funkcja main:
C/C++
#include <cstdio>
#include <cstdlib>

#include <winsock2.h>

int main()
{
}

Na początku musimy zainicjować Winsock, co robi się tak:
C/C++
WSADATA wsaData;

int result = WSAStartup( MAKEWORD( 2, 2 ), & wsaData );
if( result != NO_ERROR )
     printf( "Initialization error.\n" );

Struktura WSADATA mało nas obchodzi. MAKEWORD(2, 2) oznacza numer wersji Winsock, jakiej chcemy używać czyli 2.2. Jeśli inicjalizacja się nie powiodła, wypisujemy odpowiedni komunikat.

Następnym krokiem jest utworzenie gniazda. Jest to w Winsock podstawowy obiekt służący do komunikacji (stąd też nazwa tego API). Gniazda tworzy się przy pomocy funkcji socket. Nazwy niektórych funkcji w Winsock, jak widać, zaczynają się od małych liter, co jest dość niezwykłe dla WinAPI. Powód jest prosty: chodziło o to, by interfejs był maksymalnie kompatybilny z popularnym standardem de facto stworzonym przez firmę Berkeley, dzięki czemu portowanie aplikacji sieciowych mogło być łatwiejsze. Winsock zawiera też inne funkcje, których nie ma w ww. standardzie, jak na przykład WSAStartup (użyta przez nas przed chwilą). Wróćmy do naszych gniazdek:
C/C++
SOCKET mainSocket = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
if( mainSocket == INVALID_SOCKET )
{
    printf( "Error creating socket: %ld\n", WSAGetLastError() );
    WSACleanup();
    return 1;
}
Funkcja socket ma kilka parametrów, w znaczenie których nie będziemy się na razie wgłębiać. Najważniejsza jest dla nas wartość IPPROTO_TCP, oznaczająca, że będziemy korzystali z protokołu TCP.

Powyżej mamy też przykład użycia dwóch "niestandardowych" funkcji Winsock: WSAGetLastError
i WSACleanup. Jak widzimy, łatwo się one wyróżniają, gdyż ich nazwy zaczynają się od prefiksu WSA (Winsock API). Nietrudno się domyślić, co robią: WSAGetLastError zwraca kod ostatniego błędu, a WSACleanup sprząta po użyciu Winsocka przed wyjściem z programu.

Jeśli wszystko poszło jak należy, czyli dysponujemy prawidłowym gniazdkiem, możemy teraz pójść o krok dalej i wypełnić strukturkę sockaddr_in, reprezentującą adres IP. Oprócz właściwego adresu IPv4, składającego się, jak wiadomo, z czterech liczb rozdzielonych kropkami, w strukturze musimy też podać numer portu. Można bowiem uruchomić na jednej maszynie kilka serwerów, a klient musi połączyć się do tego właściwego. W takiej sytuacji każdy z serwerów rezerwuje sobie inny port, a klient łącząc się z serwerem podaje numer portu przypisany do interesującego go serwera:
C/C++
sockaddr_in service;
memset( & service, 0, sizeof( service ) );
service.sin_family = AF_INET;
service.sin_addr.s_addr = inet_addr( "127.0.0.1" );
service.sin_port = htons( 27015 );
Jeśli zarówno serwer, jak i klient działają na tym samym komputerze, to podajemy adres lokalnego hosta (127.0.0.1). Numer portu jest właściwie dowolny, przy czym oczywiście klient musi mieć podany ten sam. Możemy równie dobrze uruchomić klienta na jednym komputerze, a serwer na drugim i podać tutaj "prawdziwy" adres IP serwera (port może zostać ten sam).

W tym miejscu drogi klienta i serwera się rozchodzą. Serwer powinien teraz wykonać tak zwane bindowanie stworzonego gniazda (przypisanie go do adresu), a potem zacząć nasłuchiwać na wybranym porcie. Klient natomiast powinien nawiązać połączenie z serwerem. Zacznijmy od dokończenia kodu na serwerze:
C/C++
if( bind( mainSocket,( SOCKADDR * ) & service, sizeof( service ) ) == SOCKET_ERROR )
{
    printf( "bind() failed.\n" );
    closesocket( mainSocket );
    return 1;
}
Tak wygląda wspomniane bindowanie. Po tej operacji, o ile się powiodła, do gniazda mainSocket przypisany jest adres 127.0.0.1 i port 27015, a serwer gotowy jest do nasłuchiwania, czym zajmuje się funkcja listen:
C/C++
if( listen( mainSocket, 1 ) == SOCKET_ERROR )
     printf( "Error listening on socket.\n" );

Serwer jest teraz gotowy na przyjęcie próby połączenia od klienta. Polega to na wołaniu w pętli funkcji accept; ta zaś zwróci wskaźnik na gniazdo, które będzie służyło do komunikacji z danym klientem albo wartość SOCKET_ERROR. Ta ostatnia wartość niekoniecznie musi oznaczać, że coś jest nie tak – zostanie zwrócone również w przypadku, gdy klient w ogóle nie próbuje się połączyć (bo np. jeszcze nie został uruchomiony):
C/C++
SOCKET acceptSocket = SOCKET_ERROR;
printf( "Waiting for a client to connect...\n" );

while( acceptSocket == SOCKET_ERROR )
{
    acceptSocket = accept( mainSocket, NULL, NULL );
}

printf( "Client connected.\n" );
mainSocket = acceptSocket;
Jeśli połączenie zostało nawiązane, zastępujemy uchwyt naszego gniazda tym otrzymanym z accept – tamto było potrzebne tylko do połączenia się z klientem. To na razie wszystko, jeśli chodzi o serwer; po udanym połączeniu się program kończy swe działanie. Teraz pora na klienta.

Aby klient połączył się z serwerem pod wybranym wcześniej adresem, musimy wywołać funkcję connect. Podajemy jej gniazdo oraz strukturę sockaddr_in z właściwym adresem serwera:
C/C++
if( connect( mainSocket,( SOCKADDR * ) & service, sizeof( service ) ) == SOCKET_ERROR )
{
    printf( "Failed to connect.\n" );
    WSACleanup();
    return 1;
}
Na razie nasz program nie robi nic specjalnie ciekawego, ale możemy przynajmniej zaobserwować nawiązanie połączenia. W tym celu musimy najpierw uruchomić serwer, a gdy zobaczymy napis "Waiting for a client to connect", uruchomić klienta. Po chwili powinniśmy na serwerze zobaczyć komunikat "Client connected.", po czym oba programy zakończą działanie.

Wysyłanie i odbieranie danych

Spróbujemy teraz wysłać coś z klienta na serwer oraz odwrotnie. Do wysyłania służy funkcja send, a do odbierania – recv. Obie te funkcje blokują bieżący wątek do momentu, aż zakończą swoje zadania, co umożliwia nam zsynchronizowanie klienta i serwera. Klient wyśle sobie taki oto napis:
C/C++
int bytesSent;
int bytesRecv = SOCKET_ERROR;
char sendbuf[ 32 ] = "Client says hello!";
char recvbuf[ 32 ] = "";

bytesSent = send( mainSocket, sendbuf, strlen( sendbuf ), 0 );
printf( "Bytes sent: %ld\n", bytesSent );

while( bytesRecv == SOCKET_ERROR )
{
    bytesRecv = recv( mainSocket, recvbuf, 32, 0 );
   
    if( bytesRecv == 0 || bytesRecv == WSAECONNRESET )
    {
        printf( "Connection closed.\n" );
        break;
    }
   
    if( bytesRecv < 0 )
         return 1;
   
    printf( "Bytes received: %ld\n", bytesRecv );
    printf( "Received text: %s\n", recvbuf );
}

system( "pause" );
Jak widać, klient najpierw wysyła swój napis, a później w pętli czeka na ewentualną odpowiedź serwera i natychmiast wypisuje tę odpowiedź na ekran, gdy ją dostanie. Serwer robi coś podobnego ze swoim napisem:
C/C++
int bytesSent;
int bytesRecv = SOCKET_ERROR;
char sendbuf[ 32 ] = "Server says hello!";
char recvbuf[ 32 ] = "";

bytesRecv = recv( mainSocket, recvbuf, 32, 0 );
printf( "Bytes received: %ld\n", bytesRecv );
printf( "Received text: %s\n", recvbuf );

bytesSent = send( mainSocket, sendbuf, strlen( sendbuf ), 0 );
printf( "Bytes sent: %ld\n", bytesSent );

system( "pause" );

W obu przypadkach zakładamy, że otrzymane dane to stringi zakończone znakiem zerowym – w przypadku "prawdziwej" aplikacji oczywiście nie możemy tak robić, bo hakerzy dobiorą nam się do skóry :-).

Oczywiście nasza aplikacja jest bardzo, bardzo prosta i mało przydatna. W praktyce wymiana danych między klientem a serwerem trwa raczej znacznie dłużej i serwer nie zakłada, że po iluś tam komunikatach może sobie zrobić fajrant, tylko w nieskończonej pętli czeka na dalsze komunikaty. Poza tym często do serwera łączy się więcej klientów. Klienci z kolei mogą chcieć wysyłać i odbierać dane równocześnie (bo nigdy nie wiadomo, kiedy serwer postanowi sobie coś wysłać). To nas prowadzi do wielowątkowości i konieczności wprowadzenia dodatkowych mechanizmów synchronizujących, a to z kolei powoduje, że aplikacja zaczyna działać coraz wolniej... Dochodzą też takie kwestie, jak bezpieczeństwo czy obsługa większej ilości danych. Tak więc ten artykuł wyczerpuje tak naprawdę tylko bardzo niewielką część tematu. Z pewnością jednak kiedyś jeszcze do niego wrócimy.
Poprzedni dokument Następny dokument
Usługi Kontrolki