|
Java Tutorial - Parte 2 0.1
Un tutorial per esempi
|
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.
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!
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 |
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).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:
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).
Cominciamo la scrittura della app dal client. Come vedete dal sorgente, esso è piuttosto semplice:
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.
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.
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:
La domanda che sorge spontanea è: se entriamo in un loop infinito, come possiamo terminare il programma? La risposta stà nel codice finale del main:
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.
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:
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:
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.
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:
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:
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:
Sempre nel blocco try-with-resources apriamo anche i flussi di dati collegati al socket:
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 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:
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:
Anche dal lato server, il metodo main è piuttosto semplice:
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.
Diamo uno sguardo d'insieme al metodo executeCommand:
Il metodo è diviso in due blocchi try-catch annidati:
start nella esecuzione del processo esterno 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 esterniTrattandosi 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.
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:
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:
Ci rendiamo conto che i flussi di dati aperti sono molti:
Socket che stabilisce la connessione PrintWriter che invia i dati al server in modalità riga-per-riga InputStreamReader che legge i dati provenienti dal server e che viene costruito con il ImputStream ritornato dal socket BufferedReader che legge l'input da tastiera in modalità riga-per-riga costruito attraverso uno stream-reader InputStreamReader usato per costruire l'oggetto precedente ed ottenuto dall'oggetto System.in che rappresenta la tastieraA 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:
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.
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:
La prima riga di codice crea una istanza del costruttore di processi passandovi come argomenti:
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:
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:
E per finire, otteniamo il exit status del programma e lo visualizziamo sul terminale del server: il log delle operazioni:
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:
Voglio però spendere due parole sulla istruzione seguente e sul metodo Process.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:
stdIn 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.
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