Scrivere Web Services in Python: i socket

Spread the love

Andare alle origini della programmazione web è sempre affascinante. Questo articolo aiuta a capire come funziona il web attraverso azioni sui socket.

Questo post è la traduzione pressoché letterale dell’articolo del blog Iximiuz di Ivan Velichko. Sue anche le illustrazioni e gli esempi di codice funzionante. Lo ringrazio tantissimo per la gentilezza e l’entusiasmo che ha dimostrato per il mio interesse.

In coda trovate i suoi riferimenti. Io ho solamente integrato con qualche esempio applicativo e qualche commento qua e la, sempre segnalato.

Cos’è un web server?

Iniziamo rispondendo alla domanda: che cos’è un server web?

Prima di tutto, è un server (nessun gioco di parole). Un server è un processo [sic] che serve i client.

Frequentemente pensiamo al server come ad una macchina, ma in realtà è solo un processo che gira in una certa macchina – assieme a molti altri processi.

Quindi, che ci sorprendiamo o no, un server non ha nulla a che fare con l’hardware. È solo un normale software eseguito da un sistema operativo. Come la maggior parte degli altri programmi in circolazione, un server acquisisce alcuni dati dal proprio input, trasforma i dati in base ad alcune logiche di business e quindi produce alcuni dati di output. Nel caso di un server web, l’input e l’output avvengono sulla rete tramite Hypertext Transfer Protocol (HTTP). Per un server Web, l’input è costituito da richieste HTTP dai suoi client: browser Web, applicazioni mobili, dispositivi IoT o persino altri servizi Web. E l’output è costituito da risposte HTTP, spesso sotto forma di pagine HTML, ma sono supportati anche altri formati.

client-server-opt
client-server-opt

Cos’è questo protocollo di trasferimento ipertestuale (HTTP)? Bene, a questo punto, basterebbe pensarlo come un protocollo di scambio dati basato su testo (cioè leggibile dall’uomo). E la parola protocollo può essere spiegata come una sorta di convenzione tra due o più parti sul formato e sulle regole di trasferimento dei dati. Nessun problema, ci sarà un articolo che coprirà i dettagli di HTTP in seguito, mentre il resto di questo articolo sarà incentrato su come i computer inviano dati arbitrari sulla rete.

Che aspetto ha la programmazione di rete?

Nei sistemi operativi simili a Unix, è abbastanza comune trattare i dispositivi I/O come file. Proprio come avviene per i normali file su disco, i mouse, le stampanti, i modem, ecc. possono essere aperti, letti/scritti e quindi chiusi.

dev-files-opt
dev-files-opt

Per ogni file aperto, il sistema operativo crea un cosiddetto descrittore di file. Semplificando un po’, un descrittore di file è solo un identificatore intero univoco di un file all’interno di un processo. Il sistema operativo fornisce una serie di funzioni chiamate di sistema per manipolare file che accettano un descrittore di file come argomento. Ecco un esempio canonico con le operazioni read() e write():

// C-ish pseudocode

int fd = open("/path/to/my/file", ...);

char buffer[2048];
read(fd, buffer, 2048);
write(fd, "some data", 10);

close(fd);

Poiché anche la comunicazione di rete è una forma di I/O, sarebbe ragionevole aspettarsi anche che si riduca a manipolazione di file. E in effetti, esiste un tipo speciale di file per questo tipo di I/O, chiamato socket.

Un socket è un altro pezzo di astrazione fornito dal sistema operativo. Come spesso accade con le astrazioni del computer, il concetto è stato preso in prestito dal mondo reale, in particolare dalle prese di alimentazione CA, quelle attaccate al muro in cui inseriamo la spina del frigo o della TV. Una coppia di socket consente a due processi di dialogare tra loro. In particolare, in rete. Un socket può essere aperto, i dati possono essere scritti sul socket o letti da esso. E, naturalmente, quando la presa non è più necessaria, ci si dovrebbe scollegare.

ac-power-socket-opt
ac-power-socket-opt

Di socket ce ne sono di parecchi tipi diversi e ci sono molti modi per usarli per la comunicazione tra processi. Ad esempio, i socket di rete possono essere utilizzati quando due processi risiedono su macchine diverse. Per i processi locali, i socket di dominio Unix possono essere una scelta migliore. Ma anche questi due tipi di socket possono essere di tipo diverso: datagram (o UDP), stream (o TCP), raw socket, ecc. Questa varietà può sembrare complicata all’inizio, ma fortunatamente esiste un approccio più o meno generico su come utilizzare socket di qualsiasi tipo nel codice. Imparare a programmare uno di questi tipi di socket ti darà la possibilità di estendere la conoscenza ad altri tipi.

// C-ish pseudocode again

int fd = socket(SOCK_TYPE_TCP);

sockaddr serv_addr = { /* ... */ };
connect(fd, serv_addr);

char buffer[2048];
read(fd, buffer, 2048);
write(fd, "some data", 10);

close(fd);

Se conforntiamo i due pseudocodici ci accorgiamo che facciamo delle operazioni simili: nel primo caso (I/O su disco) apriamo un file, nel secondo (I/O su rete) ci connettiamo ad un socket (“infiliamo una spina”).

Più avanti in questo articolo, ci concentreremo su una forma di comunicazione client-server tramite socket di rete utilizzando lo stack di protocolli TCP/IP. Apparentemente, questa è la forma più utilizzata al giorno d’oggi, in particolare perché i browser la utilizzano per accedere ai siti web.

Come i programmi comunicano in rete

Immagina che ci sia un’applicazione che vuole inviare un pezzo di testo relativamente lungo sulla rete. Supponiamo che il socket sia già stato aperto e che il programma stia per scriverewrite – (o, nel gergo della rete, inviaresend) questi dati al socket. Come verranno trasmessi questi dati?

I computer vivono in un mondo discreto. Le schede di interfaccia di rete (NIC) trasmettono i dati in piccole porzioni, poche centinaia di byte contemporaneamente. Allo stesso tempo, in generale, la dimensione dei dati che un programma può voler inviare non è comunque limitata e può superare le centinaia di gigabyte. Per trasmettere un pezzo di dati arbitrario di grandi dimensioni sulla rete, è necessario che venga suddiviso in blocchi (chunked) e ogni blocco deve essere inviato separatamente. Logicamente, la dimensione massima del blocco non deve superare la limitazione della scheda di rete.

chunking-opt
chunking-opt

Ogni blocco è composto da due parti: le informazioni di controllo e l’informazione vera e propria (payload). Le informazioni di controllo includono gli indirizzi di origine e di destinazione, la dimensione del blocco, un checksum, ecc. Mentre il payload è… be’, i dati effettivi che il programma vuole inviare. È la vera merce che i computer si scambiano, ad esempio il contenuto di un singolo post di Twitter o di una intera pagina web.

Il più delle volte, per indirizzare i computer nella rete, vengono assegnati i cosiddetti indirizzi IP. L’acronimo IP sta per Internet Protocol, un famoso protocollo che ha reso possibile l’interconnessione delle reti (networks) dando vita a Internet. Il protocollo Internet è principalmente responsabile di 3 cose:

  • indirizzare le interfacce degli host (dei computer);
  • incapsulare i dati del payload in pacchetti (cioè il suddetto chunking);
  • instradare i pacchetti da una sorgente a una destinazione attraverso una o più reti IP.

Sul problema del chuncking: la semplificazione che adotto è non ortodossa ma ai fini della comprensione non è fuorviante (disclaimer).

IP è un cosiddetto protocollo Layer 3 della suite di protocolli Internet. I protocolli della suite formano uno stack (una pila) in cui ogni protocollo di livello superiore si basa su quello sottostante. Cioè, nel caso di IP, dovrebbe esserci un protocollo Layer ad un livello inferiore 2, o Link layer o livello collegamento (es. Ethernet o, in parole povere, Wi-Fi). I protocolli di livello collegamento si concentrano sui dettagli di trasmissione dei dati di livello inferiore e il loro ambito è limitato dalla comunicazione della rete locale (LAN) (ovvero nessuna consapevolezza del routing). La verità è che il chunking, (o framing nel gergo di rete), avviene anche a quel livello. Poiché IP è consapevole di tale limitazione, rende i suoi pacchetti abbastanza piccoli da poter essere inseriti nei frame di livello 2 perché, alla fine, l’unità di trasmissione sarà un frame, non tutto il pacchetto IP stesso. Sebbene importanti, questi dettagli sono comunque piuttosto irrilevanti per questo articolo.


Nel suo percorso dall’origine alla destinazione, un pacchetto IP di solito passa una manciata di host intermedi. Questa serie di host costituisce un percorso. Potrebbe esserci (e di solito c’è) più di un percorso per una coppia arbitraria (origine, destinazione). E poiché sono possibili più percorsi contemporaneamente, va benissimo che i pacchetti IP con la stessa coppia (origine, destinazione) prendano percorsi diversi. Tornando al problema dell’invio di un lungo pezzo di testo sulla rete, può succedere che i blocchi, ovvero i pacchetti IP su cui il testo è stato suddiviso, impiegheranno percorsi diversi verso l’host di destinazione. Tuttavia, percorsi diversi possono avere ritardi diversi. Inoltre, c’è sempre una probabilità di perdita di pacchetti perché né gli host intermedi né i collegamenti sono completamente affidabili. Pertanto, i pacchetti IP possono arrivare alla destinazione in un ordine alterato.

reassembly-opt
reassembly-opt

In generale, non tutti i casi d’uso richiedono un ordinamento rigoroso dei pacchetti. Ad esempio, il traffico voce e video è progettato per tollerare una certa quantità di perdita di pacchetti (perché la loro mancanza rappresenta un difetto pressoché inintelligibile dal fruitore: è probabile che non se ne accorga nemmeno o il disturbo sia piccolo e di brevissima durata: un burst o una fugace pixellatura anomala, per esempio) mentre la ritrasmissione dei pacchetti comporterebbe un aumento inaccettabile della latenza (e rappresenterebbe un disturbo ben più importante).

Tuttavia, quando un browser carica una pagina Web utilizzando HTTP (si osservi che parliamo di file di dimensioni molto inferiori rispetto ad uno stream audio/video), ci aspettiamo che le lettere e le parole su di essa vengano ordinate esattamente nello stesso modo in cui sono state pensate dal creatore della pagina. È così che nasce la necessità di un meccanismo di consegna dei pacchetti affidabile, ordinato e controllato dagli errori.

Come probabilmente avrai già notato, i problemi nel dominio di rete tendono a essere risolti introducendo sempre più protocolli. E in effetti, esiste un altro famoso protocollo Internet chiamato Transmission Control Protocol o semplicemente TCP.

TCP si basa sul suo protocollo sottostante, IP. L’obiettivo principale di TCP è fornire la consegna affidabile e ordinata di un flusso di byte tra le applicazioni. Pertanto, se inviamo il nostro testo (codificato) a un socket TCP su una macchina, può essere letto inalterato dal socket sulla macchina di destinazione. Per non preoccuparsi dei problemi di consegna dei pacchetti, HTTP si basa sulle capacità di TCP.

ip-tcp-payload-opt
ip-tcp-payload-opt

Per ottenere una consegna in ordine e affidabile, TCP aumenta le informazioni di controllo di ogni blocco con il numero di sequenza di incremento automatico e il checksum. Dal lato ricevente, il riassemblaggio dei dati avviene in base non all’ordine di arrivo dei pacchetti, ma al numero di sequenza TCP. Inoltre, il checksum viene utilizzato per convalidare il contenuto dei blocchi in arrivo. I blocchi non corretti vengono semplicemente rifiutati e non riconosciuti. Il lato mittente dovrebbe ritrasmettere i blocchi che non sono stati riconosciuti. Ovviamente, per implementarlo è necessaria una sorta di buffering da entrambe le parti.

Su una singola macchina alla volta possono esserci molti processi che comunicano tramite socket TCP. Pertanto, dovrebbero esserci tanti numeri di sequenza e buffer indipendenti quante sono le sessioni di comunicazione. Per risolvere questo problema, TCP introduce il concetto di connessione. Semplificando un po’, una connessione TCP è una sorta di accordo tra la parte trasmittente e ricevente sui numeri di sequenza iniziali e lo stato corrente della trasmissione. È necessario stabilire una connessione (scambiando alcuni pacchetti di controllo all’inizio, il cosiddetto handshake), mantenerla viva (alcuni pacchetti devono essere inviati in entrambe le direzioni, altrimenti la connessione potrebbe scadere, il cosiddetto keepalive) e quando la connessione non serve più, va chiusa (scambiando qualche altro pacchetto di controllo).

Ultimo ma non meno importante… Un indirizzo IP definisce un host di rete nel suo insieme. Tuttavia, tra due host qualsiasi, potrebbero esserci molte connessioni TCP simultanee. Se le uniche informazioni di indirizzamento nei nostri blocchi fossero gli indirizzi IP, sarebbe praticamente impossibile determinare l’affiliazione dei blocchi con le connessioni. Pertanto, sono necessarie alcune informazioni di indirizzamento aggiuntive. Per questo, TCP introduce il concetto di porte. Ogni connessione ottiene una coppia di numeri di porta (uno per il mittente, uno per il destinatario) che identifica in modo univoco la connessione TCP tra coppie di IP. Quindi, qualsiasi connessione TCP può essere completamente identificata dalla seguente tupla: (IP di origine, porta di origine, IP di destinazione, porta di destinazione).

Implementazione di un semplice server TCP

È ora di esercitarsi! Proviamo a creare il nostro piccolo server TCP in Python. Per questo, avremo bisogno del modulo socket dalla libreria standard.

Per un novizio, la principale complicazione con i socket è l’esistenza di un rituale apparentemente magico di preparare i socket per funzionare. Tuttavia, combinare il background teorico dall’inizio di questo articolo con la parte pratica di questa sezione dovrebbe trasformare la magia in una sequenza di azioni significative.

Nel caso di TCP, i flussi di lavoro del socket lato server e lato client sono diversi. Un server attende passivamente la connessione dei client. A priori, l’indirizzo IP e la porta TCP del server sono noti a tutti i suoi potenziali client. Al contrario, il server non conosce gli indirizzi dei suoi client fino al momento in cui questi si connettono. Vale a dire, i client svolgono il ruolo di iniziatori della comunicazione connettendosi attivamente ai server.

Tuttavia, c’è di più oltre a questo. Sul lato server, ci sono in realtà due tipi di socket coinvolti: il suddetto socket del server in attesa di connessioni e, sorpresa, sorpresa – i socket client! Per ogni connessione stabilita, c’è un altro socket creato sul lato server, simmetrico alla sua controparte lato client. Pertanto, per N client connessi, ci saranno sempre N+1 socket sul lato server.

Creare i socket TCP del server

Quindi, creiamo un socket del server:

# python3

import socket

serv_sock = socket.socket(
    socket.AF_INET,      # set protocol family to 'Internet' (INET)
    socket.SOCK_STREAM,  # set socket type to 'stream' (i.e. TCP)
    proto=0              # set the default protocol (for TCP it's IP)
)

print(type(serv_sock))   # <class 'socket.socket'>

“Aspetta un attimo, ma… dov’è l’ int fd = open("/path/to/my/socket") che mi avevi promesso?”

La verità è che la chiamata di sistema open() è troppo limitata per il caso d’uso del socket perché non consente di passare tutti i parametri necessari, come famiglia di protocollo, tipo di socket, ecc. Pertanto, per i socket, è stato introdotta una chiamata di sistema dedicata socket(). Analogamente a open(), dopo aver creato un terminale per la comunicazione, socket() restituisce un descrittore di file che fa riferimento a tale endpoint. Per quanto riguarda parte mancante fd = ..., Python è un linguaggio orientato agli oggetti. Invece di funzioni, tende a utilizzare classi e metodi. Il modulo socket della libreria standard di Python (che abbiamo usato nel primo esempio sopra) è in realtà un OO-wrapper leggero attorno all’insieme di chiamate relative ai socket. Semplificando drasticamente, può essere pensata come qualcosa del genere:

class socket:  # Yep, the name of the class starts from a lowercase letter...
               # NDR: implementazione-fantoccio della libreria di sistsma.
    def __init__(self, sock_family, sock_type, proto):
        self._fd = system_socket(sock_family, sock_type, proto)

    def write(self, data):
        system_write(self._fd, data)

    def fileno(self):
        return self._fd

Cioè, se qualcuno ne avesse davvero bisogno, può ottenere il descrittore del file (un numero intero) come segue:

print(serv_sock.fileno())  # 3 or some other small integer

Associare il socket del server all’interfaccia di rete

Poiché, in generale, una singola macchina server può avere più di una scheda di rete, dovrebbe esserci un modo per associare il socket del server a una particolare interfaccia assegnando un indirizzo locale di questa interfaccia al socket:

# Use '127.0.0.1' to bind to localhost
# Use '0.0.0.0' or '' to bind to ALL network interfaces simultaneously
# Use an actual IP of an interface to bind to a specific address.

serv_sock.bind(('127.0.0.1', 6543))

Inoltre, bind() richiede che venga specificata una porta. Il server attenderà o, nel gergo della rete, ascolterà le connessioni client su quella porta.

Dopo questa chiamata, il sistema operativo rende il socket del server pronto per accettare le connessioni in entrata. Tuttavia, il nostro codice non è ancora pronto per questo. Ma prima, tocchiamo brevemente la parte del backlog.

Quando più client si connettono al server, il server conserva le richieste in arrivo in una coda, una struttura dati di tipo FIFO. In parole semplici, il parametro backlog specifica il numero di connessioni in sospeso che la coda di connessioni conterrà.

Come già sappiamo, la comunicazione di rete avviene tramite l’invio di pacchetti. Allo stesso tempo, TCP chiede di stabilire delle connessioni. Quindi, per stabilire una connessione TCP, un client e un server devono scambiare alcuni pacchetti di controllo (cioè senza dati effettivi) (il cosiddetto handshake). E a causa dei ritardi della rete, non è una procedura istantanea.

Di solito, TCP è implementato a livello di sistema operativo e nei nostri programmi non ci occupiamo dei dettagli di livello inferiore, come l’handshake. I parametri di backlog definiscono la dimensione della coda delle connessioni stabilite. Fino a quando il numero di client connessi non eccederà la dimensione del backlog, il sistema operativo stabilirà nuove connessioni e le metterà in coda. Tuttavia, quando il numero della connessione stabilita raggiunge la dimensione del backlog, qualsiasi nuova connessione verrà esplicitamente rifiutata o implicitamente ignorata (dipende dalle configurazioni del sistema operativo). Un sito sotto attacco dDOS di solito è congegnato per portare la coda di connessioni a questo stato.

L’accettazione di connessioni client

Per prelevare una connessione dalla coda del backlog, dobbiamo fare quanto segue:

client_sock, client_addr = serv_sock.accept()

Tuttavia, la coda delle connessioni stabilite potrebbe essere vuota. In tal caso, la chiamata accept() bloccherà l’esecuzione del programma fino a quando il client successivo non si connette (o il programma viene interrotto da un segnale, ma è fuori tema per questo articolo).

Dopo aver accettato la prima connessione client, ci saranno due socket lato server: il già familiare serv_sock nello stato LISTEN e il nuovo client_sock nello stato ESTABLISHED. È interessante notare che il client_sock sul lato server e il socket corrispondente sul lato client sono i cosiddetti endpoint peer (terminali accoppiati). Cioè sono dello stesso tipo, i dati possono essere scritti o letti da ognuno di essi ed entrambi possono essere chiusi usando la chiamata close() che termina in modo efficiente la connessione. Nessuna di queste azioni influenzerà comunque il serv_socket in ascolto.

Ottenere l’indirizzo IP e la porta del socket client

Diamo un’occhiata agli indirizzi degli endpoint peer del server e del client. Ogni socket TCP può essere identificato da due coppie di numeri: (IP locale, porta locale) e (IP remoto, porta remota).

Per conoscere l’IP remoto e la porta del client appena connesso, il server può ispezionare la variabile client_addr restituita dalla chiamata (qualora sia effettuata con successo) accept():

print(client_addr)  # E.g. ('127.0.0.1', 54614)

In alternativa, il metodo socket.getpeername() dell’endpoint lato server client_sock può essere utilizzato per apprendere l’indirizzo remoto del client connesso. E per conoscere l’indirizzo locale assegnato dal sistema operativo del server per l’endpoint peer lato server, è possibile utilizzare il metodo socket.getsockname().

Nel caso del nostro server potrebbe assomigliare a questo:

serv_sock:
  laddr (ip=<server_ip>, port=6543)
  raddr (ip=0.0.0.0, port=*)

client_sock:  # peer
  laddr (ip=<client_ip>, port=51573)  # 51573 is a random port assigned by the OS
  raddr (ip=<server_ip>, port=6543)   # it's a server's listening port

Inviare e ricevere dati tramite socket

Ecco un semplice esempio di ricezione di alcuni dati dal client e poi di invio (il cosiddetto echo-server):

# echo-server

data = client_sock.recv(2048)
client_sock.send(data)

Bene, dove sono le chiamate read() e write() promesse? Sebbene sia possibile utilizzare queste due chiamate con descrittori di file socket, come con la chiamata di sistema socket(), esse non consentono di specificare tutte le potenziali opzioni necessarie. Pertanto, per i socket, sono state introdotte le chiamate di sistema send() e recv(). Nel manale Unix man 2 send si legge:

The only difference between send() and write() is the presence of flags. With a zero flags argument, send() is equivalent to write().

e su man 2 recv si legge

The only difference between recv() and read() is the presence of flags. With a zero flags argument, recv() is generally equivalent to read().

Dietro l’apparente semplicità del frammento di cui sopra c’è un problema serio. Entrambe le chiamate recv() e send() funzionano effettivamente attraverso i cosiddetti buffer di rete. La chiamata a recv() ritorna non appena alcuni dati appaiono nel buffer sul lato ricevente. E, naturalmente, alcuni raramente significa tutti. Pertanto, se il client desidera trasmettere, diciamo 1800 byte di dati, recv() può restituire non appena vengono ricevuti i primi 1500 byte (i numeri sono arbitrari in questo esempio) perché la trasmissione è stata suddivisa in due parti.

Lo stesso vale per il metodo send(). Restituisce il numero effettivo di byte che sono stati scritti nel buffer. Tuttavia, se il buffer ha meno spazio disponibile rispetto al dato tentato, ne verrà scritta solo una parte. Quindi, spetta al mittente assicurarsi che il resto dei dati venga eventualmente trasmesso. Fortunatamente, Python fornisce un pratico helper socket.sendall() che fa il ciclo di invio per te sotto il cofano.

Questo in realtà porta a considerazioni interessanti quando si tratta di progettare lo scambio di dati su TCP:

i messaggi devono essere di lunghezza fissa (yuck) o essere delimitati (shrug) o indicare quanto sono lunghi (molto meglio) o terminare interrompendo la connessione.

Rilevare quando il client ha terminato di inviare (shutdown)

Si noti che le prime tre opzioni possono comunque portare a una situazione in cui il socket sul lato server attenderà che la chiamata a recv() restituisca il suo valore per un tempo indefinito. Può succedere se il server desidera ricevere K messaggi dal client mentre il client desidera inviare solo M messaggi, dove M < K. Pertanto, spetta ai progettisti di protocollo di livello superiore decidere le regole di comunicazione.

Tuttavia, esiste un modo semplice per indicare che il client ha terminato l’invio. Il socket client può eseguire uno shutdown(how) della connessione specificando per il parametro how il valore SHUT_WR. Ciò porterà a una chiamata recv() sul lato server che restituisce 0 byte. Pertanto, possiamo riscrivere il codice ricevente come segue:

chunks = []
while True:
    data = client_sock.recv(2048)
    if not data:
        break
    chunks.append(data)

Chiudere i socket

Quando si è finito con un socket, bisogna chiuderlo:

socket.close()

La chiusura esplicita di un socket comporterà lo svuotamento dei suoi buffer e la chiusura regolare della connessione.

Semplice esempio di server TCP

Infine, ecco il codice completo dell’echo-server TCP:

# python3

import socket

# Create server socket.
serv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0)

# Bind server socket to loopback network interface.
serv_sock.bind(('127.0.0.1', 6543))

# Turn server socket into listening mode.
serv_sock.listen(10)

while True:
    # Accept new connections in an infinite loop.
    client_sock, client_addr = serv_sock.accept()
    print('New connection from ', client_addr)

    chunks = []
    while True:
        # Keep reading while the client is writing.
        data = client_sock.recv(2048)
        if not data:
            # Client is done with sending.
            break
        chunks.append(data)
    print('Received data: ', chunks)
    client_sock.sendall(b''.join(chunks))
    client_sock.close()

Salvatelo su server.py ed eseguitelo tramite python3 server.py

Nota di traduzione

Ho leggermente modificato i sorgenti per rendere più interattiva la comunicazione client-server.

Semplice implementazione del client TCP.

Le cose sono molto più semplici dal lato client. Non esiste una cosa come un socket di ascolto sul lato client. Dobbiamo solo creare un singolo endpoint socket e collegarlo connect() al server prima di inviare alcuni dati:

# python3

import socket

# Create client socket.
client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# Connect to server (replace 127.0.0.1 with the real server IP).
client_sock.connect(('127.0.0.1', 6543))

# Send some data to server.
str = input('Please, send some message to your mate: ')
client_sock.sendall(str.encode('ascii'))
client_sock.shutdown(socket.SHUT_WR)

# Receive some data back.
chunks = []
while True:
    data = client_sock.recv(2048)
    if not data:
        break
    chunks.append(data)
print('Received back ', repr(b''.join(chunks)))

# Disconnect from server.
client_sock.close()

Salvatelo su client.py ed eseguitelo tramite python3 client.py.

Test (aggiunta mia alla traduzione)

Mettiamo il server in ascolto:

$ python3 server.py
<in attesa>

Apriamo una seconda console (Ctrl+T su Ubuntu) e digitiamo

$ python3 client.py 
Please, send some message to your mate: Ciao Ivan
Received back  b'Ciao Ivan'

Torniamo nella console del server e vedremo:

New connection from ('127.0.0.1', 55484)
[b'Ciao Ivan']

Nota che se spediamo un secondo messaggio con il client, ancorché la porta del client è sempre 6543, la porta del server sarà diversa:

Client

$ python3 client.py 
Please, send some message to your mate: Ciao Marco              
Received back  b'Ciao Marco'

Server:

New connection from ('127.0.0.1', 55488)
[b'Ciao Marco']

Per curiosità mi faccio stampare tutta la struttura dati del socket, ed è molto istruttivo:

$ python3 server.py 
New connection from  ('127.0.0.1', 55492)
Socket  <socket.socket fd=4, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 6543), raddr=('127.0.0.1', 55492)>
Received data:  [b'ciao Mamma']

Come si vede, il numero di porta è stato incrementato di 4 unità, en passant il file descriptor è 4 e il left address è il client (‘127.0.0.1’, 6543) ed il right address è il server (‘127.0.0.1’, 55492).

È istruttivo anche vedere con nestat -at che c’è un server TCP in ascolto sulla porta 6543:

$ netstat -at
Connessioni Internet attive (server e stabiliti)
Proto CodaRic CodaInv Indirizzo locale        Indirizzo remoto       Stato     
.........
tcp        0      0 192.168.122.1:domain    0.0.0.0:*               LISTEN     
tcp        0      0 localhost:6543          0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:11211           0.0.0.0:*               LISTEN  
.........

Socket server vs HTTP server

Il server che abbiamo implementato sopra è chiaramente un semplice server TCP. Tuttavia, non è (ancora) un server web. Mentre (quasi?) ogni server web è un server TCP, ovviamente non tutti i server TCP sono un server web. Per trasformare questo server in un server web, dovremmo insegnargli come gestire HTTP. Cioè. i dati trasmessi tramite socket dovrebbero essere formattati secondo l’insieme di regole definite dall’Hypertext Transfer Protocol e il nostro codice dovrebbe saperli analizzare.

In conclusione

Memorizzare le cose senza capirle è una strategia scadente per uno sviluppatore (per ogni professione, aggiungerei, NdR). La programmazione di socket è un esempio perfetto di quanto leggere il codice senza il background teorico possa essere veramente frustrante. Tuttavia, una volta acquisita la comprensione delle parti mobili e dei vincoli, tutte queste manipolazioni magiche con l’API del socket si trasformano in un insieme di azioni che hanno un senso. E tu, non aver paura di dedicare tempo alle basi. La programmazione di rete è una conoscenza fondamentale che è vitale per lo sviluppo e la risoluzione dei problemi di successo di servizi Web avanzati.

Approfondimenti

Bibliografia

  • Articolo originale in inglese scritto da Ivan Velichko
  • Il profilo Twitter di Ivan: @iximiuz

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.

Questo sito usa Akismet per ridurre lo spam. Scopri come i tuoi dati vengono elaborati.