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.
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ů“.
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.
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);
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_CEKAJICICH_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_CEKAJICICH_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 );
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)
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).
Funkce recv
bere 4 parametry:
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_BUFFERU ];
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
}
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_BUFFERU ];
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.
A jak ukončit spojení? No zavoláním funkce close
.
close(sock);
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.
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.
[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.
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
}
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, 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
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!
while ( true ) {
sock ← accept()
zprava ← recv( sock )
odpoved ← zpracujZpravu( zprava )
send( sock, odpoved )
close( sock )
}
nejsmeTam ← true
while ( nejsmeTam ) {
sock ← connect( „shrek IP adresa“ )
send( sock, „Uz tam budem?“ )
nejsmeTam ← !( recv( sock ) )
}
print ← „Juchu“