MokaByte 99 - 7mbre 2005
 
MokaByte 99 - 7mbre 2005 Prima pagina Cerca Home Page

 

 

 

Il Networking in Java
II parte: la programmazione il TCP ed i socket

Il mese scorso sono stati introdotti i concetti che stanno alla base della programmazione di rete in generale, quindi, attraverso le classi URL e URLConnection, è stato creato un semplice programma di rete basato su protocollo HTTP. Questo mese verranno illustrati i concetti alla base della programmazione TCP basata su socket e le classi necessarie a creare applicazioni di rete. Infine verrà mostrato come sia semplice, in Java, creare vere e proprie applicazioni client-server.

Soket e TCP
Le classi URL e URLConnection offrono un meccanismo di alto livello per l'accesso a risorse Internet, perlopiù orientato al protocollo applicativo HTTP. Nella programmazione di rete capita abbastanza spesso di voler realizzare applicazioni proprietarie basate su protocolli applicativi differenti. In questo caso è necessario appoggiarsi direttamente sul protocollo TCP, già descritto per sommi capi il mese scorso, attraverso le socket, uno strumento di comunicazione molto versatile presente oramai su qualunque piattaforma. Prima di descrivere le basi della programmazione basata su soket è necessario introdurre il paradigma client-server, su cui le applicazioni basate su socket sono fondate.
Il paradigma client-server
Il paradigma client-server è oramai diventato sinonimo di programmazione di rete. In realtà si tratta di un'architettura applicativa più generica in cui è possibile riscontrare un fornitore di servizi, il server appunto, e un fruitore di tali servizi, che prende il nome di client.
Nella programmazione di rete, client e server comunicano attraverso la rete grazie alle socket e al protocollo TCP, che fornisce un meccanismo di comunicazione punto a punto affidabile basato sugli stream. L'applicazione client-server più familiare è il web, in cui esistono server HTTP che offrono l'accesso remoto ad una serie di documenti HTML. In questo caso il client è un browser web, che si connette al server per scaricare una pagina. Altri esempi sono FTP, in cui il client può accedere ad un file system remoto presente su un apposito server ed effettuare le consuete operazioni di lettura e scrittura.

 

Socket
Una socket è il punto di contatto di una comunicazione a due vie tra due programmi che girano su computer di rete. Sul lato server, la socket è legata (bound) ad una porta predefinita, in modo da permettere al client di identificare l'applicazione con cui connettersi. In inglese il termine socket denota le prese di corrente: la metafora di riferimento è esattamente il servizio di erogazione della corrente elettrica, a cui ci si allaccia attraverso un'apposita spina.
In un'applicazione client-server basata su socket, il server resta in attesa di una connessione su una determinate porta. Quando un client si connette, il server accetta la connessione mediante un'apposita direttiva di accept(), che connette la socket del client ad una porta libera, differente da quella di ascolto che rimane disponibile per altre connessioni. Solitamente infatti, i programmi server sono concorrenti, in modo da poter servire più di un client per volta. Sul lato client , quando la connessione viene accettata, viene creata una socket che il client può usare per comunicare con il server.
Dal momento che il protocollo TCP è un protocollo standard di Internet, non è necessario che entrambi i programmi siano scritti nello stesso linguaggio o girino sulla stessa piattaforma: spesso è anzi vero il contrario
La comunicazione tra client e server avviene mediante scambio di messaggi basati su un protocollo, binario o testuale, definito dall'utente. Lo scambio di messaggi avviene attraverso una coppia di stream del tutto equivalenti a quelli di I/O già studiati nei capitoli precedenti.
Le classi URL e URLConnection sono ovviamente basate su socket, anche se la cosa non è visibile al programmatore.

 

La classe InetAddress
La classe InetAddress incapsula un indirizzo internet, sia esso un indirizzo IP standard o un nuovo indirizzo IPv6. Questa classe non dispone di costruttori, ma unicamente di metodi factory statici che permettono di creare un InetAddress a partire da un nome simbolico o da altri parametri. Per quanto concerne gli scopi del presente trattato, l'unico metodo interessante è il seguente:

static InetAddress getByName(String host) throws UnknownHostException

Questo metodo effettua il lookup del nome simbolico su DNS e restituisce un opportuno InetAddress; se l'host non viene trovato, il metodo genera una UnknownHostException. La classe dispone anche di numerosi metodi di interrogazione, che possono essere trovati nella documentazione.

 

La classe ServerSocket
Il package java.net dispone di una coppia di classi, Socket e ServerSocket, che implementano i due lati della connessione tra un programma Java e un altro programma presente in rete in modo indipendente dalla piattaforma. La classe ServerSocket dispone dei seguenti costruttori:

ServerSocket(int port) throws IOException
ServerSocket(int port, int backlog) throws IOException

Il primo di questi costruttori richiede come argomento unicamente il numero di porta su cui ascoltare la connessione, mentre il secondo permette di specificare anche la lunghezza massima della coda in cui vengono messi in attesa i client che tentano di connettersi nel frattempo. Come di consueto nelle classi di I/O, se qualcosa non va viene lanciata una IOException. Un altro costruttore permette di specificare un indirizzo IP locale, nel caso la macchina server disponga di più schede di rete:

ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException

Tra i vari metodi della classe è sufficiente conoscerne due:

Socket accept() throws IOException
void close()throws IOException

Il primo è un metodo bloccante, che mette la socket in attesa sulla porta di rendez-vous fino a quando un client prova a connettersi; quando questo avviene, viene restituita una socket collegata al client; il secondo è il classico metodo close(), che permette di chiudere l'attività di una socket. Entrambi i metodi possono generare una IOException.

 

La classe Socket
La classe Socket implementa una socket lato client. I costruttori richiedono la specifica di un host, in forma simbolica o come oggetto InetAddress, e un numero di porta:

Socket(InetAddress address, int port) throws IOException
Socket(String host, int port) throws IOException

Come di consueto, se la connessione non andasse a buon fine viene generata una IOException. Altri costruttori permettono di gestire le situazioni in cui la macchina dispone di più di una scheda di rete, e quindi di indirizzi differenti:

Socket(InetAddress address, int port, InetAddress localAddr, int localPort) throws IOException
Socket(String host, int port, InetAddress localAddr, int localPort) throws IOException

I metodi più importanti di questa classe sono quelli che permettono di aprire gli stream di input e output con l'host remoto:

InputStream getInputStream() throws IOException
OutputStream getOutputStream() throws IOException

Infine il classico metodo close(), da invocare al termine della sessione, dopo aver chiuso gli stream di I/O:

void close() throws IOException

Tutti i metodi presentatiu possono generare una IOException in caso di problemi.

 

Programma Client-Server
Un aspetto sorprendente di Java è la possibilità di realizzare con poche righe un completo programma client-server basato su socket. La classe ReverseServer implementa un servizio non molto utile sul piano pratico, ma molto interessante sul piano didattico:

import java.io.*;
import java.net.*;

public class ReverseServer {
public static void main(String argv[]) throws IOException {
ServerSocket server = new ServerSocket(5000);
while ( true )
try {
System.out.println("In attesa di connessione...");
Socket client = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(new OutputStreamWriter(client.getOutputStream()));
while ( true ) {
String s = in.readLine();
if ( s == null )
break;
else {
System.out.println(client + ". Messaggio: " + s);
StringBuffer reverse = new StringBuffer(s).reverse();
out.println(reverse);
out.flush();
}
}
in.close();
out.close();
client.close();
}
catch (IOException ioe) {
ioe.printStackTrace();
}
}
}

Come prima cosa viene creata una socket server legata alla porta 5000: se a questo livello venisse generata una IOException, la sua gestione viene delegata alla JVM. A questo punto viene avviato un ciclo while infinito: tipicamente un programma server gira a ciclo continuo, come in questo caso. All'interno del ciclo while viene aperto un blocco try-catch al cui interno vengono svolte tutte le operazioni relative alla gestione di un client: per prima cosa viene stampato un messaggio a console, quindi viene chiamata la accept(), che mette in attesa il server fino a quando non avviene un tentativo di connessione; se la connessione va a buon fine, la accept() genera la socket client. Seguono l'estrazione degli stream di Input/Output, che per comodità vengono concatenati ad appositi Reader e Writer, quindi inizia il ciclo while che costituisce il dialogo vero e proprio con il client. Il server legge una riga di testo dallo stream di input, verifica se è uguale a null (segno che la comunicazione è stata interrotta), e in caso contrario stampa su console le informazioni della socket client seguite dal messaggio ricevuto; quindi inverte la stringa ricevuta attraverso una chiamata all'apposito metodo di StringBuffer rispedisce al client questa versione rielaborata dell'input. La chiamata alla flush() è necessaria, dal momento che il sistema operativo, e in generale gli stream, tendono a trattenere i dati fino a quando non raggiungono un volume sufficiente a giustificarne la spedizione.

Quando il client chiude la connessione, l'istruzione break provoca l'uscita dal ciclo while, e l'esecuzione delle istruzioni che chiudono gli stream di I/O e la socket client. Se durante questa interazione qualcosa andasse storto, il blocco catch raccoglie la IOException, stampa il contenuto dello stack e comincia una nuova iterazione. Il programma server va lanciato prima del client con il seguente comando da console:

java ReverseServer

Il programma client è lievemente più semplice del programma server:

import java.io.*;
import java.net.*;

public class ReverseClient {
public static void main(String argv[]) {
if ( argv.length != 1 )
System.out.println("Usage: java ReverseClient <hostname>");
else
try {
Socket client = new Socket(argv[0], 5000);
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(new OutputStreamWriter(client.getOutputStream()));
BufferedReader lineReader = new BufferedReader(new InputStreamReader(System.in));
while ( true ) {
String line = lineReader.readLine();
if ( line.equals("!q") )
break;
else {
out.println(line);
out.flush();
System.out.println(in.readLine());
}
}
in.close();
out.close();
client.close();
}
catch (IOException ioe) {
ioe.printStackTrace();
}
}
}

In primo luogo viene effettuato un controllo sul numero dei parametri: esso infatti richiede, al momento dell'esecuzione, la specifica del nome dell'host su cui gira il programma server. Se il client viene eseguito sulla stessa macchina su cui è in esecuzione il programma server, sarà necessario specificare la stringa "localhost", che denota il dispositivo di loopback, ossia la connessione di una macchina con se stessa:

java ReverseClient localhost

Da notare che anche se client e server girano sulla stessa macchina, utilizzano due console differenti e di conseguenza due spazi di indirizzamento diversi. In breve, si tratta di una valida simulazione di una comunicazione client-server reale.

Dopo il controllo sul numero dei parametri viene aperto un blocco try-catch al cui interno vengono svolte le operazioni di connessione: dapprima viene creata la socket client, quindi vengono estratti gli stream di I/O; a questo punto viene aperto un BufferedReader connesso allo stream di input di sistema, ossia la console. In questo modo è possibile leggere ciò che l'utente scrive a console. A questo punto viene avviato un ciclo while infinito, al cui interno viene eseguita l'interazione con il server: dapprima viene letto quanto scritto dall'utente su console, quindi si verifica se questo corrisponde alla stringa di uscita "!q", in caso contrario la stringa viene inviata al server e la risposta viene stampata a console. Si noti anche in questo caso il ricorso alla flush(), per gli stessi motivi già spiegati per il server. Ecco una tipica interazione tra client e server:

C:\>java ReverseClient localhost
Scrivi qualcosa, poi premi invio:
ciao
oaic
Scrivi qualcosa, poi premi invio, oppure scrivi !q per uscire:
Tanto va la gatta al lardo che ci lascia lo zampino
onipmaz ol aicsal ic ehc odral la attag al av otnaT
Scrivi qualcosa, poi premi invio, oppure scrivi !q per uscire:
Rosso di sera, bel tempo si spera
areps is opmet leb ,ares id ossoR
Scrivi qualcosa, poi premi invio, oppure scrivi !q per uscire:
!q

C:\>

La console del server mostrerà un output simile al seguente:

C:\java -ea ReverseServer
In attesa di connessione...
Socket[addr=/127.0.0.1,port=1030,localport=5000]. Messaggio: ciao
Socket[addr=/127.0.0.1,port=1030,localport=5000]. Messaggio: Tanto va la gatta al lardo che ci lascia lo zampino
Socket[addr=/127.0.0.1,port=1030,localport=5000]. Messaggio: Rosso di sera, bel tempo si spera
In attesa di connessione...

Si tenti di lanciare due client contemporaneamente: il secondo resterà in attesa fino a quando il primo non ha terminato la sua interazione. Per servire più di un client per volta è necessario ricorrere alla programmazione concorrente, come verrà mostrato nel prossimo paragrafo.

 

Programma Client-Server concorrente
La versione concorrente del server si compone di due classi: la prima svolge il ruolo di server di rendez-vous:

import java.io.*;
import java.net.*;

public class MultithreadReverseServer {
public static void main(String argv[]) throws IOException {
System.out.println("In attesa di connessione...");
ServerSocket server = new ServerSocket(5000);
while ( true ) {
try {
Socket client = server.accept();
Thread t = new Thread(new ThreadServer(client));
t.start();
}
catch (IOException ioe) {
ioe.printStackTrace();
}
}
}
}

In questa prima classe viene creata la socket server, sulla porta 5000. Quindi, all'interno del solito ciclo while infinito, il server attende una connessione client attraverso la accept(), quindi passa la socket appena ottenuta ad un'apposita classe ThreadServer, che lavora su un thread separato: dopo l'avvio, mediante l'apposito metodo start(), il server di rendez-vous si rimette in attesa per un'altra connessione. La classe ThreadServer, realizzazione dell'interfaccia Run-nable, gestisce l'interazione con un client in modo indipendente dal server principale:

import java.io.*;
import java.net.*;

public class ThreadServer implements Runnable {
private Socket client;

public ThreadServer(Socket client) {
this.client = client;
}

public void run() {
while ( true ) {
try {
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(new OutputStreamWriter(client.getOutputStream()));
while ( true ) {
String line = in.readLine();
if ( line == null )
break;
else {
System.out.println(client + ". Messaggio: " + line);
StringBuffer reverse = new StringBuffer(line).reverse();
out.println(reverse);
out.flush();
}
}
in.close();
out.close();
}
catch (IOException ioe) {
ioe.printStackTrace();
break;
}
}
try {
client.close();
}
catch (IOException ioe) {
ioe.printStackTrace();
}
}
}

La classe ThreadServer contiene codice equivalente a quello del ciclo più interno della classe ReverseServer, ossia il ciclo che gestisce l'interazione con un particolare client. Quando la connessione cade o viene chiusa, il metodo run() chiude gli stream e la socket e termina la sua esecuzione. Questo server accetta connessioni dallo stesso client visto in precedenza, tuttavia, a differenza di quanto avveniva con il server precedente, MultithreadReverseServer è in grado di gestire un numero indefinito di connessioni client contemporaneamente, ciascuna su un thread separato. La struttura di questo server non è molto diversa da quella di un server concorrente che svolge compiti più complessi: si invita il lettore a modificare il presente codice al fine di creare programmi client-server più potenti e più utili. Chi volesse approfondire l'argomento, può trovare utili spunti in [1].
Conclusioni
Questo mese è stato introdotto il paradigma client-server, l'architettura su cui si fonda qualunque programma di rete. Quindi sono state introdotte le classi più importanti per la programmazione TCP: InetAddress, ServerSocket e Socket. Infine è stato dimostrato come sia semplice, in Java, realizzare programmi client-server basati su socket, sia a thread singolo che a thread multiplo.

Il mese prossimo verrà illustrata l'uso del protocollo UDP e dei datagrammi.

 

Bibliografia
[1] Elliotte Rusty Harold Java Network Programming O'Reilly