Internetové programování s WinSock (3)

 

Minule jsme vytvořili náš první socket a dnes si konečně předvedeme, k čemu je užitečný. Naučíme se navázat spojení se serverem a přenášet data pomocí protokolů TCP i UDP.

 

Ustavení relace TCP

 

Protože TCP je protokol spojovaný, první, co musíme provést, je připojení k serveru. Po připojení je možné zasílat serveru data a přijímat odpovědi bez opakovaného udávání cílové adresy. Ta je po připojení pevně určena a socket není možné používat pro komunikaci s jinými servery.

Připojení provedeme zavoláním funkce connect. Prvním parametrem je dříve úspěšně vytvořený socket typu SOCK_STREAM. Jako druhý parametr dosadíme ukazatel na správně vyplněnou strukturu SOCKADDR_IN včetně čísla portu. Protože funkce connect počítá i s jinými protokoly, než je TCP/IP, je na místě tohoto parametru očekávána obecnější struktura SOCKADDR. Náš ukazatel proto musíme správně přetypovat. Třetí a poslední parametr určuje velikost námi zadané adresní struktury v bytech.

V případě, že je spojení úspěšně navázáno, funkce vrátí nulu, jinak vrátí kód SOCKET_ERROR.

Pro objasnění následuje krátký výpis:

 

SOCKET client_sock;

SOCKADDR_IN si;

...

/* vytvoreni socketu */

/* vyplneni adresy */

connect(client_sock, (SOCKADDR *) &si, sizeof si);

...

 

Odesílání a příjem dat

 

Když jednou máme socket připojený k serveru, můžeme začít se samotnou komunikací. K odesílání dat slouží funkce send a ke čtení dat přijatých máme funkci recv.

Funkce send má čtyři parametry. Prvním z nich je socket, jímž chceme posílat data. Tento socket musí být úspěšně vytvořený a připojený. Jako druhý parametr se zadává ukazatel na buffer obsahující data k odeslání. Třetí parametr udává počet bytů z bufferu, které mají být odeslány. Poslední parametr specifikuje volby, které nebudeme používat, proto sem budeme dosazovat nulu. Návratovou hodnotou je počet skutečně odeslaných bytů. Jestliže funkce selže, je navrácena hodnota SOCKET_ERROR.

Musíme si uvědomit, že funkce může odeslat méně dat, než jsme požadovali. V takovém případě musíme posunout ukazatel o počet poslaných bytů, o stejnou hodnotu snížit požadavek na množství dat, která zbývá odeslat, a zavolat funkci send znovu s těmito novými parametry. Toto je nutné opakovat, dokud nejsou odbavena všechna naše data.

Následující kód zajistí odeslání všech dat z bufferu určité velikosti.

 

int offset;

char buf[256];

...

offset = 0;

while (offset < sizeof buf) {

  /* pokusime se odeslat zbytek bufferu */

  /* posuneme se o pocet skutecne odeslanych bytu */

  offset += send(sock, buf + offset, sizeof buf - offset, 0);

}

 

V tomto kódu, stejně jako v několika následujících, se dopouštíme přímo kriminálního postupu. V každé aplikaci je nutno po čtení a zápisu nejdříve zkontrolovat návratovou hodnotu, jestli nedošlo k chybě nebo k ukončení relace, a pak je teprve možné ji považovat za počet přenesených bytů. Naším důvodem je snaha o šetření místem a koncentrace na vysvětlení samotného přenosu dat.

Uveďme si ještě jeden příklad, který je v praxi obvyklejší. Tentokrát máme v bufferu pevné velikosti uložen nulou zakončený textový řetězec, který potřebujeme odeslat. Ukončující nulu nikdy neodesíláme, pokud to aplikační protokol výslovně nežádá, jinak by protější strana naším zprávám nemusela vůbec rozumět.

 

#include <string.h>

...

int len, offset;

char buf[256];

...

strcpy(buf, "Nejaka zprava, treba LIST /pub\r\n");

len = strlen(buf);

offset = 0;

while (offset < len) {

  offset += send(sock, buf + offset, len - offset, 0);

}

 

Čtení dat se provádí podobným způsobem. Funkce recv má opět čtyři parametry s podobným významem, jako tomu bylo v případě funkce send. Ukazatel předávaný druhým parametrem tentokrát ukazuje na připravený buffer, do něhož budou zapsána přijatá data. Třetí parametr určuje množství dat v bytech, které se do našeho bufferu vejde. Funkce vrací počet překopírovaných bytů, v případě chyby pak SOCKET_ERROR. Počet přečtených bytů opět může být menší než zadaná velikost bufferu. Jestliže potřebujeme k další práci programu více dat, musíme volat funkci opakovaně, podobně jako tomu bylo v případě funkce send.

Často bývají zprávy odesílané klientem nebo serverem ukončeny speciálním znakem k tomu určeným, například koncem řádku. V takovém případě potřebujeme opakovaně volat recv, dokud v přijatých datech nenajdeme oddělovací znak. Následující výpis kódu ukazuje, jak je možné přečíst zprávu ze serveru zakončenou znakem line feed (v C známým jako new line).

 

#include <string.h>

...

int offset, last_offset, bytes;

/* do bufferu se musi vejit cela zprava */

char buf[2048];

...

offset = 0;

do {

  /* zapamatujeme si zacatek prijimaneho retezce */

  last_offset = offset;

  /* pokusime se precist co nejvice bytu */

  offset += recv(sock, buf + offset, sizeof buf - offset - 1,        0);

  /* ukoncime vznikly retezec nulou */

  buf[offset] = 0;

  /* skoncime, kdyz nalezneme znak LF */

} while (strchr(buf + last_offset, '\n') == NULL);

 

Přijímáme-li textová data, s nimiž chceme pracovat pomocí řetězcových funkcí, musíme sami doplnit ukončující nulu podobně jako v předchozím příkladě. Funkce recv to za nás neudělá, čemuž se nesmíme divit, protože tato funkce nemá tušení, jestli ukládá data textová nebo binární. Jestliže na to zapomeneme, je na světě dost nepříjemná chyba, protože budeme předpokládat problém spíše v síťových funkcích než v našem vlastním poli.

 

Chování přenosových funkcí

 

Jak už bylo řečeno, funkce send a recv vracejí jako svůj výsledek počet skutečně přenesených bytů, který může být nižší než počet požadovaný.

Není-li možné odeslat data okamžitě, funkce send čeká, dokud se jí nepodaří odeslat alespoň část dat. Teprve potom její činnost skončí a je vrácen počet bytů. To, co funkce send ve skutečnosti dělá, je zápis dat do bufferu TCP. Samotný přenos přes síťové rozhraní zajišťuje operační systém asynchronně mimo vědomí našeho programu. Jestliže při volání funkce send jako počet bytů k odeslání zadáme k nulu, funkce jednoduše vrátí nulu, nic neodešle, neohlásí chybu.

Chování funkce recv je trochu odlišné. Mohlo by se očekávat, že pokud nejsou k dispozici žádná příchozí data ze serveru, pak funkce vrátí nulu. Skutečnost je jiná. Funkce se pokusí překopírovat co nejvíce bytů z bufferu TCP do bufferu našeho. Nejsou-li žádná data k dispozici, funkce čeká, dokud nějaká nepřijdou, a teprve po jejich zkopírování ukončí svou činnost. Jestliže tedy v programu zavoláme recv v okamžiku, kdy server nemá v úmyslu odeslat žádná další data, program navždy zatuhne.

Funkce recv vrátí nulu v případě, že protější počítač zrušil spojení TCP korektním způsobem.

Když dojde k nestandardnímu výpadku spojení, funkce send i recv vracejí záporný chybový kód SOCKET_ERROR.

Existuje způsob, jak jednoduše zjistit, jestli jsou na socketu nějaká příchozí data. K tomuto účelu můžeme použít funkci ioctlsocket, která slouží i k některým dalším méně obvyklým operacím se socketem. Za první parametr dosadíme patřičný socket, za druhý konstantu FIONREAD určující druh operace - zjištění dostupného množství příchozích dat. Třetím parametrem musí být ukazatel na 32bitovou celočíselnou proměnnou, do níž bude zapsán počet bytů přítomných v bufferu, které mohou být získány jedním voláním funkce recv. Funkce vrátí nulu nebo SOCKET_ERROR, jak už jsme zvyklí.

 

long bytes;

...

ioctlsocket(sock, FIONREAD, &bytes);

if (bytes > 0)

  recv(sock, buf, sizeof buf, 0);

...

 

Skutečnost, že funkce recv a send mohou čekat a blokovat zbytek programu, může být nepříjemná. Ze zatím probraných funkcí se podobným způsobem chovají také funkce gethosbyname a connect. Obě nejdříve čekají na odpověď vzdáleného počítače, a potom teprve vracejí své návratové hodnoty. Proto i tyto funkce mohou způsobit dočasné "zaseknutí" programu. To je tolerovatelné v programech pracujících v dávkovém režimu nebo ovládaných z příkazové řádky. V normálním událostmi řízeném programu s grafickým uživatelským rozhraním by takové chování bylo nevhodné a takový program by určitě důvěru nezískal. V těchto programech se používají sockety v tzv. neblokujícím režimu, případně blokující funkce běžící v samostatném vlákně. My zatím zůstaneme v blokujícím režimu, který je jednodušší a k vysvětlování principů práce se sockety vhodnější. K funkcím neblokujícím se vrátíme později.

 

Zrušení relace TCP

 

Poté, co jsme ukončili komunikaci se vzdáleným počítačem, je nutné se správně odpojit. Spojení TCP je obousměrné, a proto existuje možnost uzavřít spojení v každém směru zvlášť. K tomu používáme funkci shutdown. Jejím prvním parametrem je samozřejmě socket, který potřebujeme odpojit. Druhý parametr udává, ve kterém směru se má spojení ukončit. Konstanta 1 znamená, že ze socketu ještě můžeme číst, ale odesílání dalších dat už je nemožné. Aplikace na protější straně spojení obdrží tzv. paket FIN, který oznamuje, že tato polovina spojení je uzavřena. Konstanta 0 naopak způsobí ukončení příjmu dat a hodnota 2 uzavření v obou směrech.

V praxi se používá tento postup:

·            Po odeslání všech dat zavoláme funkci shutdown s volbou 1, čímž oznámíme vzdálenému počítači, že z naší strany se už dat nedočká.

·            Pomocí funkce recv přečteme všechna data, která nám server ještě pošle.

·            Server zavře druhou polovinu spojení, což poznáme podle toho, že recv vrátí nulu (nebo chybový kód, přeruší-li se spojení nestandardním způsobem).

·            Teprve teď zrušíme socket pomocí funkce closesocket.

V některých protokolech, například v případě datového spojení FTP, ukončuje relaci nejdříve server. V takovém případě po přečtení všech dat zjistíme, že server zavřel svou odesílající stranu spojení. Totéž uděláme my pomocí funkce shutdown a můžeme okamžitě zavřít socket pomocí closesocket.

Někdy se jedna polovina spojení uzavírá poměrně brzy po navázání spojení a ke komunikaci se používá jen polovina druhá. Například WWW prohlížeč pošle serveru poměrně krátký požadavek HTTP, zavře svou odesílací stranu socketu, a pak už jen přijímá data.

Program by měl vždy zavírat jen svou odesílací polovinu spojení a neměl by používat jiné, násilné postupy.

 

Stahování stránek WWW

 

V tuto chvíli máme probráno poměrně dost látky, proto si zasloužíme trochu efektnější příklad. Bude jím program pro stahování WWW stránek a jejich ukládání na disk. Program (pracuje v konzolovém režimu) se nejdříve zeptá na adresu serveru (např. www.chip.cz), pak na cestu k dokumentu v rámci serveru (v nejjednodušším případě /) a nakonec na jméno souboru, do kterého následně uloží staženou stránku. V zájmu jednoduchosti program nepracuje s adresami URL a jsou ignorovány všechny možné chyby s výjimkou neexistující adresy.

Stažení stránky má na starosti protokol HTTP. V námi použité verzi HTTP 0.9 má požadavek na stránku tvar GET /cesta<CR><LF>. Server jako odpověď odešle obsah požadovaného dokumentu. V pokročilejších verzích je přímo v požadavku uvedena verze protokolu a komunikace je bohatší o tzv. hlavičky, pomocí nichž si klient a server vyměňují různé informace.

Následuje kompletní výpis programu.

 

#include <stdio.h>

#include <string.h>

#include <winsock.h>

 

int main(void)

{

  WSADATA WSAData;

  SOCKADDR_IN si;

  SOCKET sock;

  unsigned long ip_addr;

  char buf[2048];

  int len, offset;

  FILE *fw;

 

  WSAStartup(0x0101, &WSAData);

  printf("Zadejte adresu serveru: ");

  gets(buf);

 

  /* preklad adresy */

  ip_addr = inet_addr(buf);

  if (ip_addr == INADDR_NONE) {

    HOSTENT *phe = gethostbyname(buf);

   

    if (phe != NULL)

      ip_addr = *(unsigned long *) (phe->h_addr);

  }

  if (ip_addr == INADDR_NONE) {

    printf("Server nenalezen.\r\n");

    return 0;

  }

 

  /* pripojeni k serveru HTTP */

  si.sin_family       = AF_INET;

  si.sin_addr.s_addr  = ip_addr;

  si.sin_port         = htons(80);

 

  sock = socket(AF_INET, SOCK_STREAM, 0);

  connect(sock, (SOCKADDR *) &si, sizeof si);

 

  /* sestaveni a odeslani pozadavku */

  strcpy(buf, "GET ");

  printf("Zadejte cestu zacinajici lomitkem: ");

  gets(buf + strlen(buf));

  strcat(buf, "\r\n");

 

  len = strlen(buf);

  offset = 0;

  while (offset < len)

    offset += send(sock, buf + offset, len - offset, 0);

  shutdown(sock, 1);

 

  /* prijem a ulozeni dokumentu */

  printf("Zadejte jmeno mistniho souboru: ");

  gets(buf);

  fw = fopen(buf, "w");

  /* cteme, dokud se server neodpoji */

  while ((len = recv(sock, buf, sizeof buf, 0)) > 0)   

    fwrite(buf, 1, len, fw);

  fclose(fw);

 

  closesocket(sock);

  WSACleanup();

  return 0;

}

 

Klient nad UDP

 

Práce se socketem protokolu UDP je odlišná a o něco jednodušší, než tomu bylo v případě protokolu TCP. Není ustavováno žádné spojení, a proto odpadá volání funkcí connect a shutdown. Po vytvoření socketu typu SOCK_DGRAM je možné okamžitě posílat a přijímat data. K tomu slouží funkce sendto, která odesílá datagram na uvedenou adresu a funkce recvfrom, která přečte přijatý datagram a zároveň uloží adresu, ze které přišel. Jediný socket tedy můžeme používat pro komunikaci s několika počítači nebo aplikacemi. Na podrobnosti přenosu datagramů se podíváme dále.

Méně obvyklým, ale také možným postupem je svázání socketu s jednou adresou pomocí funkce connect. Po zavolání této funkce nedojde ustavení žádného spojení, pouze k omezení socketu na jedinou vzdálenou adresu. Přenos datagramů se pak provádí pomocí nám už známých funkcí send a recv, u kterých se neuvádí žádná adresa. Datagramy přijaté z jiných adres jsou zahazovány. Změnit adresu, ke které je socket "připojen" je možné opakovaným voláním funkce connect. Jestliže chceme omezení zrušit, zavoláme connect se strukturou typu SOCKADDR_IN obsahující adresu IP 0.0.0.0. Poté můžeme socket UDP používat zase standardním způsobem.

Po skončení práce se socketem jej jednoduše zrušíme pomocí volání closesocket. Nikdy nevoláme funkci shutdown, ani v případě, že byl socket "připojen" pomocí funkce connect.

 

Přenos datagramů

 

Funkce sendto má první čtyři parametry podobné jako funkce send. Jsou to socket, ukazatel na buffer, velikost datagramu v bytech a volby. Pátým parametrem je ukazatel na cílovou adresu, která má obdržet datagram. Ukazatel na vyplněnou strukturu SOCKADDR_IN musíme opět přetypovat na typ SOCKADDR*. Posledním parametrem je velikost naší adresní struktury v bytech.

 

#include <string.h>

...

SOCKET sock;

SOCKADDR_IN si;

char buf[128]

...

sock = socket(AF_INET, SOCK_DGRAM, 0);

/* vyplneni adresy si */

strcpy(buf, "Servere, toto je muj datagram!");

sendto(sock, buf, strlen(buf), 0, (SOCKADDR *) si, sizeof si);

...

 

Funkce sendto vrací počet odeslaných bytů, v případě selhání SOCKET_ERROR. Velikost datagramu nesmí být příliš velká, jinak se zvyšuje nebezpečí jeho ztráty po cestě, případně jeho odeslání nemusí být vůbec možné. Jednotlivé datagramy by měly být velké řádově do stovek bytů, nad 512 bytů obvykle dochází k fragmentaci. Horním limitem, ke kterému už by se aplikace neměly příliš přibližovat, je 8 KB pro jeden datagram. Funkce sendto neohlásí žádnou chybu, jestliže byl datagram úspěšně odeslán, ale nebyl doručen k cíli.

Funkce recvfrom čte data jednoho přijatého datagramu. Parametry jsou opět analogické: socket, buffer pro příjem, maximální počet bytů, volby. Pátý parametr je ukazatel na strukturu SOCKADDR_IN, do které má být uložena adresa, ze které datagram přišel. Jedná se o parametr výstupní, adresa je vyplněna až funkcí recvfrom. Šestý parametr se liší. Je to ukazatel na celočíselnou proměnnou obsahující velikost bufferu pro adresu. Před voláním musí být v této proměnné uložen počet bytů pro uložení adresy, funkce toto číslo změní na skutečnou velikost uložené adresní struktury. Jestliže nás adresa odesílatele nezajímá, můžeme dosadit za poslední dva parametry NULL.

Funkce vrací počet bytů z přečteného datagramu. Jestliže náš buffer není dost velký, aby se do něj celý datagram vešel, je zbytek datagramu zahozen a funkce navíc vrátí chybový kód. Každé příští volání recvfrom čte vždy nový datagram. Velikost prvního přijatého datagramu, který čeká na přečtení, můžeme určit pomocí funkce ioctlsocket s parametrem FIONREAD, kterou jsme už zmínili u proudových socketů TCP. Lepší je ale znát pevnou maximální velikost datagramu, který můžeme obdržet, a číst vždy do bufferu s touto velikostí.

 

SOCKET sock;

char buf[512];

int bytes;

...

/* vytvoreni socketu */

/* komunikace se serverem */

bytes = recvfrom(sock, buf, sizeof buf, 0, NULL, NULL);

/* v bufferu mame datagram, v promenne bytes jeho delku */

 

Funkce sendto a recvfrom čekají, dokud není celý datagram přenesen, a proto stejně jako send a recv blokují další činnost programu.

 

Závěr

 

Teď už víme, že funkční internetový klient nemusí být zase tak složitý. Příště se podíváme na činnosti typické pro serverové aplikace a další důležité oblasti.