Síťová komunikace

V některých úlohách můžete chtít vytvořit komunikaci mezi více klienty na různých počítačích. To se dá implementovat několika způsoby. Nejčastější ovšem je vytvořit síťovou komunikaci, která se může zdát komplikovaná, ale není tomu tak. Běžnou implementací síťové komunikace je tzv. klient-server. Kdy existuje server, ke kterému se připojují klienti, a který řídí běh aplikace. Klienti v tomto případě slouží pouze jako jakýsi terminál.

Motivace

Síťová komunikace je jako čtení a zápis do souborů. Představme si, že implementujeme klienta. Nechť má k dispozici 2 soubory klient.in (soubor, který funguje jako standardní vstup, akorát místo klávesnice je plněn ze serveru) a klient.out (soubor, který funguje jako standardní výstup, akorát místo obrazovky je čten serverem). No a co když chceme přečíst něco ze serveru? No to je jednoduché použijeme funkci fread na soubor klient.in. No a pokud chceme zapsat použijeme fwrite.

Jaké jsou problémy? No, když zavoláme fscanf (resp. fscanf) a uživatel (resp. server) ještě nic na standardní vstup (resp. soubor klient.in) nenapsal, pak na tomto volání čekáme na vstup. To se dá zařídit pomocí neblokujícího čtení nebo prostě omezením. Prostě program nemůže pracovat dál dokud uživatel (resp. server) nic nezadá (resp. nepošle).

Takže vlastně komunikace po síti je jako komunikace na standardní vstupu a výstupu? Ve skutečnosti je to složitější (např. uživatel může zaslat část zprávy a druhou poslat až za chvíli), ale řešit problémy, které mohou nastat není cílem tohoto předmětu.

Z hlediska serveru je komunikace úplně stejná, akorát je nutné si uvědomit, že máme k dispozici více „standardních vstupů“ a „standardních výstupů“.

Soubory na síti?!

V části výše jsme předpokládali, že existují nějaké sdílené soubory, pomocí kterých náš klient a server komunikují. To není zcela přesné. K tomu aby se mohli klient a server spojit je potřeba několik věcí. V následujícím textu budu používat slovo „socket“ bez bližšího vysvětlení, pro účely PA2 stačí chápat „socket“ jako věc podobnou file descriptoru a vysokou úroveň abstrakce, která nemusí odpovídat realitě.

Pokud se má klient připojit k serveru je nutné, aby server běžel. Server tedy musí běžet a musí tzv. poslouchat. Představit si to můžeme jako když pracujeme na úřadě. Čekáme až přijde klient a vezme si papírek. Server je identifikován poté svojí IP adresou (něco jako adresa úřadu, podobně i klient má svoji IP adresu) a portem (číslo našeho okénka, každé okénko vyřizuje jiné věci). No a my čekáme dokud nepřijde klient. Takže my budeme poslouchat, zda náhodou neklepe. My se můžeme dokonce rozhodnout poslouchat klepání jen určitých klientů, jen klientů z určité IP adresy. Ale obecně nám to může být jedno, tudíž budeme poslouchat na IP adrese 0.0.0.0 což je adresa symbolizující „všechny“ možné IP adresy. Výsledkem takového procesu bude právě nějaký ten „socket“, kam se při příchodu klienta zapíše ono „zaťukání“ a server si jej může přečíst.

Máme server, který poslouchá. No a co s klientem? Klient musí znát IP adresu serveru (když jdu na úřad, tak také znám jeho adresu) a port (číslo okénka, ke kterému chci jít, každé totiž vyřizuje jinou záležitost). A jediné co, tak musíme zaklepat. Po zaklepání počkáme na slovo „dále“ a pokud vstoupíme, tak už se bavíme s úředníkem, dle zadaných pravidel (žijeme v silně byrokratickém státu). Většinou se budeme chtít připojovat na ten stejný počítač, ten má vždy adresu 127.0.0.1, což je něco jako klíčové slovo this u objektů.

Samozřejmě se může stát, že bude chtít více klientů přistupovat ke stejnému okénku, ale pokud nebudeme používat vlákna, tak ostatní musí čekat. Což nemusí být vždy problém.

Implementace

Společná část

Server i klient „musí“ provést na začátku několik potřebných věcí. První věc je převést IP adresu a port (kde server poslouchá a kam klient připojuje) do struktury addrinfo. To se dá udělat následujícím kódem:

 struct addrinfo * ai;

if ( getaddrinfo( adresa, port, NULL, &ai ) != 0 ) {
    // Nastala chyba! Ohlas chybu uživateli a ukonči program.
}

// Struktura addrinfo byla úspěšně vytvořena, pokračuj dál.
// A nezapomeň ve chvíli, kdy ji nepotřebuješ, uvolnit paměť.

freeaddrinfo( ai ); 

Následně je potřeba vytvořit „socket“, jak jsem psal výše, chápejme ho jako obdobu file descriptorů, protože ho budeme využívat podobným způsobem.

 int sock = socket( ai->ai_family, SOCK_STREAM, 0 );
if ( sock == –1 ) {
    freeaddrinfo( ai );
    // Nastala chyba, uvolníme strukturu addrinfo.
    // Vypíšeme uživateli chybu a ukončíme program.
}

// Jakmile budeme chtít ukončit poslouchání či připojení k serveru,
// musíme socket uzavřít pomocí close(sock); jedná se obdobu k fclose(fd); 

Serverová část

Nyní je potřeba provést dvě věci, tedy nabindování socketu a nastavení poslouchání. Nabindování si můžeme představit jako vystavení čísla našeho okénka nad dveře.

 if ( bind( sock, ai->ai_addr, ai->ai_addrlen ) != 0 ) {
    // Nastala chyba, uvolni addrinfo, uzavri socket, ukonci program.
}

if ( listen( sock, MAX_CEKAJICIC­H_KLIENTU ) != 0 ) {
    // Nastala chyba, uvolni addrinfo, uzavri socket, ukonci program.
}

// Tady uz je SERVER ready a může přijímat klienty. Všimněte si konstanty MAX_CEKAJICIC­H_KLIENTU, která říká
// jak dlouhá fronta se bude tvořit u našeho okénka, pokud právě s někým komunikujeme.
// Další klienti již budou znechuceně odcházet. 

Nyní server má „poslouchací socket“ a může tedy přijímat klienty. To udělá funkcí accept, která v parametru bere „poslouchací socket“ (+ pomocnou strukturu a její délku) a vrací „socket“ určený pro komunikaci s klientem.

 struct sockaddr addr;
socklen_t addrLen = sizeof( addr );

int clientSock = accept ( sock, &addr, &addrLen ); 

Klientská část

Klient to má mnohem jednodušší, on prostě přijde a zaťuká funkcí connect, která v parametrech bere „socket“ a strukturu s adresou kam se má připojit.

 if ( connect( sock, ai->ai_addr, ai->ai_addrlen ) != 0 ) {
    // Nastala chyba, uvolnim addrinfo, uzavru socket, ukončím aplikaci.
}

// Komunikuji se serverem.
// Po skončení zavolám close(sock) 

Komunikace

Nyní máme „komunikační socket“ jak pro klienta (zavoláním connect), tak pro server (zavoláním accept). A nyní nás už čeká jen komunikace pomocí funkcí recv (pro čtení) a send (pro zápis).

Čtení

Funkce recv bere 4 parametry:

  1. „komunikační“ socket (u serveru clientSock, u klienta jen sock)
  2. buffer (místo kam se mají nahrát data)
  3. bufferLen (kolik dat se do tohoto místa vejde, je dobré mít nastavený buffer na větší velikost, než je délka zasílané zprávy)
  4. flagy (zde se dá nastavit neblokující čtení)

Návratovou hodnotou je počet přijatých bytů, v případě chyby je vráceno číslo –1, číslo 0 pokud se klient řádně odpojil (zavoláním close).

 char buffer [ MAX_VELIKOST_BUF­FERU ];
ssize_t length = recv( clientSock, buffer, sizeof( buffer ) – 1, 0 );

if ( length == –1 ) {
    // Nastala chyba
} else if ( length == 0 ) {
    // Klient se odpojil
} else {
    // Zpracujeme zprávu

Zápis

Analogicky funkce send bere 4 „stejné“ parametry, tedy socket, buffer odkud se berou data, počet zaslaných bytů (teď již to není velikost bufferu, ale velikost zprávy) a flagy. Návratovou hodnotou je počet odeslaných bytů a –1 při chybě.

 char buffer [ MAX_VELIKOST_BUF­FERU ];
send( clientSock, buffer, strlen( buffer ), 0 );
// Všimněte si, že používáme STRLEN a ne SIZEOF,
// protože posíláme jen zprávu, ne celý buffer. 

Ukončení spojení?

A jak ukončit spojení? No zavoláním funkce close.

 close(sock); 

Komunikace

No jo umím komunikovat s jedním klientem, ale co když jich je víc? V praxi existuje několik modelů komunikace. Můžete pro každého klienta vytvořit vlákno, které jej bude obsluhovat. Nebo můžete využít funkci select (viz. manuálové stránky). Případně pokud potřebujete komunikovat jen mezi dvěma klienty, pak jeden klient může vystupovat jako server a druhý jen jako klient. Ale obecně vám zde ukážu 2 přístupy komunikaci mezi více klienty.

Uspořádaná komunikace

Server jakožto hlavní vedoucí čeká na klienty a to tím způsobem, že jakmile přijme prvního klienta, uloží si jeho „komunikační socket“ do pole a čeká na další klienty (případně může klientovi poslat zprávu, že čeká na další klienty), pro které provede to stejné. Jakmile přijme dostatečný počet klientů začne komunikace.

Komunikace probíhá tak, že server pravidelně prochází všechny klienty v pevně daném pořadí. A je s nimi domluven na principu komunikace (to určuje samozřejmě programátor). Tohle je typické pro tahové hry. Ukážeme si to např. na člověče nezlob se. Předpokládáme, že server běží, ale neběží hra.

Log komunikace

Hráči jsou očíslováni 0, 1, 2 a 3. Šipka [i]-> ukazuje, že hráč i něco posílá na server, šipka <-[i] ukazuje, že server posílá něco hráči i.
 [0]→ Ťuk ťuk (klient volá connect, server volá accept)
// Spojení navázáno
[0]→ POCET HRACU 3 (klient volá send, server volá recv)
// Tím zahájí hru

[0]→ JMENO Adam (klient volá send, server volá recv)
← [0] POCET HRACU 1/3
// Server ví, že má čekat ještě na 2 hráče, do té doby se s nikým nebude bavit.
// A uloží si informaci, že hráč 0 se jmenuje Adam.
// Klient volá recv, aby zjistil informace o připojených hráčích

[1]→ Ťuk ťuk (klient volá connect, server volá acccept)
[1]→ JMENO Barbora
← [1] POCET HRACU 2/3
← [1] 0 JMENO Adam
← [0] 1 JMENO Barbora
// Server dá všem vědět co se děje, že se připojila Barbora,
// a Barboře řekne stav, který na serveru je

[2]→ Ťuk ťuk
[2]→ JMENO Cecil
← [2] POCET HRACU 3/3
← [2] 0 JMENO Adam
← [2] 1 JMENO Barbora
← [0] 2 JMENO Cecil
← [1] 2 JMENO Cecil
// Vsichni hraci jsou pripojeni a muze zacit hra. 

Server

 vytvorenaHra ←  false
pocetHracu ← 0
hraci ← [], delka ← 0

while ( !vytvorenaHra || delka < pocetHracu ) {
    sock ← accept()
    if ( !vytvorenaHra ) {
        pocetHracu ← recv()
    }

    hraci[ delka ] = { socket = sock, jmeno = recv() }
    send( hraci[ delka ].socket, „POCET HRACU " + ( delka + 1 ) + "/“ + pocetHracu )

    for ( i ← 0; i < delka; i ← i + 1 ) {
        send( hraci[ delka ].socket, i + " JMENO " + hraci[ i ].jmeno )
        send( hraci[ i ].socket, i + " JMENO " + hraci[ delka ].jmeno )
    }

    ++delka

Klient

 protihraci ←  []

if ( prvni ) {
    send( "POCET HRACU " + pocetHracu )
}

send( "JMENO " + jmeno )
mojePoradi, pocetHracu ← recv()
for ( i ← 0; i < mojePoradi – 1; i ← i + 1 ) {
    id, jmeno ← recv()
    protihraci[ id ] ← jmeno
}

// Cekam na pripojeni ostatnich

for ( i ← mojePoradi; i < pocetHracu; i ← i + 1 ) {
    id, jmeno ← recv()
    protihraci[ id ] ← jmeno

Samotná hra

Samotná hra, pak probíhá v podobném duchu. Od serveru přijde např. informace HRAJE HRAC 1, pro ostatní to znamená aby zobrazili informaci, že hraje Barbora, pro Barboru, že ji klient nabídne hodit kostkou (to může být na straně serveru nebo na klientovi, pokud bychom chtěli udělat umělou inteligenci, která švidluje s kostkou) a táhnout figurkou. Barbora odešle svůj tah, a server vyhodnocení tahu odešle ostatním (příp. by jej mohl Barboře vrátit, že je to chybný tah).

 [0]→ HRAJE HRAC 1
[1]→ HRAJE HRAC 1
[2]→ HRAJE HRAC 1

← [1] HOD 6
← [1] TAH FIGURKOU 3
[1]→ FAIL „Tah touto figurkou neni mozny“

← [1] TAH FIGURKOU 2
[0]→ HRAC 1 – HOD 6 – TAH FIG 2
[1]→ OK
[2]→ HRAC 1 – HOD 6 – TAH FIG 2

← [1] HOD 3
← [1] TAH FIGURKOU 3
[0]→ HRAC 1 – HOD 3 – TAH FIG 3
[1]→ OK
[2]→ HRAC 1 – HOD 3 – TAH FIG 3

// Pokracuje dalsi hrac 

Polling (otravný oslík)

Předchozí varianta předpokládá „několik“ klientů připojených zároveň na server, kteří jsou vyřizování v předem určeném (či dohodnutém) pořadí. Následující varianta se využívá například v prostředí webových stránek. Klient není stále připojen na server, ale vždy se připojí, položí rychlý dotaz či příkaz a zase se odpojí, aby se mohl připojit někdo jiný.

Ve skutečnosti to funguje jako ostravný oslík z filmu Shrek 2. Všimněte si, že nyní si nepamatujeme kdo je kdo, tudíž pokud se klient připojí musí nám vždy sdělit svoje jméno či ID, které jsme mu přidělili při prvním připojení. V našem případě jsou klienty Oslík a Fiona a naopak server je Shrek.

 // Klient 0
→ NAME Oslík: Už tam budem?
← RESPONSE: Ne

// Klient 1
→ NAME Oslík: Už tam budem?
← RESPONSE: Ne

// Klient 2
→ NAME Oslík: Už tam budem?
← RESPONSE: Ne

// Klient 3
→ NAME Fiona: Už tam jsme!
← RESPONSE: Díky bohu.

// Klient 4
→ NAME Oslík: Už tam budem?
← RESPONSE: ANO! 

Server

 while ( true ) {
    sock ← accept()

    zprava ← recv( sock )
    odpoved ← zpracujZpravu( zprava )
    send( sock, odpoved )

    close( sock )

Klient (Oslík)

 nejsmeTam ←  true

while ( nejsmeTam ) {
    sock ← connect( „shrek IP adresa“ )

    send( sock, „Uz tam budem?“ )
    nejsmeTam ← !( recv( sock ) )
}

print ← „Juchu“