<< Home

Scriviamo un server TCP

Indice

Introduzione

In questo post spiegherò come scrivere un server TCP e, per mostrarne il funzionamento, il rispettivo client.

Cos'è TCP?

TCP è un protocollo di rete, ossia un protocollo che permette lo scambio di messaggi tra computer. Più nel dettaglio quello che ci permette di fare è collegare virtualmente due computer e trasferire un insieme di byte da uno all'altro. Il collegamento è virtuale perchè in realtà i computer si suppongono già fisicamente collegati mediante un insieme di router, tuttavia è solo quando entrambi manifestano l'intento di voler comunicare che questo può avvenire. Quindi per "virtuale" intendo dire che il collegamento è un'astrazione perchè di fatto i computer sono sempre stati collegati.

Come funziona?

Per poter comunicare usando TCP, si deve prima decidere chi dei due nodi è il server e chi è il client. Il server è quello che si mette in ascolto per la connessione, mentre il client è colui che decide quando comunicazione deve cominciare. È da notare che la differenza tra server e client non è legata a chi invia e chi riceve informazioni, è solo un modo per gestire il primo collegamento. Se non ci fosse questa distinzione di ruoli allora sarebbe quasi impossibile creare connessioni TCP! Supponiamo che i ruoli siano simmetrici e che la connessione si avvii quando entrambi allo stesso tempo decidono di aprirla: quali sono le probabilità che due utenti ed i rispettivi computer si colleghino allo stesso tempo? Per farlo in modo preciso sarebbe richiesto qualche tipo di sincronizzazione, ma questa implicherebbe una comunicazione, tuttavia per ipotesi i computer non stanno ancora comunicando!! Un analogia che potrebbe mostrare questo problema sotto un'altra luce è quella dei telefoni cellulari. In effetti ogni volta che abbiamo addosso il cellulare ci stiamo comportando da server, perchè in ogni istante siamo aperti a ricevere telefonate. Dall'altra parte un nostro amico che ci chiama si comporta da client poichè è lui che decide di avviare la comunicazione. Rimuovere questi due ruoli comporterebbe che per parlare a telefono sia noi che il nostro amico dovremmo premere il tasto verde di chiamata allo stesso tempo.

Per il protocollo TCP ogni computer su una rete è caratterizzato da un identificativo univoco chiamato indirizzo IP. In più su ciascun PC ogni programma è caratterizzato da un ulteriore identificativo chiamato porta univoco localmente. Naturalmente due computer per parlarsi hanno bisogno dei rispettivi IP, tuttavia quelli che parlano fra di loro sono i programmi in esecuzione sui due PC, quindi c'è bisogno della porta come ulteriore fattore discriminante. Per questo motivo per avviare la comunicazione il client deve conoscere il "nome" del server, cioè la coppia indirizzo-porta. Quando il client contatterà il server, automaticamente questo verrà a conoscenza degli identificativi del client. Questo comporta che il client debba conoscere a priori l'indirizzo e la porta del server! Questo però nella pratica rappresenta un vincolo molto poco limitante.

Gli indirizzi IP sono stringhe di 32 cifre binarie (bit). Le combinazioni di 0 ed 1 (i bit) che si possono fare con una stringa di questa lunghezza sono circa 4 miliardi, per cui questo è il limite di computer che si possono avere su una rete. Per quanto riguarda le porte, queste sono stringhe binarie di 16 cifre. Le permutazioni che invece si possono ottenere in questo caso sono 65536. Quando si programma di solito le porte, dato che assumono valori piccoli (rispetto agli indirizzi IP), vengono riportate come numero (cioè in base 10 invece che base 2). Per quanto riguarda gli indirizzi IP invece, riportarle come numeri risulterebbe scomodo dato che è difficile distinguere numeri molto grandi ad occhio. Per questo motivo si usa la notazione dot-decimal, per la quale la stringa di 32 bit viene divisa in 4 stringhe da 8 e ciascuna sotto-stringa viene rappresentata in base 10 separata dalle altre con un puntino. Indirizzi IP espressi usando questa notazione sono molto comuni e chiunque abbia usato un computer ne avrà visto uno. Un esempio di indirizzo IP in notazione dot-decimal è 192.168.0.1. Dato che una stringa di 8 bit ha 256 permutazioni, i 4 numeri nella notazione dot-decimal possono solo assumere valori da 0 a 255 (i numeri da 0 a 255 sono in totale 256, cioè di conta pure lo 0).

Una volta avviata la comunicazione, i due processi mandano messaggi ed attendono risposte, sincronizzandosi, effettuando elaborazioni e producendo risultati grazie a questa cooperazione. Una volta conclusa la comunicazione, entrambi i processi possono chiudere la connessione.

Queste sono solo parole, noi dobbiamo farlo in codice tutto questo!! Me non temete! Ogni passo sarà accuratamente ed amorevolmente documentato :^) <3

Cosa realizzeremo

In verità dire che faremo un server ed un client TCP non è una descrizione esaustiva. TCP descrive solo il metodo che i programmi useranno per comunicare, ma non cosa si diranno! In particolare quel che faremo è un client che invia un messaggio al server, che leggerà il messaggio, ne convertirà maiuscole in minuscole e viceversa, e poi rimanderà la versione elaborata del messaggio.

Come lo realizzeremo: I socket

Per realizzare un'applicazione del genere, dovremo fare quella che viene chiamata socket programming, cioè la programmazione che usa i "socket".

Un socket è una struttura dati del kernel della quale possiamo chiamare dei metodi per effettuare delle comunicazioni sulla rete. È il modo in cui il sistema operativo astrae le funzionalità più crude dell'hardware. Il tema dell'astrazione dell'hardare è molto vasto, ma per i nostri scopi ci basta questo accenno.

Il client avrà una struttura del tipo

1main()
2{
3 address = 127.0.0.1
4 port = 8080
5 socket = create_tcp_socket()
6
7 connect(socket, address, port)
8
9 messaggio = "Ossimoro"
10
11 send(socket, messaggio)
12
13 messaggio = receive(socket)
14
15 print('Il server ha risposto con: ' + messaggio)
16
17 close(socket)
18}

mentre il server:

1main()
2{
3 port = 8080
4 socket = create_tcp_socket()
5
6 listen(socket, port)
7
8 loop {
9 socket2 = accept(socket)
10
11 messaggio = read(socket2)
12
13 messaggio = convert_case(messaggio)
14
15 send(socket2, messaggio)
16
17 close(socket2)
18 }
19 close(socket)
20}

Socket

I socket possono svolgere tante funzioni, quindi quando ne creiamo uno dobbiamo specificare le modalità di utilizzo. La funzione che permette di creare un socket è

1#include <sys/socket.h>
2
3int socket(int domain, int type, int protocol);

Con questa notazione include + prototipo della funzione intendo dire che la libreria inclusa definisce la funzione sotto.

Il modo in cui si usa questa funzione è un po' caotico. Questa cosa è dovuta al fatto che quando ne fu progettata l'interfaccia, si cercò di prevedere gli sviluppi futuri, che poi non avvennero mai.

Il primo argomento domain specifica lo spazio degli indirizzi del protocollo che vogliamo usare. Il valore di questo parametro può essere specificato usando dei valori specificati in sys/socket.h come AF_INET, AF_UNIT oppure AF_BLUETOOTH. Il prefisso AF_ sta per Address Family. Noi useremo solo AF_INET, in cui INET sta, banalmente, per internet.

Il secondo argomento type specifica quello che comunemente indichiamo col nome di protocollo, cioè TCP nel nostro caso. Il valore che dobbiamo fornire per TCP è SOCK_STREAM. Altri esempi di protocolli che possiamo specificare sono UDP (SOCK_DGRAM) o l'assenza di protocollo (SOCK_RAW).

Il terzo valore protocol è superfluo. Nel caso di TCP può sempre essere zero.

La funzione socket ritorna quel che è comunemente chiamato descrittore del socket, cioè un numero intero positivo che descrive inequivocabilmente il nostro socket all'interno del kernel. Ogni volta che vorremo operare sul socket, forniremo alle funzione questo descrittore ed il sistema operativo saprà su quale socket operare. Se la creazione della struttura fallisce, allora un valore negativo è ritornato.

In sintesi, per creare un socket TCP faremo una cosa del tipo:

1#include <unistd.h> // Questa definisce [close]
2#include <sys/socket.h>
3
4int main(void)
5{
6 int fd = socket(AF_INET, SOCK_STREAM, 0);
7 if(fd < 0) {
8 // Non sono riuscito a creare il socket TCP!
9 return -1;
10 }
11
12 // .. fai cose col socket ..
13
14 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
15 return 0;
16}

Su linux ed i sistemi unix in generale, le risorse sono spesso astratte sotto il concetto di file. I socket sarebbero un tipo speciale di file. Per questo motivo il descrittore dei socket è anche chiamato file descriptor (e da questo il nome fd della variabile)

Sia nel caso del client che del server l'apertura e chiusura del socket sono uguali. Quello che cambia è la loro configurazione e poi connessione. Una volta avviata invece il codice torna simmetrico, nel senso che i due programmi possono ricevere ed inviare dati allo stesso modo.

Configurazione del socket per il server

Quel che il server dovrà fare dopo aver creato il socket è assegnargli un indirizzo IP tra quelli che ha a disposizione (potrebbe evere più di una interfaccia di rete!). Questo si fa usando la funzione bind, infatti "bind" si potrebbe tradurre dall'inglese all'italiano con "legare" (il socket è legato all'indirizzo). Una volta fatto questo è possibile attendere la connessione del client usando la funzione listen, che "ascolta" per connessioni ("listen" è la parola inglese che corrisponde ad "ascoltare" in italiano). Per accettare le connessioni dopo aver fatto listen, il server userà la funzione accept, che restituirà un nuovo socket relativo alla particolare connessione appena creata.

Bind

La funzione bind è la parte più difficile della guida, quindi una volta fatta quella sarà tutto in discesa! L'interfaccia della funzione è questa qui:

1#include <sys/socket.h>
2
3int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

Ad un livello più alto, quello che fa bind è prendere un buffer contenente un indirizzo ed associarlo ad un socket. Dato che questa funzione si può usare per più protocolli con spazi di indirizzi diversi, fa il minimo numero di assunzioni su come sia fatto un indirizzo. Infatti, la struttura che rappresente un indirizzo generico per bind è questa qui

1#include <sys/socket.h>
2
3typedef unsigned short sa_family_t;
4// [sa_family_t] contiene i valori [AF_*]
5
6struct sockaddr {
7 sa_family_t sa_family;
8 char sa_data[14];
9};

in cui l'effettivo valore dell'indirizzo è un array di byte. Programmando in C, interpretare una struttura dati come array di caratteri corrisponde a dire "non mi interessa sapere come è strutturato questo dato, voglio solo poterlo spostare in giro". L'indirizzo effettivo poi potrebbe anche essere meno o più grande di struct sockaddr, da cui la necessita dell'argomento addrlen di bind in cui è possibile specificare la dimensione della regione puntata dall'argomento addr.

Se questa interfaccia ti sembra confusa, non temere! È quel che provano tutti. È il risultato di anni di standard e convenzioni che hanno avuto bisogno di essere sempre retrocompatibili.

Tuttavia, quando andremo a costruire l'indirizzo da fornire a bind non opereremo su una struttura del tipo struct sockaddr ma di tipo struct sockaddr_in, definita come

1#include <netinet/in.h>
2
3struct sockaddr_in {
4 short sin_family;
5 unsigned short sin_port;
6 struct in_addr sin_addr;
7 char sin_zero[8];
8};
9
10struct in_addr {
11 unsigned long s_addr;
12};

il cui layout è compatibile a struct sockaddr (entrambi hanno fin_family allo stesso posto e le loro dimensioni sono uguali, di 16 byte), ma in più il campi sa_data di struct sockaddr è esploso nei tre campo sin_port, sin_addr e sin_zero. Una volta configurata questa struttura potremo fornirla a bind dopo aver fatto un cast a struct sockaddr.

Il campo sin_family di struct sockaddr (e quindi di struct sockaddr_in) contiene il tipo di indirizzo, quindi uno di quegli identificativi che cominciano con AF_. Il valore di questo campo deve essere uguale al primo argomento di socket, cioè AF_INET. Del resto ha senso interpretare struct sockaddr come un struct sockaddr_in solo quando usiamo AF_INET. Il campo sin_family di un struct sockaddr_in sarà sempre AF_INET.

I campi sin_port e sin_addr (più in particolare sin_addr.s_addr) sono rispettivamente porta ed indirizzo, cioè gli stessi porta ed indirizzo di cui parlavamo nell'introduzione al TCP. Questi due campi dovranno avere l'ordinamento dei byte della rete (cioè big endian, nel caso del TCP), quindi se il nostro computer non ha lo stesso suo ordinamento (cioè è little endian) allora dovremo effettuare una conversione. Per effettuare questa conversione comodamente, possiamo usare le funzioni

1#include <arpa/inet.h>
2uint32_t htonl(uint32_t hostlong);
3uint16_t htons(uint16_t hostshort);
4uint32_t ntohl(uint32_t netlong);
5uint16_t ntohs(uint16_t netshort);

Che trasformano interi da ordinamento dell'host (il nostro computer) a quello della rete o viceversa. Infatti "hton" ed "ntoh" stanno per "host to network" e "network to host". La "l" o "s" finali invece fanno riferimento al tipo di dato che convertono, cioè "long" o "short". Nel caso dell'indirizzo dovremo usare htonl, mentre nel caso della porta htons.

Invece di specificare un singolo indirizzo IP, possiamo usare la costante INADDR_ANY definita in arpa/inet.h per effettuare il bind su tutti i possibili indirizzi disponibili.

L'ultimo campo sin_zero ha come unica funzione quella di far combaciare la dimensione delle strutture sockaddr e sockaddr_in. Infatti il nome stesso contiene la parola "zero" per indicare che questo campo dovrebbe solo errere posto uguale a zero.

Il valore di ritorno di bind è zero quando tutto è andato a buon fine ed un valore negativo quando ha fallito.

Detto questo, il binding del server sarà fatto in questo modo qui

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6
7int main(void)
8{
9 int fd = socket(AF_INET, SOCK_STREAM, 0);
10 if(fd < 0) {
11 // Non sono riuscito a creare il socket TCP!
12 return -1;
13 }
14
15 struct sockaddr_in addr;
16 addr.sin_family = AF_INET;
17 addr.sin_port = htons(8080);
18 addr.sin_addr.s_addr = INADDR_ANY;
19 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
20
21 if(bind(fd, (struct sockaddr*) &addr,
22 sizeof(struct sockaddr_in))) {
23 // Il binding è fallito!!
24 close(fd);
25 return -1;
26 }
27
28 // .. fai cose col socket ..
29
30 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
31 return 0;
32}

Decisamente più facile a farsi che a dirsi!!

Come per AF_INET, le altre famiglie di indirizzi rappresentano a loro modo il contenuto di struct sockaddr. Per questo motivo ciascuna definisce una propria struttura analoga a struct sockaddr_in per costruire i propri indirizzi. Ad esempio per AF_INET6 (anche noto come IPv6) si usa sockaddr_in6, mentre per AF_UNIX (i socket unix) si usa struct sockaddr_un. Tuttavia queste strutture potrebbero non avere la stessa dimensione di struct sockaddr! Questo è dovuto al fatto che quando è stata progettata questa API originariamente si è supposto che 14 byte sarebbero bastati per ogni genere di indirizzo. Quando poi è risultato necessario avere indirizzi più grandi, si è preferito non cambiare la definizione di struct sockaddr per rimanere retrocompatibili. In ogni caso questa differenza di dimensione non crea problemi di correttezza nei programmi.

Listen

Ultimo passo dell'inizializzazione è il listening, cioè effettivamente ascoltare per nuove connessioni. La funzione che dobbiamo usare è

1#include <sys/socket.h>
2
3int listen(int sockfd, int backlog);

Il primo argomento sockfd naturalmente è il socket che vogliamo mettere in ascolto, mentre il secondo backlog corrisponde alla dimensione della coda di connessioni non ancora accettate. Il valore di ritorno, come per bind, è non-zero quando qualcosa va storto.

Ogni volta che una richiesta di connessione viene ricevuta mentre il programma è in ascolto, il kernel la mette in una coda. Il nostro programma dal lato suo prenderà elementi da quella coda usando la funzione accept. Il paramentro backlog fa riferimento alla dimensione di queste coda. Se una nuova connessione arriva ma la code è piena, sarà automaticamente rifiutata, per questo è importante scegliere accuratamente questo valore. Se questo parametro è troppo piccolo ed il programma non riesce ad accettare connessioni con una frequenza comparabile a quella con cui arrivano, allora delle connessioni saranno perse. Se invece lo scegliamo troppo grande, delle risorse saranno sprecate.

Naturalmente per condizioni di prova come la nostra ha poca importanza che valore usiamo. Anche scegliere 1 andrà bene, tuttavia portando questo programma in circostanze più realistiche, questo creerebbe problemi!

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6
7int main(void)
8{
9 int fd = socket(AF_INET, SOCK_STREAM, 0);
10 if(fd < 0) {
11 // Non sono riuscito a creare il socket TCP!
12 return -1;
13 }
14
15 struct sockaddr_in addr;
16 addr.sin_family = AF_INET;
17 addr.sin_port = htons(8080);
18 addr.sin_addr.s_addr = INADDR_ANY;
19 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
20
21 if(bind(fd, (struct sockaddr*) &addr,
22 sizeof(struct sockaddr_in))) {
23 // Il binding è fallito!!
24 close(fd);
25 return -1;
26 }
27
28 if(listen(fd, 32)) {
29 // Non sono riuscito ad ascoltare!
30 close(fd);
31 return -1;
32 }
33
34 // .. fai cose col socket ..
35
36 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
37 return 0;
38}

Accept

Una volta completata la configurazione, il server potrà accettare nuove connessioni, usarle e poi chiuderle iterativamente. Come accennato prima, per accettare nuove richiesta si usa

1#include <sys/socket.h>
2
3int accept(int sockfd, struct sockaddr *restrict addr,
4 socklen_t *restrict addrlen);

dove sockfd è il descrittore del socket messo in ascolto mentre addr e addrlen sono argomenti di output che restituiscono l'indirizzo del client in una struttura del tipo struct sockaddr_in. Se non siamo interessati all'indirizzo possiamo fornire NULL ai due argomenti finali. Il valore di ritorno invece è il descrittore di un nuovo socket dal quale si potranno leggere e scrivere byte. Una volta conclusa la comunicazione con questo client, questo socket potrà essere chiuso ed una nuova connessione accettata usando dinuovo accept. Naturalmente è anche possibile accettare più connessioni allo stesso tempo chiamando accept più volte in sequenza prima, ma questo ci costringerebbe a dover rendere decisamente più sofisticata la struttura del nostro programma.

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6
7int main(void)
8{
9 int fd = socket(AF_INET, SOCK_STREAM, 0);
10 if(fd < 0) {
11 // Non sono riuscito a creare il socket TCP!
12 return -1;
13 }
14
15 struct sockaddr_in addr;
16 addr.sin_family = AF_INET;
17 addr.sin_port = htons(8080);
18 addr.sin_addr.s_addr = INADDR_ANY;
19 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
20
21 if(bind(fd, (struct sockaddr*) &addr,
22 sizeof(struct sockaddr_in))) {
23 // Il binding è fallito!!
24 close(fd);
25 return -1;
26 }
27
28 if(listen(fd, 32)) {
29 // Non sono riuscito ad ascoltare!
30 close(fd);
31 return -1;
32 }
33
34 while(1) {
35 int clifd = accept(fd, NULL, NULL);
36 if(clifd < 0)
37 continue; // Torna in cima al loop
38
39 // .. fai cose col socket ..
40
41 close(clifd);
42 }
43
44 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
45 return 0;
46}

Configurazione del socket per il client

La configurazione del client è più semplice del server. Come il server crea un socket TCP usando socket, poi però si connette al server chiamando la funzione connect. Se connect ha avuto buon fine, la connessione è stata stabilita ed è possibile comunicare.

Connect

La funzione connect è definita in questo modo qui

1#include <sys/socket.h>
2
3int connect(int sockfd, const struct sockaddr *addr,
4 socklen_t addrlen);

Dove sockfd è il socket che vogliamo usare per effettuare la connessione (quello creato con socket) e la coppia addr/addrlen specifica l'indirizzo al quale vogliamo connetterci. Come per gli altri caso avremo addr pari ad un struct sockaddr_in e addrlen pari a sizeof(struct sockaddr_in). Se la funzione fallisce, allora -1 viene ritornato, altrimenti 0.

L'inizializzazione del client quindi avrà una forma del genere

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6
7int main(void)
8{
9 int fd = socket(AF_INET, SOCK_STREAM, 0);
10 if(fd < 0) {
11 // Non sono riuscito a creare il socket TCP!
12 return -1;
13 }
14
15 struct sockaddr_in addr;
16 addr.sin_family = AF_INET;
17 addr.sin_port = htons(8080); // Questa è la stessa del server!!
18 addr.sin_addr.s_addr = ???; // Ma qui cosa ci và?!
19 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
20
21 if(connect(fd, (struct sockaddr*) &addr,
22 sizeof(struct sockaddr_in))) {
23 // La connessione è fallita!!
24 close(fd);
25 return -1;
26 }
27
28 // .. fai cose col socket ..
29
30 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
31 return 0;
32}

L'indirizzo che forniamo a connect deve avere la stessa porta sulla quale il server ha fatto bind e come indirizzo IP anche quello sul quale ha fatto bind. Se il server ha fatto bind su più di un indirizzo IP usando INADDR_ANY, ci basta specificare uno di quelli a sua disposizione.

Normalmente gli indirizzi IP sono noti in rappresentazione dotted decimal. In tal caso per convertirli da tale rappresentazione ad un intero con i byte ordinati come piace alla rete, si può usare la funzione inet_pton:

1#include <arpa/inet.h>
2
3int inet_pton(int af, const char *restrict src,
4 void *restrict dst);

L'argomento af è la famiglia di indirizzi, ossia AF_INET nel nostro caso. Invece src è la stringa (zero-terminata) contenente l'indirizzo in rappresentazione dotted decimal. L'argomento dst è il riferimento ad una struttura struct in_addr dove vogliamo che la funzione scriva l'indirizzo generato. Se tutto va bene, allora la funzione ritorna 1, altrimenti 0 o -1.

La funzione inet_pton può operare anche su indirizzi IPv6. In tal caso l'argomento af è pari a AF_INET6 e dst punterà ad una struttura di tipo struct in_addr6. È per questo motivo che dst ha tipo void*, perchè può essere sia di tipo struct in_addr che struct in_addr6.
Esistono molte funzioni che effettuano questo genere di conversione. Un altro esempio è inet_aton, che però converte solo indirizzi IPv4.

Dato che sia client che server saranno eseguiti sullo stesso computer, possiamo considerare l'indirizzo IP del server pari a 127.0.0.1, quindi la connessione diventerà

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6
7int main(void)
8{
9 int fd = socket(AF_INET, SOCK_STREAM, 0);
10 if(fd < 0) {
11 // Non sono riuscito a creare il socket TCP!
12 return -1;
13 }
14
15 const char ip[] = "127.0.0.1";
16 unsigned short port = 8080;
17
18 struct sockaddr_in addr;
19 addr.sin_family = AF_INET;
20 addr.sin_port = htons(port);
21 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
22
23 if(inet_pton(AF_INET, ip, &addr.sin_addr) != 1) {
24 // Non sono riuscito a convertire l'indirizzo IP!!
25 close(fd);
26 return -1;
27 }
28
29 if(connect(fd, (struct sockaddr*) &addr,
30 sizeof(struct sockaddr_in))) {
31 // La connessione è fallita!!
32 close(fd);
33 return -1;
34 }
35
36 // .. fai cose col socket ..
37
38 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
39 return 0;
40}

A questo punto anche la configurazione del client è conclusa!

La business logic

Una volta connessi client e server, sarà il client a mandare il primo messaggo, quindi la prima cosa che farà il server dopo la connessione (cioè l'accept) è una chiamata a recv (dall'inglese, "ricevi"), mentre il client dopo la connessione (connect) chiamerà send (dall'inglese, "invia").

Send

La funzione send è definita così

1#include <sys/socket.h>
2
3ssize_t send(int sockfd, const void *buf, size_t len, int flags);

Dove sockfd è il socket relativo alla connessione (quello ritornato da accept nel server, oppure quello ritornato da socket nel client). Gli argomenti buf e len fanno riferimento all buffer che deve essere inviato e quanto è grande. Infine flags è un argomento che permette di configurare il modo in cui viene effettuato l'invio (per saperne di più sui flags, leggi questo). Il valore di default di flags è 0, ossia quel che useremo noi. Il valore di ritorno invece è -1 se qualcosa è andato storto, oppure il numero di byte inviati. È possibile che il valore di ritorno sia non negativo ma inferiore alla dimensione del buffer, quindi solo una parte del buffer è stata inviata, in tal caso dovremmo manualmente ritentare l'invio della parte mancante!

La funzione send, quando l'argomento flags è posto a 0, è equivalente alla funzione write. La funzione send opera solo sui socket, mentre write opera sui file in generale (un socket è un tipo di file).

Il tipo ssize_t è una versione con segno di size_t (cioè può assumere valori negativi). Questi tipi di dato sono usati di solito per mantenere la dimensione di regioni di memoria.

Recv

La funzione recv è abbastanza simmetrica rispetto alla sorella send. L'interfaccia è questa qui:

1#include <sys/socket.h>
2
3ssize_t recv(int sockfd, void *buf, size_t len, int flags);

Anche qui sockfd è il socket della connessione, tuttavia il buffer buff con dimensione len non sarà letto ma scritto. Il valore di len è considerato più come il numero massimo di byte da leggere, infatti il valore di ritorno, che è il numero di byte ricevuti, può solo essere inferiore o uguale a len. Tuttavia, in caso di errore è ritornato -1. Per quanto riguada il parametro flags, il comportamento è analogo a send.

Una cosa aggiuntiva da notare è che quando recv ritorna 0 significa che il nodo col quale siamo in comunicazione si è disconnesso.

La relazione fra send e write si ha anche nel caso di recv e read.

La business logic del client

Per quanto riguarda il client, un messaggio che verrà mandato al server per essere convertito. Quando la risposta sarà ricevuta, il programma la stampera a video. Avremo una cosa del genere (ho rimosso il codice che abbiamo scritto sino ad ora per poterci concetrare su quello che dobbiamo fare ora)

1/* ..gli header qui.. */
2#include <stdio.h>
3
4ssize_t super_send(int fd, const char *buf, size_t len, int flags)
5{
6 ssize_t sent_bytes = 0;
7 do {
8 ssize_t n = send(fd, buf + sent_bytes,
9 len - sent_bytes, flags);
10 if(n < 0) {
11 sent_bytes = -1;
12 break;
13 }
14 sent_bytes += n;
15 } while((size_t) sent_bytes < len);
16 return sent_bytes;
17}
18
19ssize_t super_recv(int fd, char *buf, size_t len, int flags)
20{
21 ssize_t received_bytes = 0;
22 do {
23 ssize_t n = recv(fd, buf + received_bytes,
24 len - received_bytes, flags);
25 if(n <= 0) {
26 received_bytes = n;
27 break;
28 }
29 received_bytes += n;
30 } while((size_t) received_bytes < len);
31 return received_bytes;
32}
33
34int main(void)
35{
36 int fd = /* ..creazione del socket.. */
37
38 /* ..inizializzazione del socket.. */
39
40 const char *message = "Ossimoro";
41
42 char risposta[32]; // Il buffer che conterrà la risposta del server.
43
44 // Assumiamo che la lunghezza massima della risposta
45 // sia 31 byte dato che deve entrare nel buffer
46 // [risposta] (31 perchè dobbiamo aggiungere un byte
47 // nullo alla fine per rendere la risposta una
48 // stringa zero-terminata).
49
50 // Dato che la risposta ha la stessa lunghezza del
51 // nostro messaggio, possiamo assicurarci già ora
52 // che la risposta non sforerà il buffer.
53 size_t message_len = strlen(message);
54 if(message_len > sizeof(risposta)-1) { // NOTA: sizeof(risposta)-1 è 31
55 fprintf(stderr, "Il messaggio non può essere "
56 "più lungo di %ld caratteri\n", sizeof(risposta)-1);
57 close(fd);
58 return -1;
59 }
60
61 // Invia il messaggio!
62 ssize_t n = super_send(fd, message, message_len, 0);
63 if(n < 0) {
64 /* Qualcosa è andato storto! */
65 fprintf(stderr, "Non sono riuscito ad inviare [%s]\n", message);
66 close(fd);
67 return -1;
68 }
69
70 n = super_recv(fd, risposta, message_len, 0);
71 if(n < 0) {
72 /* Qualcosa è andato storto! */
73 fprintf(stderr, "La ricezione della risposta a [%s] è fallita!", message);
74 close(fd);
75 return -1;
76 }
77
78 // A questo punto [recv] avrà scritto esattamente [n] byte
79 // all'interno di [risposta], per cui il primo byte dopo il
80 // messaggio è proprio alla posizione [risposta[n]]. Ora
81 // possiamo aggiungere il byte nullo terminatore:
82 risposta[n] = '\0';
83
84 // E stampare il messaggio
85 printf("[%s] -> [%s]\n", message, risposta);
86
87 /* ..chiudi il socket.. */
88 return 0;
89}

In realtà in questo codice abbiamo barato un po'. Non è necessariamente vero che recv e send inviino e ricevano l'intero messaggio con una sola chiamata. Per inviare il messaggio potrebbero servire due o più send! La necessità di chiamare iterativamente queste funzioni sorge quando il loro valore di ritorno (cioè n in questo esempio) è positivo ma comunque inferiore alla lunghezza del messaggio che abbiamo inviato. In questo programma non è un problema grave, perchè nel peggiore delle ipotesi risulterà che il messaggio del server è corretto a meno di qualche byte finale mancante.

Per rendere il nostro programma più robusto, la send potrebbe essere chiamata in questo modo qui

1ssize_t sent_bytes = 0;
2do {
3 // [message[i] + sent_bytes] è l'indirizzo della parte
4 // del messaggio non ancora inviato, mentre la sua
5 // lunghezza è [message_len - sent_bytes].
6 ssize_t n = send(fd, messages[i] + sent_bytes, message_len - sent_bytes, 0);
7 if(n < 0) {
8 sent_bytes = -1;
9 break;
10 }
11 sent_bytes += n;
12} while(sent_bytes < message_len);

In questo modo send verrà chiamata finchè l'interò messaggio sarà stato inviato, oppure un errore sarà avvenuto. Questo loop nel complesso si comporta come una send dove il valore di ritorno è sent_bytes ma che se invia il messaggio, non è mai parzialmente.

Quello che potremmo fare è creare una funzione ausiliaria super_send che effettua questo meccanismo per poi sostituirla alla semplice send interna al nostro programma.

Come accennato lo stesso problema si pone per recv. È possibile che ci vogliano più chiamate per ricevere l'intero messaggio. Per risolvere questo problema, come per la send, si potrebbe aggiungere un loop attorno alla sua chiamata

1ssize_t received_bytes = 0;
2do {
3 ssize_t n = recv(fd, risposta + received_bytes, message_len - received_bytes, 0);
4 if(n <= 0) {
5 received_bytes = n;
6 break;
7 }
8 received_bytes += n;
9} while(received_bytes < message_len);

Le stesse considerazione rispetto alla receive valgono: questo loop si comporta come una versione augmentata di recv che però non riceve mai il messaggio parzialmente, a patto che non avvenga un errore (in tal caso received_bytes == -1) e che il server non si disconnetta (received_bytes == 0).

Il codice completo del client avendo aggiunto super_send e super_recv è

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6#include <stdio.h>
7
8ssize_t super_send(int fd, const char *buf, size_t len, int flags)
9{
10 ssize_t sent_bytes = 0;
11 do {
12 ssize_t n = send(fd, buf + sent_bytes,
13 len - sent_bytes, flags);
14 if(n < 0) {
15 sent_bytes = -1;
16 break;
17 }
18 sent_bytes += n;
19 } while((size_t) sent_bytes < len);
20 return sent_bytes;
21}
22
23ssize_t super_recv(int fd, char *buf, size_t len, int flags)
24{
25 ssize_t received_bytes = 0;
26 do {
27 ssize_t n = recv(fd, buf + received_bytes,
28 len - received_bytes, flags);
29 if(n <= 0) {
30 received_bytes = n;
31 break;
32 }
33 received_bytes += n;
34 } while((size_t) received_bytes < len);
35 return received_bytes;
36}
37
38int main(void)
39{
40 int fd = socket(AF_INET, SOCK_STREAM, 0);
41 if(fd < 0) {
42 // Non sono riuscito a creare il socket TCP!
43 return -1;
44 }
45 const char ip[] = "127.0.0.1";
46 unsigned short port = 8080;
47 struct sockaddr_in addr;
48 addr.sin_family = AF_INET;
49 addr.sin_port = htons(port);
50 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
51
52 if(inet_pton(AF_INET, ip, &addr.sin_addr) != 1) {
53 // Non sono riuscito a convertire l'indirizzo IP!!
54 close(fd);
55 return -1;
56 }
57 if(connect(fd, (struct sockaddr*) &addr,
58 sizeof(struct sockaddr_in))) {
59 // La connessione è fallita!!
60 close(fd);
61 return -1;
62 }
63
64 const char *message = "Ossimoro";
65 char risposta[32]; // Il buffer che conterrà la risposta del server.
66
67 size_t message_len = strlen(message);
68 if(message_len > sizeof(risposta)-1) { // NOTA: sizeof(risposta)-1 è 31
69 fprintf(stderr, "Il messaggio non può essere "
70 "più lungo di %ld caratteri\n", sizeof(risposta)-1);
71 close(fd);
72 return -1;
73 }
74 // Invia il messaggio!
75 ssize_t n = super_send(fd, message, message_len, 0);
76 if(n < 0) {
77 /* Qualcosa è andato storto! */
78 fprintf(stderr, "Non sono riuscito ad inviare [%s]\n", message);
79 close(fd);
80 return -1;
81 }
82 n = super_recv(fd, risposta, message_len, 0);
83 if(n < 0) {
84 /* Qualcosa è andato storto! */
85 fprintf(stderr, "La ricezione della risposta a [%s] è fallita!", message);
86 close(fd);
87 return -1;
88 }
89
90 risposta[n] = '\0';
91 printf("[%s] -> [%s]\n", message, risposta);
92
93 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
94 return 0;
95}

Decisamente un bel pezzo di programma!

La business logic del server

Il server dalla sua parte, accetterà una connessione, riceverà il messaggio e dopo averlo convertito lo rimanderà al client. Una volta inviato tornera ad attendere per una nuova connessione.

1/* ..qui gli header.. */
2#include <ctype.h> // isupper, tolower, toupper
3
4ssize_t super_send(int fd, const char *buf, size_t len, int flags)
5{
6 ssize_t sent_bytes = 0;
7 do {
8 ssize_t n = send(fd, buf + sent_bytes,
9 len - sent_bytes, flags);
10 if(n < 0) {
11 sent_bytes = -1;
12 break;
13 }
14 sent_bytes += n;
15 } while((size_t) sent_bytes < len);
16 return sent_bytes;
17}
18
19int main(void)
20{
21 int fd = /* .. crea il socket.. */
22
23 /* ..inizializzalo.. */
24
25 while(1) {
26 int clifd = accept(fd, NULL, NULL);
27
28 if(clifd < 0)
29 /* Errore durante l'accettazione */
30 continue; // Torna ad accettare richieste
31
32 char buffer[32];
33
34 // In questo caso usiamo [recv] e non [super_recv]
35 // perchè non conosciamo la dimensione del messaggio
36 // a priori.
37 ssize_t n = recv(clifd, buffer, sizeof(buffer)-1, 0);
38 if(n <= 0) {
39 // C'è stato un errore oppure
40 // il client si è disconnesso.
41 // Chiudi il server e torna ad
42 // aspettare per nuove richieste.
43 close(clifd);
44 continue;
45 }
46 buffer[n] = '\0';
47
48 /* Converti la stringa */
49 for(int i = 0; buffer[i] != '\0'; i += 1) {
50 char c = buffer[i];
51 if(isupper(c))
52 c = tolower(c);
53 else
54 c = toupper(c);
55 buffer[i] = c;
56 }
57
58 // Ed ora inviala dinuovo
59 super_send(clifd, buffer, n, 0);
60
61 // Non controlliamo il valore di ritorno di
62 // [super_send] perchè in ogni caso chiuderemmo
63 // il socket.
64
65 close(clifd);
66 }
67
68 /* ..chiudi il socket.. */
69 return 0;
70}

Aggiungendo la porzione di inizializzazione e le altre omesse, si ottiene

1#include <string.h> // memset
2#include <unistd.h> // Questa definisce [close]
3#include <sys/socket.h>
4#include <arpa/inet.h>
5#include <netinet/in.h>
6#include <ctype.h> // isupper, tolower, toupper
7
8ssize_t super_send(int fd, const char *buf, size_t len, int flags)
9{
10 ssize_t sent_bytes = 0;
11 do {
12 ssize_t n = send(fd, buf + sent_bytes,
13 len - sent_bytes, flags);
14 if(n < 0) {
15 sent_bytes = -1;
16 break;
17 }
18 sent_bytes += n;
19 } while((size_t) sent_bytes < len);
20 return sent_bytes;
21}
22
23int main(void)
24{
25 int fd = socket(AF_INET, SOCK_STREAM, 0);
26 if(fd < 0) {
27 // Non sono riuscito a creare il socket TCP!
28 return -1;
29 }
30
31 struct sockaddr_in addr;
32 addr.sin_family = AF_INET;
33 addr.sin_port = htons(8080);
34 addr.sin_addr.s_addr = INADDR_ANY;
35 memset(&addr.sin_zero, 0, sizeof(addr.sin_zero));
36
37 if(bind(fd, (struct sockaddr*) &addr,
38 sizeof(struct sockaddr_in))) {
39 // Il binding è fallito!!
40 close(fd);
41 return -1;
42 }
43 if(listen(fd, 32)) {
44 // Non sono riuscito ad ascoltare!
45 close(fd);
46 return -1;
47 }
48 while(1) {
49 int clifd = accept(fd, NULL, NULL);
50
51 if(clifd < 0)
52 /* Errore durante l'accettazione */
53 continue; // Torna ad accettare richieste
54
55 char buffer[32];
56
57 ssize_t n = recv(clifd, buffer, sizeof(buffer)-1, 0);
58 if(n <= 0) {
59 close(clifd);
60 continue;
61 }
62 buffer[n] = '\0';
63
64 /* Converti la stringa */
65 for(int i = 0; buffer[i] != '\0'; i += 1) {
66 char c = buffer[i];
67 if(isupper(c))
68 c = tolower(c);
69 else
70 c = toupper(c);
71 buffer[i] = c;
72 }
73
74 super_send(clifd, buffer, n, 0);
75 close(clifd);
76 }
77 close(fd); // Quando abbiamo finito, possiamo chiuderlo.
78 return 0;
79}

Per come è stato scritto questo programma, il controllo non uscirà mai dal while, per cui l'ultima chiamata a close, in realtà, non sarà mai eseguita! Il motivo per cui l'ho lasciata è per mostrare comunque la struttura generale del programma che usa i socket.

In realtà i nomi super_send e super_recv sono intesi come nomi umoristici, nomi più appropriati sarebbero send_all e recv_all.

Riferimenti