Java Tutorial - Parte 2 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Un piccolo telnet

Introduzione

Nel capitolo precedente abbiamo brevemente imparato quali classi abbiamo a disposizione per implementare una comunicazione con una macchina remota. In questo capitolo scriveremo una piccola applicazione CLI (=Command Line Interface) che ci consente di prendere il controllo di una macchina remota attraverso il terminale.

Premetto che le classi Java usate in questo piccolo esempio fanno parte del package java.net, quello descritto nel capitolo precedente. Il package java.net è specifico per il protocollo TCP/IP ed è composto di molte classi, sia a basso che ad alto livello. Tuttavia, il package java.net è stato sostituito dal nuovo package java.nio (Java New Input/Output) il quale fornisce una API più generica per l'input/output, non strettamente collegata ad un particolare protocollo.
In particolare, la nuova API java.nio colma alcune mancanze del vecchio package java.net, le più sentite delle quali sono: le primitive interrompibili ed i canali "selezionabili". Il progettino presentato in questo capitolo usa ancora le classi del package java.net ma è un buon punto di inizio per familiarizzare con il protocollo TCP/IP. Se il lettore è a digiuno totale con i concetti che stanno alla base delle reti TCP/IP il mio consiglio è quello di leggere questo capitolo. Se, al contrario, il lettore ha già una certa familiarità con i concetti di socket, indirizzo IP, etc, può saltare al prossimo capitolo.

A cosa serve telnet

Il telnet è una utility storica e famosa nel mondo Unix ad oggi caduta in disuso a causa dei severissimi problemi di sicurezza che la affliggevano (e la affliggono tuttora ....).

Aprite il terminale di Windows (cosidetto "Prompt dei comandi") e digitate un comando qualsiasi:

>dir
 Il volume nell'unità C è Windows
 Numero di serie del volume: F805-ADBC

 Directory di C:\...\tutorials\javatutor2

17/11/2024  18:31    <DIR>          .
09/12/2024  17:16    <DIR>          ..
28/01/2025  16:23               539 backup.txt
18/11/2024  14:52    <DIR>          docs
19/02/2025  23:24    <DIR>          dox
20/08/2024  18:30           129.423 Doxyfile
17/11/2024  16:22    <DIR>          doxygen
10/08/2024  13:36            23.411 LICENSE.txt
18/02/2025  17:58    <DIR>          projects
               3 File        153.373 byte
               6 Directory  419.300.163.584 byte disponibili

Ebbene, il telnet è il "Prompt dei comandi" (terminale, in Unix/Linux ) ma i comandi che digitiamo sulla macchina locale sono, in realtà, eseguiti sulla MACCHINA REMOTA. Potete ben immaginare la pericolosità intrinseca di un simile servizio in ascolto su una qualsiasi macchina: chiunque conosca l'indirizzo IP ed il numero di porta di questo servizio può prendere il controllo totale da remoto!

Quella che viene presentata in questo capitolo è una applicazione CLI che si compone di due parti: una parte server che deve essere eseguita sulla macchina controllata, ed una parte client che viene eseguita sulla macchina controllante. L'esecuzione della parte server su una qualsiasi macchina connessa ad una rete, sia locale che (peggio) pubblica, espone questa stessa macchina a severissimi rischi per la sicurezza, per la integrità nonchè per la segretezza dei dati contenuti sulla macchina controllata.

I files sorgente

La cartella javatutor2/projects/telnet contiene i files sorgente di questo progetto:

nome file package descrizione
TinyClient.java telnet il lato client della connessione
TinyServer.java telnet il lato client della connessione
ExecuteCmd.java telnet piccola app di test sui comandi esterni

Gli streams ed i writers / readers

Avrete notato sicuramente in Scrittura su un socket e Lettura da un socket che i metodi write e read disponibili per gli streams operano a livello del singolo byte o, al massimo, a livello di array di bytes. Ne consegue che per scrivere una stringa di testo sarà necessario richiamare due o più volte il metodo write. Stessa cosa per la lettura di una stringa di testo. Ma non è tutto: se dovessimo scrivere e/o leggere un intero, come facciamo? Sappiamo che un intero è composto da quattro bytes ma come possiamo accedere ai singoli bytes che compongono una variabile intera?

Non allarmatevi: non è così difficile! La libreria standard di Java mette a disposizione del programmatore diverse classi di input/output che, pur basandosi sugli streams, consentono di astrarre i dettagli implementativi della trasmissione dei singoli bytes. Le classi in questione sono, sostanzialmente:

  • PrintWriter che può essere costruito da uno stream in output e che fornisce tutte le varianti dei metodi print e println a cui oramai siamo abituati
  • Scanner che può essere costruito da uno stream in input e che ci consente di leggere stringhe, interi, float etc mediante i suoi numerosi metodi di input
  • BufferedReader che può essere costruito da uno stream in input e che fornisce il supporto per un input line-oriented (=riga per riga).

Protocollo applicativo

In Il modello ISO/OSI ho accennato al fatto che qualunque applicazione che si basa sulle connessioni deve stabilire delle regole precise su quali tipi di dati devono essere scambiati ed in che modalità. Questo insieme di regole prende il nome di protocollo applicativo e si trova al settimo livello della catena ISO/OSI.

Ogni applicazione ha un suo specifico protocollo applicativo; non esiste, almeno al momento, un protocollo applicativo generico che possa andare bene per tutte le applicazioni. Per questa applicazione telnet, il protocollo applicativo è il seguente:

Il protocollo lato server

  • dopo che la connessione è stata accettata con successo, il server invia un breve messaggio di presentazione ed attende il comando dal client
  • il comando del client si compone di una sola riga di testo
  • il server fà partire un processo esterno nel quale viene eseguito il comando; l'output del processo esterno viene inviato al client
  • l'output del processo esterno può essere composto da una o più righe di testo; la fine del messaggio viene marcata dal server con una riga a se stante che contiene la stringa "END-OF-MESSAGE"
  • dopo aver inviato la risposta, il server legge un nuovo comando proveniente dal client
  • se la riga di comando contiene la stringa "exit" il server chiude la connessione e termina

Il protocollo lato client

  • il client inizia la richiesta di connessione al server e, in caso di successo, legge il messaggio di presentazione
  • attende l'input da tastiera da parte dello user; questo input deve consistere in una sola riga di testo e rappresenta il comando da eseguire sul server
  • invia il comando al server
  • attende la risposta completa del server (il messaggio); una risposta può contenere una o più righe di testo; essa si considera completa quando in una riga a se stante è contenuta la stringa "END-OF-MESSAGE"
  • quando il client avverte la disconnessione provocta dal server in seguito al comando "exit", il client termina

I/O bloccante o non-bloccante?

Avrete sicuramente notato che in questo particolare protocollo il flusso del programma si blocca. Da parte del server, non vi è alcuna utilità nel proseguire una elaborazione finchè non arriva un comando da parte del client: quindi la strategia di bloccarsi in attesa di un comando è una strategia corretta da parte del server.
Lo stesso dicasi per il lato opposto: il client si blocca in attesa del comando digitato sulla tastiera da parte dello user; quando il comando è stato digitato esso viene inoltrato al server. Successivamente, il client si blocca in attesa della risposta da parte del server; anche in questo caso, non avrebbe senso continuare una elaborazione (per esempio accettando un altro input da tastiera) senza prima aver visualizzato i risultati del comando appena inviato.

Vi è da osservare che i metodi di alto livello con i quali possiamo leggere da un Reader e scrivere su un Writer sono di per se bloccanti in ogni caso: anche se una riga viene letta un byte alla volta, il metodo deve comunque attendere di leggere il carattere newline (=nuova riga) e fino a quel momento non ritorna, di fatto bloccandosi. Cosa succede se il carattere newline non arriva mai? In gergo si dice che la applicazione è congelata.
Benchè questa particolare applicazione non risenta particolarmente del blocco di input / output (salvo il congelamento, si intende) vi sono situazioni in cui non è proprio possibile per una applicazione rimanere in attesa di input da remoto senza consentire comunque la interazione con l'utente: questo è particolarmente vero nelle applicazioni GUI come anche il nostro Mastermind.

La libreria Java mette a disposizione strumenti alternativi a quelli visti nelle sezioni precedenti per le connessione TCP/IP; si tratta di classi che gestiscono l'I/O basando sul concetto di canale anzichè stream, e buffer anzichè bytes, stringhe e righe. Analizzeremo questi strumenti in JavaNIO (Java New IO).

La classe TinyClient

Cominciamo la scrittura della app dal client. Come vedete dal sorgente, esso è piuttosto semplice:

Il metodo main del client

public static void main(String argv[])
{
String hostname = "localhost"; int port = 18862;
if ( argv.length >= 1 ) {
hostname = argv[0];
}
if ( argv.length >= 2 ) {
port = Integer.parseInt( argv[1] );
}
... omissis ...

Nelle prime righe del main impostiamo dei valori di default per le variabili hostname e port e leggiamo la command-line per verificare se l'user ha specificato dei valori specifici per queste variabili.

Il blocco try-catch

Passiamo poi al resto del main che viene incluso in un blocco try-catch dal momento che i metodi relativi alla scrittura e lettura dei flussi possono sollevare eccezioni di tipo IOException la quale è una eccezione checked.

public static void main(String argv[])
{
... omissis ...
try (
// il blocco try-with-resources
Socket socket = new Socket(hostname, port);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
BufferedReader stdIn = new BufferedReader( new InputStreamReader(System.in));
)
{
String command, response;
printResponse( in ); // legge la presentazione
// entra in un loop infinito, la chiusura della connessione
// viene rilevata da una eccezione SocketException
while (true ) {
command = stdIn.readLine(); // legge il comando da tastiera
out.println(command); // invia il comando al server
printResponse( in ); // visualizza la risposta del server
}
}
... omissis ...

Notate che è stato usato la variante try-with-resources del blocco try-catch in modo da automatizzare le operazioni di chiusura dei flussi. Per coloro che non hanno dimestichezza col blocco try-with-resources, è stata dedicata una sottosezione che ne approfondisce le caratteristiche proprio in questo capitolo (vedi Il blocco try-with-resources).

Il codice prosegue entrando in un loop infinito che:

  • legge il comando da tastiera
  • invia il comando al server
  • visualizza la risposta del server

Intercettare le eccezioni

La domanda che sorge spontanea è: se entriamo in un loop infinito, come possiamo terminare il programma? La risposta stà nel codice finale del main:

public static void main(String argv[])
{
... omissis ...
// il server ha chiuso la connessione
catch( SocketException ex )
{
System.out.println();
System.out.println( ex.getMessage());
}
catch (IOException ex)
{
System.out.println( "IOException: " + ex.getClass().getName() );
System.out.println( "MESSAGE: " + ex.getMessage());
ex.printStackTrace();
}
}

Se ricordate il protocollo applicativo di questo piccolo telnet (vedi Protocollo applicativo), il client avverte che la connessione è stata chiusa dal server in risposta al comando exit. Nel leggere il flusso di dati proveniente dal server, il client verifica ad ogni lettura se lo stream è stato chiuso e, in caso affermativo, solleva una eccezione di tipo SocketException: questo è il motivo per cui la eccezione SocketException viene intercettata in modo specifico anche se non ce ne sarebbe bisogno essendo essa derivata da IOException.

Il metodo printResponse

Concludiamo il sorgente del lato client della applicazione commentando brevemente il metodo che visualizza a terminale la risposta del server. Poichè la risposta può essere composta da più righe, il metodo esegue in un loop che legge riga-per-riga i dati provenienti dal server attraverso lo stream:

private static void printResponse( BufferedReader in )
throws IOException, SocketException
{
String response;
do {
response = in.readLine();
if ( response == null ) {
throw new SocketException( "Connection closed by the peer" );
}
System.out.println( response );
} while( !"END-OF-MESSAGE".equals( response ));
}

Il metodo che legge riga-per-riga è il BufferedReader.readLine che ritorna una stringa contenente la riga di testo letta oppure null se viene incontrata la fine del flusso che, per un flusso collegato ad un socket, corrisponde alla chiusura della connessione. Quando questa condizione viene rilevata, una eccezione di tipo SocketException viene sollevata.
La lettura dello stream riga-per-riga prosegue fino a che non viene incontrata una riga marcatrice della fine del messaggio proveniente dal server, come stabilito dal protocollo applicativo (vedi Protocollo applicativo). Ogni riga letta dallo stream viene visualizzata sul terminale del client Vi sono quindi due diverse modalità di uscire dal metodo printResponse:

  • la prima normalmente, quando si incontra il marcatore di fine messaggio
  • la seconda con una eccezione quando la connessione col server si interrompe

La classe TinyServer

Passiamo ora ad esaminare la classe che implementa il server. Essa è solo leggermente più complessa ma solo per il fatto che esso deve eseguire una certa elaborazione.

Il metodo main del server

Nelle prime righe di codice si impostano le variabili dei parametri di connessione: per il server, l'unico parametro obbligatorio è il numero della porta di ascolto. Poi si stampano alcune informazioni di servizio:

public static void main(String argv[])
{
int port = 18862;
System.out.println( "TinyTelnet server version 0.1" );
if ( argv.length > 0 ) {
port = Integer.parseInt( argv[0] );
}
System.out.println( "Waiting connections on port: " + port );

Successivamente, si apre il canale di comunicazione istanziando un oggetto di classe ServerSocket costruito con un argomento: la porta di ascolto, subito seguito dalla accept di qualsiasi richiesta di connessione pervenga:

ServerSocket server = new ServerSocket(port);
Socket client = server.accept();

Come già descritto in Creare un socket lato server, il ServerSocket NON è il socket di comunicazione; in altre parole, esso non rappresenta affatto il endpoint della connessione. Un endpoint è costituito dal Socket restituito dal metodo accept: questo è un comportamento normalissimo anzi, necessario, poichè uno stesso server può connettersi con più di un client:

ServerSocket server = new ServerSocket(port);
Socket client1 = server.accept();
Socket client2 = server.accept();
Socket client3 = server.accept();
Socket client4 = server.accept();

Sempre nel blocco try-with-resources apriamo anche i flussi di dati collegati al socket:

PrintWriter out = new PrintWriter(client.getOutputStream(), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(client.getInputStream()));

Per coloro che non hanno dimestichezza col blocco try-with-resources, è stata dedicata una sottosezione che ne approfondisce le caratteristiche proprio in questo capitolo (vedi Il blocco try-with-resources).

Il blocco try-catch

Il metodo main prosegue entrando nel blocco try-catch. Viene visualizzato a terminale l'indirizzo IP del client e ad esso viene inviato un breve messaggio di presentazione:

System.out.println( "Connection accepted from: "
+ client.getRemoteSocketAddress().toString());
// invia la presentazione
out.println( "Welcome to TinyServer version 0.1" );
out.println( "Type \'exit\' to disconnect" );
out.println( "END-OF-MESSAGE" );

Si entra poi in un loop infinito che termina quando sul canale collegato al socket viene rilevato il comando exit. Questo evento provoca la uscita dal loop, la conseguente chiusura del socket (rilevata dal client) e la terminazione del programma:

while ( true ) {
String inputLine = in.readLine();
if ( inputLine != null && !inputLine.isBlank()) {
System.out.println( "Executing command: " + inputLine );
if (inputLine.startsWith("exit")) {
break;
}
}
System.out.println( "Sending response" );
executeCommand( inputLine, out );
out.println( "END-OF-MESSAGE" );
System.out.println( "Response sent" );
}
System.out.println( "Closing connection ... " );
client.close();

Anche dal lato server, il metodo main è piuttosto semplice:

  • si mette in ascolto sulla porta specificata
  • accetta la connessione in ingresso e crea un endpoint della connessione con quello specifico client; questo endpoint è il socket
  • legge dal flusso in ingresso collegato al socket il comando da eseguire sulla macchina del server
  • esegue il comando inviando tutto l'output al flusso in uscita collegato al socket

Nell'eseguire queste operazioni il server visualizza sul terminale (del server) l'andamento della elaborazione come l'indirizzo IP del endpoint remoto, il comando da eseguire e il cosidetto exit status del processo eseguito.
Queste informazioni stampate sul proprio terminale permettono al server di tenere una sorta di log (=registro) di chi ha fatto che cosa: se un utente inviasse il comando "format c:" potrebbe essere possibile risalire al colpevole.

Ovviamente il server non visualizza sul proprio terminale i risultati della elaborazione del processo esterno: quelli vanno inoltrati al client. Infine, il codice del main del server termina con la intercettazione della eccezione checked di tipo IOException che visualizza un messaggio di errore sul terminale e che provocherà la terminazione del programma server.

public static void main(String argv[])
{
... omissis ...
catch (IOException ex)
{
System.out.println( "IOException: " + ex.getClass().getName() );
System.out.println( "MESSAGE: " + ex.getMessage());
ex.printStackTrace();
}
}

Il metodo executeCommand

Diamo uno sguardo d'insieme al metodo executeCommand:

static void executeCommand( String command, PrintWriter out )
{
try
{
... creazione ed esecuzione del processo ...
try ( resources )
{
... lettura risultati ed inoltro al client
}
catch ( Exception ex )
{
... eccezioni in lettura / scrittura dei risultati e sul socket
}
}
catch ( Exception ex )
{
... eccezioni per la esecuzione del processo
}
}

Il metodo è diviso in due blocchi try-catch annidati:

  • quello più esterno si riferisce alle eccezioni sollevate dal metodo start nella esecuzione del processo esterno
  • quello più interno si riferisce alle eccezioni causate dai metodi che scrivono su e leggono dagli streams collegati al processo o al socket; questo blocco è di tipo try-with-resources

Partiamo dal blocco più esterno: il metodo ProcessBuilder.start può sollevare molte eccezioni di cui una sola checked: la IOException. Tuttavia, molte altre possono essere sollevate:

  • NullPointerException: se uno degli elementi del comando è una stringa nulla
  • IndexOutOfBoundsException: se la lista delle stringhe del comando è vuota
  • SecurityException: se sul server è installato un security manager e per qualche motivo non è possibile eseguire il processo
  • UnsopportedOperationException: se il sistema operativo non supporta la creazione di processi esterni

Trattandosi di eccezioni unchecked non è necessario intercettarle, ovviamente. Tuttavia, cosa succede se non le intercettiamo? Facile immaginarlo: la eccezione si propaga al metodo chiamante, il main, il quale termina non essendoci alcun handler a gestirle. Ma non è quello che vogliamo: se vi è una qualche eccezione nella esecuzione del programma dobbiamo informarne l'user remoto: è piuttosto inutile loggare il motivo della eccezione sul terminale del server quando l'user potrebbe essere a centinaia di kilometri di distanza.
Ecco perchè intercettiamo tutte le eccezioni che possono essere sollevate nel metodo executeCommand e, quando accadono, inviamo il messaggio di errore sullo stream collegato al socket in modo che l'user le possa vedere. E come si fà ad intercettare tutte le eccezioni? facile, basta intercettare la loro classe base: la Exception.

Il metodo executeCommand viene descritto in dettaglio in una delle prossime sezioni e, precisamente, in Esecuzione di un processo esterno. In quella sezione vengono fornite informazioni essenziali per comprendere bene il codice del metodo.

Il blocco try-with-resources

Per coloro che già hanno familiarità con questo costrutto, consiglio di saltare questa sezione e riprendere il tutorial da Esecuzione di un processo esterno.

Un blocco try-with-resources è molto simile al blocco try-catch standard ma si differenzia da esso per la presenza di istruzioni che appaiono subito dopo la keyword try e racchiuse tra due parentesi tonde:

try ( resources )
{
... corpo del blocco try ...
}
catch( ... )
{
}

Le istruzioni comprese nel blocco resources devono servire ad aprire flussi di input/output mentre o scopo del blocco try-with-resources è quello di chiudere automaticamente i flussi sia in caso di eccezioni che di normale uscita dal blocco.
Anche se a prima vista può sembrare un feature ridicolo, dal momento che i flussi possono essere chiusi con il semplice richiamo del metodo close, la chiusura automatica dei flussi porta un grande vantaggio. Analizziamo il codice che apre gli streams (=flussi) di dati nel blocco resources:

... omissis ...
// il blocco try-with-resources
try (
Socket socket = new Socket(hostname, port);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
BufferedReader stdIn = new BufferedReader( new InputStreamReader(System.in));
)
{
... omissis ...

Ci rendiamo conto che i flussi di dati aperti sono molti:

  • un Socket che stabilisce la connessione
  • un PrintWriter che invia i dati al server in modalità riga-per-riga
  • un InputStreamReader che legge i dati provenienti dal server e che viene costruito con il ImputStream ritornato dal socket
  • un BufferedReader che legge l'input da tastiera in modalità riga-per-riga costruito attraverso uno stream-reader
  • uno InputStreamReader usato per costruire l'oggetto precedente ed ottenuto dall'oggetto System.in che rappresenta la tastiera

A parte il fatto di dover richiamare il metodo close per cinque volte (che non è certo un grande lavoro), quale è la evidente criticità nel chiudere i flussi?
RISPOSTA: poichè una eccezione può accadere in ogni momento e prima che i flussi siano tutti aperti, è evidente che non possiamo chiudere un flusso se non è stato aperto. Ne consegue che dobbiamo tenere traccia di quanti e quali dei cinque flussi sono stati aperti e, in caso di eccezioni, chiudere solo quelli effettivamente aperti. Ovviamente, è fattibile, questo è certo. Ecco il codice:

Socket socket; PrintWriter out;
BuffereReader in, stdIn; InputStreamReader reader;
try {
// apre i flussi
socket = new Socket(hostname, port);
out = new PrintWriter(socket.getOutputStream(), true);
reader = new InputStreamReader(socket.getInputStream());
in = new BufferedReader( reader );
stdIn = new BufferedReader( new InputStreamReader(System.in));
... elaborazione ...
}
catch( IOException ex )
{
System.err.println( "ERROR: " + ex.getMessage());
}
// chiude i flussi ma solo quelli effettivamente aperti
try {
if ( stdIn != null ) {
stdIn.close();
}
if ( in != null ) {
in.close();
}
if ( reader != null ) {
reader.close();
}
if ( out != null ) {
out.close();
}
if ( socket != null ) {
socket.close();
}
}
catch( IOException ignore )
{
}

Notate che i flussi sono stati chiusi in ordine inverso rispetto all'ordine con cui sono stati aperti.
Il blocco try-with-resources esegue tutto questo auromaticamente ma solo per quelle classi che implementano la interfaccia AutoCloseable.

Esecuzione di un processo esterno

La libreria standard di Java mette a disposizione del programmatore la classe ProcessBuilder per mezzo della quale è possibile eseguire un qualsiasi programma o applicazione (avendone i permessi, ovviamente) in un processo separato dalla Java Virtual Machine.
Il processo separato viene controllato in questo modo:

ProcessBuilder pb = new ProcessBuilder( "comando", "arg-1", ... , "arg-n" );
Process proc = pb.start();
InputStream in = proc.getInputStream();
BufferedReader stdin = new BufferedReader( new InputStreamReader( in ));
InputStream err = proc.getErrorStream();
BufferedReader stderr = new BufferedReader( new InputStreamReader( err ));

La prima riga di codice crea una istanza del costruttore di processi passandovi come argomenti:

  • il nome del comando, programma o applicazione da eseguire; in questo contesto comando deve essere un nome di file completo come per esempio "C:\Programmi\Google\Chrome\crome.exe"
  • zero o più argomenti alla command-line del comando

La seconda riga di codice fà partire il processo con il metodo start il quale restituisce un oggetto di classe Process che, oltre a fornire varie info sul processo in corso mette a disposizione diversi metodi per controllare la esecuzione del comando.

Le successive righe di codice usano i metodi Process.getInputStream e Process.getErrorStream per ottenere i flussi di dati che corrispondono ai flussi standard output e standard error del processo da eseguire. A questo proposito va detto che la classe Process definisce anche il metodo getOutputStream che restituisce un flusso in output collegato allo standard input del processo da eseguire: poichè il comando che deve eseguire il server telnet non ha bisogno di alcun input, questo flusso non viene aperto.

Dopo aver aperto i flussi come risorse del blocco try, il metodo executeCommand fornisce un escamotage per forzare il sollevamento di alcune eccezioni:

// force exceptions
switch( command ) {
case "io" : throw new IOException("IOException forced" );
case "security" : throw new SecurityException( "SecurityException forced" );
case "unsupported" : throw new UnsupportedOperationException( "UnsupportedOperationException forced" );
default : break;
}

Se l'utente invia al server il comando "io" otterrà come output un messaggio di errore; questo è un metodo conveniente per testare le eccezioni. Lo svantaggio è che se esisterà, in futuro, un comando interno col nome "io" non potrà essere eseguito, salvo modificare il codice sorgente del server.

Proseguendo la analisi del sorgente del metodo executeCommand notiamo i due loop che prelevano l'output del comando eseguito e lo inviano sullo stream collegato al socket in modo che sia visibile al user remoto. Oltre allo standard output, il metodo invia sullo stream collegato al socket anche l'output di standard error dal momento che i messaggi di errore scaturiti dal programma sono, di norma, inviati a questo device:

String line;
while ( (line = stdin.readLine()) != null ) {
out.println( line );
}
while ( (line = stderr.readLine()) != null ) {
out.println( line );
}

E per finire, otteniamo il exit status del programma e lo visualizziamo sul terminale del server: il log delle operazioni:

int exitVal = proc.waitFor();
System.out.println( "Process exit status: " + exitVal );

Ogni comando, programma o applicazione deve ritornare al sistema operativo un exit status (codice di uscita) che, convenzionalmente, vale ZERO se il programma si è concluso con successo. Un valore diverso da ZERO significa che sono intervenuti errori di qualche tipo nella elaborazione: non esiste un sistema di codifica che attribuisca un valore a seconda del codice di uscita, ogni applicazione può scegliere il significato che preferisce.

Lo stesso Java permette al programmatore di inviare un codice di uscita dai programmi:

int exitStatus = 10;
System.exit( exitStatus );

Voglio però spendere due parole sulla istruzione seguente e sul metodo Process.waitFor

int exitVal = proc.waitFor();

Come oramai assodato, il metodo ProcessBuilder.start carica in memoria un processo separato dal nostro programma che è in esecuzione nella JVM; ne consegue che i due processi girano in parallelo, ognuno per conto suo. Nessuno dei due processi è a conoscenza dello stato di avanzamento dell'altro ed l'unico collegamento tra i due processi sono i due flussi di dati che abbiamo aperto nel blocco try-with-resources:

  • lo standard output del programma esterno è collegato a stdIn
  • lo standard error del programma esterno è collegato a stdErr

Il fatto che i processi siano eseguiti in modo concorrente è un enorme vantaggio in termini computazionali ma, a volte, non è quello che vogliamo.

Essendo eseguiti in modo autonomo, il metodo executeCommand potrebbe rientrare prima che il processo esterno abbia concluso la elaborazione; la conseguenza di ciò è che, ritornando al main il server invii al client il marcatore di fine messaggio prima ancora che il processo esterno si sia concluso e potrebbe avere ancora risultati da inviare.
La strategia corretta per il server telnet è quella di aspettare che il processo esterno si sia concluso prima di ritornare al main: questo viene ottenuto richiamando il metodo Process.waitFor il quale blocca il processo corrente fino a che il processo rappresentato dall'oggetto proc non si sia concluso. Il metodo waitFor ritorna il exit status del processo esterno.

Ulteriore documentazione

La documentazione completa della applicazione descritta in questo capitolo può essere visualizzata clikkando il seguente link che riporta alla documentazione Javadoc del progetto: Tiny Telnet

Argomento precedente - Argomento successivo - Indice Generale