Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autor: 'kubawal'
Nota administracyjna
[DejaVu] Uważam, że tekst wymaga korekty części teoretycznej dot. protokołów TCP i UDP. Zalecałbym również istotnie przeredagować tekst, aby artykuł miał nieco bardziej profesjonalną formę. Niemniej jednak bardzo się cieszę, że ktoś też się stara dzielić wiedzą na temat poznawanych bibliotek w formie artykułów :)
Biblioteki C++

Komunikacja sieciowa TCP i UDP

[lekcja] Materiał poświęcony narzędziom do komunikacji sieciowej TCP i UDP, dostępnym w bibliotece SFML 2.x.

Wprowadzenie

Internet jest dziś wszędzie. Nie byłoby fajnie wykorzystać sieci przy pisaniu własnego programu/gry?
Jest na to prosty sposób: można wykorzystać moduł SFML Network który daje prosty i wygodny dostęp do komunikacji między komputerami.
Na początku przedstawimy trochę teorii na temat działania Internetu(jeśli znasz się na tym możesz pominąć ten dział)

TCP, UDP i IP

Każdy komputer podpięty do sieci otrzymuje swój własny, unikatowy adres IP (Internet Protocol).
Zazwyczaj przedstawia się go w postaci np. "25.750.243.768".

Do komunikacji między komputerami wykorzystuje się najczęściej dwa protokoły: TCP i UDP.
Wszystkie one są bezpośrednio wspierane przez SFML.

Żeby połączyć dwa różne komputery używa się tzw. gniazd (ang. socket).

To porównanie pomoże ci zrozumieć działanie gniazd, połączeń i protokołów:
Wyobraź sobie dwa gniazdka w dwóch programach - to właśnie sa gniazda. Możemy je połączyć kablem - to jest połączenie internetowe.
Te dwa gniazdka mogą mieć różne rodzaje (mogą być takie jak u nas, lub takie jak w Wielkiej Brytanii),
Jednak muszą oba mieć ten sam rodzaj, eby można je było połączyć odpowiednim kablem - właśnie te rodzaje gniazdek to protokoły.

TCP

TCP (Transmission Control Protocol) oparte jest na koncepcji klient-serwer. Najpierw klient łączy się do serwera, po czym można wymieniać między nimi dane. Jest jeszcze jedno pojęcie związane z tym protokołem: jeśli serwer chce połączyć się z klientem, to nasłuchuje (ang. listen) na swoim porcie, czy jakiś komputer nie chce się z nim komunikować.

UDP

UDP (User Datagram Protocol) zasadniczo rożni się od TCP tym, że gniazdo TCP klienta łączy się z gniazdem serwera, a w UDP komputer po prostu wysyła dane do innego komputera bez nawiązania polączenia.
UDP jest zasadniczo szybsze od TCP, lecz niekiedy "gubi" dane, więc stosuje się go w transmisjach czasu rzeczywistego (konferencje wideo, itp.).
Jescze jedna funkcja UDP to możliwość wysyłania do kilku komputerów naraz.

Połączenie TCP

Tworzenie gniazda klienta

Adres serwera, do którego się chcemy połączyć możemy przedstawić jako klasę sf::IpAddress która w konstruktorze przyjmuje
adres w postaci np. "10.204.87.74"
Do tworzenia gniazda klienta TCP używamy natomiast klasy sf::TcpSocket.

C/C++
// klient

sf::TcpSocket socket; // tworzymy gniazdo klienta
sf::IpAddress ip = "adres.ip.serwera.do.ktorego.sie.chcesz.polaczyc";
unsigned int port = 54000 // port na którym nasłuchuje serwer

if( socket.connect( ip, port ) != sf::Socket::Done ) // łączymy się z adresem 'ip' na porcie 'port'
// jeśli funkcja connect zwróci sf::Socket::Done oznacza to, że wszystko poszło dobrze
{
   
cerr << "Nie można połączyć się z " << ip.toString() << endl;
   
exit( 1 );
}

Pobieranie adresu lokalnego/zdalnego

Adres lokalny (ang. local address) to adres naszego komputera.
Adres zdalny (ang. remote address) to adres komputera, do którego jesteśmy podłączeni.
Gniazda TCP umożliwiają odczytanie obydwu tych adresów.
getLocalAddress() zwraca lokalne IP.
getLocalPort() zwraca lokalny port.
getRemoteAddress() zwraca zdalne IP.
getRemotePort() zwraca zdalny port.

Tworzenie gniazda serwera TCP

Do stworzenia gniazda serwera TCP potrzebna jest klasa sf::TcpListener.

C/C++
// serwer

sf::TcpListener listener; // tworzymy gniazdo nasłuchujace
unsigned int port = 54000; // port, na którym będziemy nasłuchiwać

if( listener.listen( port ) != sf::Socket::Done ) // rozpoczynamy nasłuchiwanie na porcie 'port'
{
   
cerr << "Nie mogę rozpocząć nasłuchiwania na porcie " << port << endl;
   
exit( 1 );
}

//...

Następnie używamy metody accept(), która oczekuje na klienta chętnego do nawiązania połączenia.
Kiedy takowy się znajdzie, inicjuje ona gniazdo podane przez referencję w argumencie, przez które możemy się z nim komunikować

C/C++
// serwer

while( /*...*/ )
{
   
sf::TcpSocket client; // tworzymy gniazdo, dzięki któremu będziemy mogli się komunikować z klientem
   
listener.accept( client );
   
   
// wysyłanie/odbieranie danych od/do klienta
}

Komunikacja między połączonymi gniazdami TCP

No dobra, mamy gotowe, połączone, gniazda klienta i serwera. Teraz można już się komunikować.

C/C++
// sposób działa dla klienta i dla serwera

const int datasize = 100; // rozmiar bloku danych
char data[ 100 ] = "..."; // tworzymy blok danych

//...

// wysyłanie danych
if( socket.send( data, datasize ) != sf::Socket::Done ) // i wysyłamy...
{
   
// nie można wysłać danych (prawdopodobnie klient/serwer się rozłączył)
   
cerr << "Nie można wysłać danych!\n";
   
exit( 1 );
}

//...

// odbieranie danych
unsigned int received; // do tej zmiennej zostanie zapisana ilość odebranych danych
if( socket.receive( data, datasize, received ) != sf::Socket::Done ) // i wysyłamy...
{
   
// nie można odebrać danych (prawdopodobnie klient/serwer się rozłączył)
   
cerr << "Nie można odebrać danych!\n";
   
exit( 1 );
}
else
   
 cout << "Odebrano " << received << " bajtów\n";

Uwaga:
Wszystkie funkcje odbierania danych, oraz funkcja accept()blokujące.
Oznacza to mniej więcej, że funkcja taka będzie czekać na dane do odebrania(w przypadku receive()) lub klienta(accept())

Zamykanie połączenia TCP

Żeby zamknąć połączenie wystarczy wywołać metodę close()

C/C++
// działa dla serwera i dla klienta

socket.close();

Komunikacja za pomocą UDP

W UDP jest prościej, niż w TCP. Nie trzeba tworzyć klienta i serwera, akceptować połączeń, nasłuchiwać itd.
Taki jest mniej więcej schemat działania:
  • 1. Stworzyć gniazdo (klasa sf::UdpSocket);
  • 2. Jeśli chcemy także odbierać, trzeba określić(za pomocą metody bind()), na jaki port mają nam wysyłać;
  • 3. Wysyłać/odbierać dane(za pomocą metod send() i receive()

C/C++
sf::UdpSocket sock; // tworzymy gniazdo

const int datasize = 100; // rozmiar danych do wysłania/odebrania
char data[ datasize ] = "..."; // dane

// wysyłanie
sf::IpAddress ip( "adres.komputera.do.którego.chcesz.wysłać.dane" );
unsigned int port = 56000; // port, na który chcesz wysłac dane
if( sock.send( data, datasize, ip, port ) != sf::Socket::Done )
{
   
cerr << "Nie można wysłać danych!\n";
   
exit( 1 );
}

// odbieranie
sf::IpAddress ip( "adres.komputera.od.którego.chcesz.odebrać.dane" );
unsigned int port = 56000; // port, na którym chcesz odbierać dane
unsigned int senderport = 54000; // port, z którego zostanły wysłane dane
unsigned int received;
sock.bind( port );
if( sock.receive( data, datasize, received, ip, senderport ) != sf::Socket::Done )
{
   
cerr << "Nie można odebrać danych!\n";
   
exit( 1 );
}
cout << "Odebrano bajtów: " << received << endl;

Problem blokujących gniazd


Wyobraź sobie, że piszesz serwer, który obsługuje naraz wiele klientów. Nie możesz używać metody accept(). Dlaczego? Spójrzmy na przykład:
C/C++
// źle

sf::TcpListener server; // gniazdo nasłuchujące
vector < sf::TcpSocket * > clients; // tutaj przechowujemy klientów
//...
while( true )
// główna pętla serwera
{
   
TcpSocket * tmp = new sf::TcpSocket;
   
server.accept( * tmp ); // akceptujemy nowego klienta
   
clients.push_back( tmp ); // i dodajemy go do kontenera
   
    // tutaj obsługujemy klientów
    //...
}
Uwaga!:
Zauważ, że gniazda przechowujemy w kontenerze wskaźników do nich (vector<TcpSocket*>). Powodem tego jest to, że przypisywanie gniazd do siebie jest zabronione (zadeklarowane jako prywatna składowa klasy sf::TcpSocket). Dlatego gniazda, które będą przechowywane w kontenerach należy tworzyć w pamięci wolnej
Przeanalizujmy ten kod: W głównej pętli tworzymy sobie gniazdo i akceptujemy. Czekaj! Przecież metoda accept() oczekuje na klienta!
A co, jeśli nikt nie będzie chciał się z nami połączyć? accept() będzie czekało i blokowało program!
Co będzie z podłączonymi już klientami, które czekają, aż je obsłużymy?
Taki sam problem występowałby, gdybyśmy, podczas obsługiwania klienta wywołali receive(), a klient nic by nam nie wysłał - receive() czekało by na dane do odebrania
Musi być jakiś sposób, żeby wiedzieć, że ktoś się chce do nas podłączyć lub nam coś wysłać...
Owszem, SFML posiada klasę sf::SocketSelector, która informuje nas, czy któreś z obserwowanych gniazd chce nam coś wysłać. Gniazda dodajemy za pomocą metody add()
C/C++
// dobrze

sf::TcpListener server; // gniazdo nasłuchujące
vector < sf::TcpSocket * > clients; // tutaj przechowujemy klientów

sf::SocketSelector sel; // selektor
sel.add( server ); // dodajemy gniazdo nasłuchujące
//...
while( true )
// pętla główna serwera
{
   
if( sel.wait( sf::seconds( 2 ) ) // jeśli metoda wait() zwróci true, to znaczy, że któreś z dodanych gniazd jest gotowe do odbioru
    // jako argument podajemy czas, przez który ma czekać na dane
   
{
       
if( sel.isReady( server ) ) // metoda isReady() sprawdza, czy dane gniazdo ma dane do odebrania
        // jeśli do motedy isReady() przekażemy gniazdo nasłuchujące, true oznacza, że ktoś chce się do niego podłaczyć
       
{
           
TcpSocket * tmp = new sf::TcpSocket;
           
server.accept( * tmp ); // skoro ktoś chce się do nas połączyć, to go akceptujemy
           
clients.push_back( tmp ); // i dodajemy go do listy
           
sel.add( * tmp ); // oraz do selektora, żeby można było od niego odbierać dane
            // nie zapomnij, by usunąć(za pomocą delete) gniazdo, kiedy się rozłączy
       
}
       
       
// pętla przechodząca po kontenerze gniazd (zależy od typu kontenera)
       
for( int i = 0; i < clients.size(); i++ ) // u nas to jest for i indeks i
       
{
           
if( sel.isReady( * clients[ i ] ) ) // *clients[i] coś nam wysłał
           
{
               
const int datasize = 100; // rozmiar danych do odebrania
               
char data[ datasize ]; // dane
               
unsigned int received; // odebrane
               
clients[ i ]->receive( data, datasize, received );
               
cout << "Odebrano " << received << " bajtów od " << clients[ i ]->getRemoteAddress() << endl;
               
// tutaj robimy coś z odebranymi dany
                //...
           
}
        }
       
//...
        // reszta kodu serwera
   
}
   

Pakiety

Z TCP (i UDP) jest pewien problem: jeśli wyślesz np. 1kB danych to dotrą one (prawdopodobnie) podzielone na klka części. Serwer odbierze pierwszą serię danych, a później nie będzie wiedział, czy ma oczekiwać na następną, czy to już wszystko.
Drugi problem to różnice między komputerami: kiedy wysyłasz dane do jakiejś maszyny, nie wiesz, czy ma ona np. ten sam rozmiar typu int, albo czy używa ona konwencji little-endian (bajty "bardziej znaczące" są na początku), czy big-endian (na końcu).
Obydwa te problemy można rozwiązać za pomocą klasy sf::Packet oraz kilku typów o stałych rozmiarach.

sf::Packet

Ta klasa implementuje pakiet - serię danych dodatkowo znającą swój rozmiar.

Jak odbierać dane za pomocą pakietów:
1. Tworzymy zmienną typu sf::Packet
2. Odbieramy dane za pomocą metody receive()
3. Wyciągamy dane z pakietu jak ze strumienia istream z biblioteki standardowej
C/C++
sf::Packet pak; // 1
someSocket.receive( pak ); // 2

float x;
int y;
string z;
pak >> x >> y >> z; // 3
Powinno się sprawdzać, czy odczyt danych powiódł się w taki sam sposób, jak robi się to ze standardowymi strumieniami:
C/C++
if( pak >> x >> y >> z )
// odczyt sie powiódł
else
// coś nie zadziałało
   
Pamiętaj, by odczytywać zmienne w takiej samej kolejności, w jakiej się je wysłało

Jak wysyłać dane z pomocą pakietów:
1. Tworzymy zmienną typu sf::Packet
2. Zapisujemy dane do pakietu jak do strumienia ostream z biblioteki standardowej
3. Wysyłamy dane za pomocą metody send()
C/C++
sf::Packet pak; // 1

float x = 3.14f;
int y = 64;
string z = "hello!";
pak << x << y << z; // 2
someSocket.send( pak ); // 3

Typy o stałych rozmiarach

Jeśli nie jesteś pewny, że klient i serwer działają na takich samych maszynach, pownieneś wysyłać zmienne o jasno określonej wielkości: ('ze znakiem' oznacza, ze liczba może być ujemna)
  • Int8 - liczba ze znakiem, 8 bitów
  • Uint8 - liczba bez znaku, 8 bitów
  • Int16 - liczba ze znakiem, 16 bitów
  • Uint16 - liczba bez znaku, 16 bitów
  • Int32 - liczba ze znakiem, 32 bitów
  • Uint32 - liczba bez znaku, 32 bitów
  • Int64 - liczba ze znakiem, 64 bitów
  • Uint64 - liczba bez znaku, 64 bitów