La comunicazione tra due o più computer è certamente una delle caratteristiche più affascinanti e stimolanti dell'informatica che ha sempre rivestito un ruolo di primaria importanza. Java offre una serie di classi semplici e, allo stesso tempo, complete per consentire la scrittura di applicazioni client-server dai più svariati utilizzi, attraverso l'utilizzo dei Socket.
Cosa sono i Socket
Ma che cos'è un Socket? Con tale termine (che letteralmente vuol dire "presa"), in generale, si definisce una rappresentazione a livello software utilizzata per interfacciare i due terminali (endpoint) in gioco in una connessione tra due computer. In altre parole, potremmo considerare i socket come delle prese (una per ogni macchina) che siano interconnesse tra loro attraverso un ipotetico cavo in cui passi il flusso di dati che i computer si scambiano.
Un esempio che rende bene l'idea è quello di pensare ai socket come alle prese telefoniche presenti ai due capi opposti durante una conversazione al telefono. Le due persone che colloquiano al telefono comunicano attraverso le rispettive prese. La conversazione, in tal caso, non finirà finché non verrà chiusa la cornetta e fino ad allora la linea resterà occupata.
I protocolli coinvolti nell'implementazione dei Socket sono, fondamentalmente, 2:
- TCP (Transfer Control Protocol)
- UDP (User Datagram Protocol)
In questo articolo faremo riferimento ai socket di tipo TCP. Per quanto riguarda i socket UDP, è utile sapere che sono implementati in Java attraverso l'uso della classe DatagramSocket
.
Per la comunicazione in rete, Java utilizza il modello a stream. Un socket può mantenere due tipi di stream: uno di input ed uno di output. Dal punto di vista software, ciò che avviene è che un processo invia dei dati ad un altro processo attraverso la rete, scrivendo sullo stream di output associato ad un socket. Un altro processo, accede ai dati scritti in precedenza leggendo dallo stream di input del socket stesso.
Per far sì che una tale comunicazione possa avvenire con successo, è necessario che uno dei due computer (il server) si metta in attesa di una chiamata mentre l'altro (il client) tenti di comunicare con il primo. Nella realtà, un server che sia in grado di comunicare con un solo client per volta potrebbe essere poco utile. Pertanto, utilizzando i vantaggi offerti dai thread (vedremo come) sarà possibile estendere tale scenario ed ottenere un server in grado di interagire contemporaneamente con più client.
Classi Java utilizzate
Il package Java che fornisce supporto per l'implementazione dei Socket è java.net
. In particolare, le classi che andremo ad esaminare sono:
URLConnection
Socket
ServerSocket
La classe URLConnection
URLConnection
è una classe astratta che rappresenta un punto di riferimento per le altre classi che gestiscono la comunicazione tra un'applicazione client ed una URL. Come è facilmente intuibile dal nome stesso, la classe URLConnection
rende agevole la lettura di documenti che si trovino su un Web server ma, nella pratica, viene utilizzata anche per effettuare operazioni di "scrittura" verso la risorsa identificata dalla URL in questione. Ad esempio, attraverso l'utilizzo di tale classe sarà possibile inviare ad una servlet, identificata da una particolare URL, un documento in formato XML.
La potenza della classe URLConnection
risiede nel fatto che essa consente la gestione del colloquio client-server ad alto livello, senza che il programmatore debba minimamente preoccuparsi dei dettagli sui socket che ne consentono l'utilizzo. Vediamo un esempio su come può essere utilizzata tale classe, simulando il comportamento di un comune browser che richieda ad un web server una particolare pagina html:
Listato 1. Simulare un client HTTP
import java.io.*;
import java.net.*;
public class URLClient
{
private String strURL;
public URLClient(String strURL)
{
this.strURL = strURL;
}
public String retrievePage ()
{
StringBuffer document = new StringBuffer();
try {
URL url = new URL(strURL);
URLConnection conn = url.openConnection();
BufferedReader reader = new BufferedReader(new
InputStreamReader(conn.getInputStream()));
String line = null;
while ((line = reader.readLine()) != null)
document.append(line + "n");
reader.close();
}
catch (MalformedURLException e) {
System.out.println("MalformedException durante la connessione);
}
catch (IOException e) {
System.out.println("IOException durante la connessione");
}
return document.toString();
}
public static void main(String[] args)
{
URLClient client = new URLClient("http://www.html.it");
String webPage = client.retrievePage();
try {
FileWriter out = new FileWriter ("htmlit.html");
out.write(webPage);
out.close();
}
catch (Exception ex) {
ex.printStackTrace();
}
}
}
Se si prova ad eseguire questo semplice programma di esempio e si apre successivamente sul proprio browser il file htmlit.html (creato dal programma stesso nella stessa directory in cui lo si è mandato in esecuzione) si potrà osservare che il risultato ottenuto altro non è che la pagina iniziale di HTML.IT.
Analizzando il codice precedente, si può notare come la classe URLClient
utilizzi un costruttore parametrizzato (che prende in input una URL) e definisca l'implementazione del metodo retrievePage()
. Tale metodo, in poche righe di codice, effettua la connessione al web server e ricava il contenuto di una pagina html (che nel nostro caso è, appunto, la pagina iniziale del sito www.html.it).
Il tutto è ottenuto semplicemente aprendo un socket verso la URL di destinazione, grazie all'istruzione url.openConnection()
e salvando il contenuto della pagina html (ricavata attraverso l'istruzione conn.getInputStream()
) su un oggetto di classe BufferedReader
.
La classe BufferedReader
è una classe cosiddetta wrapper che ci consente di andare a leggere il contenuto dello stream ricavato semplicemente utilizzando una serie di chiamate successive al metodo readLine()
(ovvero riga per riga).
Le basi della comunicazione Socket
Prima di vedere nel dettaglio come utilizzare le classi Socket e ServerSocket, è importante chiarire alcuni concetti fondamentali. Utilizziamo ancora l'esempio della chiamata telefonica per rendere più facili i concetti.
Per avviare una conversazione telefonica, si sa che è fondamentale che uno dei due interlocutori conosca il numero dell'altro. In particolare, se prendiamo spunto dai numeri telefonici delle grandi aziende, sappiamo che esiste un numero base che corrisponde, ad esempio, al centralino al quale è possibile accodare altre cifre per effettuare delle chiamate direttamente ad uffici specifici. Ovvero, vengono utilizzate delle estensioni al numero iniziale.
Se proviamo a riportare gli stessi concetti nella comunicazione in rete (e quindi sui socket), potremo dire che un computer (il client) dovrà conoscere l'indirizzo IP del computer remoto (il server) specificando, inoltre, una particolare estensione definita Numero di Porta (port number), che per semplicità viene spesso chiamata semplicemente porta.
I Port Numbers
Nel protocollo TCP/IP, i port numbers rappresentano numeri a 16 bit il cui valore può variare tra 0 e 65535. Nella pratica, però, non tutti questi valori possono essere utilizzati a proprio piacimento. Infatti, i numeri di porta compresi tra 0 e 1024 sono riservati a particolari servizi come telnet, ftp, SMTP, POP3 e HTTP e possono, pertanto, essere utilizzati soltanto in caso di interfacciamento con tali servizi. Esiste, in particolare, un'authority che ha il compito di assegnare queste porte a determinati servizi: la Internet Assigned Numbers Authority (IANA).
Le porte utilizzabili dagli utenti della rete sono quelle definite Registered Ports, i cui valori variano tra 1024 e 49151. Infine, sui port numbers compresi tra 49152 e 65535 non viene applicato alcun controllo. Esse sono definite private ports.
In una comunicazione client-server, è possibile definire la porta su cui il server si mette in ascolto mentre il client utilizzerà, in modo automatico, una porta scelta dal sistema operativo da quelle a disposizione. È chiaro che sia il client sia il server devono stabilire una sorta di accordo sulla porta da utilizzare per la comunicazione. Se il client proverà ad utilizzare l'indirizzo IP del server fornendo, però, una porta errata, non si stabilirà alcuna connessione tra le due macchine.
Le classi Socket e ServerSocket
Le classi Socket
e ServerSocket
sono le due classi che intervengono nell'implementazione di una comunicazione client-server che basata sui socket. La prima si occupa della gestione del client che effettua la chiamata mentre la seconda fornisce le funzionalità necessarie a creare un server che stia in ascolto su una particolare porta.
I passi necessari a stabilire una connessione possono essere così riassunti:
- Il server sceglie una porta su cui mettersi in ascolto. Quando il client tenterà di aprire una connessione, il server si limiterà a richiamare il metodo
accept()
della classeServerSocket
ottenendo, a sua volta, un riferimento al canale di comunicazione instaurato con il client. - Il client apre una connessione con il server costruendo un oggetto di tipo
Socket
a cui verranno passati due parametri: l'indirizzo del server e la porta su cui il server stesso è in ascolto. - Instaurata la connessione, le due macchine (server e client), potranno comunicare semplicemente avvalendosi del modello a stream messo a disposizione dalle classi
InputStream
eOutputStream
.
Vediamo un esempio pratico su come implementare un server ed un client che comunichino via socket:
Listato 2. Un Server TCP/IP
import java.net.*;
import java.io.*;
public class SimpleServer
{
private int port;
private ServerSocket server;
public SimpleServer (int port)
{
this.port = port;
if(!startServer())
System.err.println("Errore durante la creazione del Server");
}
private boolean startServer()
{
try
{
server = new ServerSocket(port);
}
catch (IOException ex)
{
ex.printStackTrace();
return false;
}
System.out.println("Server creato con successo!");
return true;
}
public void runServer()
{
while (true)
{
try
{
// Il server resta in attesa di una richiesta
System.out.println("Server in attesa di richieste...");
Socket s1 = server.accept();
System.out.println("Un client si e' connesso...");
// Ricava lo stream di output associate al socket
// e definisce una classe wrapper di tipo
// BufferedWriter per semplificare le operazioni
// di scrittura
OutputStream s1out = s1.getOutputStream();
BufferedWriter bw = new BufferedWriter(
new OutputStreamWriter(s1out));
// Il server invia la risposta al client
bw.write("Benvenuto sul server!n");
// Chiude lo strema di output e la connessione
bw.close();
s1.close();
System.out.println("Chiusura connessione effettuatan");
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
public static void main (String args[])
{
// Crea un oggetto di tipo SimpleServer in ascolto
// sulla porta 7777
SimpleServer ss = new SimpleServer(7777);
ss.runServer();
}
}
Il codice che segue, invece, implementa un semplice client TCP/IP:
Listato 3. Un client TCP/IP
import java.net.*;
import java.io.*;
public class SimpleClient
{
public static void main(String args[])
{
try
{
// Apre una connessione verso un server in ascolto
// sulla porta 7777. In questo caso utilizziamo localhost
// che corrisponde all'indirizzo IP 127.0.0.1
System.out.println("Apertura connessione...");
Socket s1 = new Socket ("127.0.0.1", 7777);
// Ricava lo stream di input dal socket s1
// ed utilizza un oggetto wrapper di classe BufferedReader
// per semplificare le operazioni di lettura
InputStream is = s1.getInputStream();
BufferedReader dis = new BufferedReader(
new InputStreamReader(is));
// Legge l'input e lo visualizza sullo schermo
System.out.println("Risposta del server: " + dis.readLine());
// Al termine, chiude lo stream di comunicazione e il socket.
dis.close();
s1.close();
System.out.println("Chiusura connessione effettuata");
}
catch (ConnectException connExc)
{
System.err.println("Errore nella connessione ");
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
Se si prova ad eseguire su due shell separate, il server ed il client (facendo attenzione a far prima partire il server!) si potrà notare come avviene l'interazione tra i due componenti.
Naturalmente, l'esempio precedente è davvero molto semplificato e serve a rendere soltanto l'idea di come funziona lo scambio di informazioni via socket tra due pc. La grande limitazione, in questo caso, è rappresentata dal fatto che il server è in grado di accettare le chiamate dei client in modo sequenziale, ovvero un solo client alla volta.
Con l'ausilio dei thread possiamo migliorare le cose e far si che il server sia sempre in grado di accettare una chiamata, a prescindere dal fatto che in quel momento siano o meno connessi altri client. Vediamo come:
Listato 4. Un Server TCP/IP MultiThread
import java.net.*;
import java.io.*;
public class SimpleServer
{
private int port;
private ServerSocket server;
private Socket client;
public SimpleServer (int port)
{
this.port = port;
if(!startServer())
System.err.println("Errore durate la creazione del Server");
}
private boolean startServer()
{
try
{
server = new ServerSocket(port);
}
catch (IOException ex)
{
ex.printStackTrace();
return false;
}
System.out.println("Server creato con successo!");
return true;
}
public void runServer()
{
while (true)
{
try
{
// Il server resta in attesa di una richiesta
System.out.println("Server in attesa di richieste...");
client = server.accept();
System.out.println("Un client si e' connesso...");
ParallelServer pServer = new ParallelServer(client);
Thread t = new Thread (pServer);
t.start();
}
catch (IOException ex)
{
ex.printStackTrace();
}
}
}
public class ParallelServer implements Runnable
{
private Socket client;
public ParallelServer (Socket client)
{
this.client = client;
}
public void run()
{
try
{
// Ricava lo stream di output associate al socket
// e definisce una classe wrapper di tipo
// BufferedWriter per semplificare le operazioni
// di scrittura
OutputStream s1out = client.getOutputStream();
BufferedWriter bw = new BufferedWriter(
new OutputStreamWriter(s1out));
// Il server invia la risposta al client
bw.write("Benvenuto sul server!n");
// Chiude lo stream di output e la connessione
bw.close();
client.close();
System.out.println("Chiusura connessione effettuata");
}
catch (IOException ex)
{
ex.printStackTrace();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
public static void main (String args[])
{
SimpleServer ss = new SimpleServer(7777);
ss.runServer();
}
}
In questo caso, abbiamo utilizzato una inner class ausiliaria ParallelServer, che si occupa di parallelizzare le operazioni svolte dal server e che viene passata come input ad un thread. Il risultato sarà che il server sarà in grado di gestire contemporaneamente le connessioni con più client.
Alla prossima.