MokaByte
Numero 25 - Dicembre 98
|
|||
codice nativo Java |
|||
Michele Crudele |
|
Con questo articolo intendiamo descrivere il processo di integrazione di codice nativo C/C++ in una applicazione Java, avvalendoci di un esempio concreto per illustrare le principali caratteristiche della JNI. Java è un linguaggio sostanzialmente nuovo, la cui flessibilità e maggiore semplicità rispetto al C++ derivano dal suo paradigma object oriented, dalla sua natura interpretata e dalla portabilità. Al contrario del C++, che è stato progettato come una evoluzione del C mantenendo la totale compatibilità all’indietro, Java parte da zero eliminando tutti i residui di linguaggio procedurale, semplificando la gestione della memoria da parte del programmatore, risolvendo in modo elegante il problema dell’ereditarietà multipla; in altre parole eliminando tutti i meccanismi più ostici del C++ e purificandolo dal punto di vista object oriented. Questo approccio, se da un lato offre un linguaggio più semplice e congruente del C++, dall’altro scopre il fianco ad alcune critiche. Tra le più significative annotiamo ([2]):
Già con il JDK 1.0 Java permette di caricare codice nativo all’interno di una applicazione, ma con il JDK 1.1 è stata formalizzata una interfaccia chiamata Java Native Interface (JNI), che mette a disposizione del programmatore un insieme di funzioni standard attraverso le quali è possibile effettuare, da codice nativo, svariate operazioni nell’ambiente Java. Si arriva così ad accedere oggetti Java, crearli, distruggerli, invocare metodi Java, gestire le eccezioni, sincronizzare thread, caricare la Virtual Machine in una applicazione nativa. Step by step In questo paragrafo
cercheremo di suddividere il processo di integrazione di codice nativo
in una serie di passi logici che saranno descritti nei prossimi paragrafi
modellandoli sul codice di esempio allegato all’articolo. Cominciamo col
porci una domanda: come facciamo ad invocare una funzione procedurale da
un linguaggio object oriented come Java? L’elemento del linguaggio più
vicino al concetto di funzione sembrerebbe essere il metodo di una classe.
Ed è proprio qualificando un metodo come nativo che Java si apre
al mondo esterno. In pratica è possibile indicare a Java che un
certo metodo è implementato altrove, in C, o comunque in un modo
dipendente dalla piattaforma ([1]), qualificandolo con il modifier native
nella sua dichiarazione. L’implementazione deve essere contenuta in una
DLL che esporta la funzione C che lo implementa. Sorge spontanea un’altra
domanda: quale è il prototipo di questa funzione?
Attenzione però, bisognerebbe evitare ad esempio, presi dalla frenesia, di progettare una sola classe contenitore per tutte le funzioni native che si vogliono esportare. Si definisca più di una classe Java se necessario. È tutto tempo speso a vantaggio della riusabilità del codice che si sta scrivendo. Dichiarazione dei metodi nativi Vogliamo sviluppare
una classe Drive che incapsula alcune operazioni di uso comune sui drive
di un PC. Drive.list (Drive.FIXED | Drive.REMOTE);Il metodo refresh() aggiorna le informazioni dell’oggetto su cui viene invocato; questo perché, una volta costruito un oggetto Drive, alcune informazioni dinamiche potrebbero cambiare (ad esempio lo spazio libero, oppure il drive potrebbe non esistere perché disconnesso, ecc.). Si usa quindi refresh() per ottenere le informazioni più "fresche" del drive. La dichiarazione dei metodi nativi è molto semplice. Basta scrivere il prototipo del metodo, qualificarlo con il modificatore native, e terminare la dichiarazione con un ‘;’, senza corpo del metodo. Più formalmente:
Generazione dell’header file Dichiarati i metodi nativi, dobbiamo ora scrivere i prototipi delle funzioni C che li implementano. Fortunatamente il JDK distribuisce un tool (javah.exe) che, dato in input il file Java compilato, genera l’header file C contenente i prototipi delle funzioni native che implementano i metodi dichiarati nativi nella classe Java. Creare questo file è dunque molto semplice; basta compilare il file Java e lanciare il comando javah con l’opzione -jni. Nel nostro caso: Con l’opzione -jni si chiede un prototipo di funzione "stile JNI", il che significa che si vuole usare la JNI per implementare il codice nativo. In effetti si può anche fare a meno di usare le interfacce fornite dalla JNI, ma le limitazioni sono notevoli ed è molto difficile trovare della documentazione adeguata (le uniche fonti di informazione che conosciamo sono i file .h sotto la directory include del JDK e in [2]). Il file .h che viene generato da javah.exe ha lo stesso nome della classe Java. Editiamo dunque Drive.h e diamogli un’occhiata. Il suo contenuto informativo si riduce sostanzialmente ai prototipi delle funzioni native C: Analizziamoli: JNIEXPORT e JNICALL assicurano la compilazione del codice per Win32 che richiede keyword speciali per le funzioni esportate da una DLL. Sono delle macro dipendenti dalla piattaforma. Il nome della funzione nativa è formato da:
Premesso che è possibile sviluppare il codice nativo sia in C che in C++, tutto ciò che segue è riferito ad una implementazione C++. Consigliamo di seguire questa strada perché la JNI fornisce una interfaccia C++ più chiara e type safety. Si osservi ad esempio la chiamata in C jclass cls = (*env)->FindClass (env, "java/lang/String");ovviamente più congruente e facile da ricordare. A questo punto possiamo iniziare ad implementare le funzioni native. Basta creare un file, in questo caso DriveImp.cxx, includere Drive.h, copiare i prototipi delle funzioni native, e svilupparle. Comunque dobbiamo prima imparare due cose fondamentali: i tipi di dato nativi che corrispondono a quelli Java, ed i metodi per accederli; in breve, abbiamo bisogno di una panoramica introduttiva alla JNI. Di questo ci occuperemo nei prossimi 3 paragrafi. Tipi di dato nativi in Java Rileggiamo i
prototipi delle funzioni native: jobjectArray, jclass, jint, jobject. Ma
cosa sono? Sono i tipi di dato nativi che corrispondono a quelli Java,
e li sostituiscono nell’ambiente nativo.
la corrispondente funzione che lo implementa prenderà in input un jobject A parte i primi due argomenti di cui abbiamo spiegato il significato, il terzo è un jobject che corrisponde ad un oggetto della classe Java MiaClasse. La JNI definisce un insieme di tipi concettualmente sotto tipi di jobject, allo scopo di agevolare il compito del programmatore e ridurre la possibilità di errore. Prima di concludere con l’argomento è opportuno soffermarci a descrivere molto rapidamente due tipi di dato ricorrenti, jstring e jarray. jstring non è una stringa C (char* per intenderci), quindi manipolarla come tale è scorretto. Tutte le operazioni su jstring devono essere compiute invocando opportune funzioni di JNIEnv*. È improbabile che nel codice nativo si abbia la necessità di gestire direttamente oggetti jstring; è più agevole convertire una jstring in una stringa C, elaborarla, ed eventualmente riconvertire il risultato in jstring se la funzione nativa restituisce al chiamante un tale oggetto. Quindi, le interfacce più importanti da ricordare per quanto riguarda l’oggetto jstring sono GetStringUTFChars() per la conversione da jstring a stringa C, e NewStringUTF() per la conversione da char* a jstring. Non dimentichiamo di liberare la memoria riservata per il char* restituito dalla GetStringUTFChars() chiamando la ReleaseStringUTFChars(). jarray a sua volta non è un array C e come tale non può essere acceduto se non attraverso appropriate funzioni di JNIEnv*. Questo codice ad esempio è scorretto
Ancora una volta
la distinzione tra tipi primitivi di dato ed oggetti è d’obbligo.
Gli array di tipi primitivi possono essere trasformati in array C invocando
i metodi Get<tipo>ArrayElements() che ritornano un <tipo>*, al contrario
degli altri per i quali invece è possibile soltanto l’accesso al
singolo elemento attraverso i metodi Get/SetObjectArrayElement().
jint* body = env->GetIntArrayElements (arr, 0);Si noti come per ogni Get<tipo>ArrayElements() sia necessario chiamare una Release<tipo>ArrayElements() per rilasciare la memoria allocata per il valore di ritorno. Questo perché tale memoria non è automaticamente gestita dal garbage collector di Java. Onde evitare di copiare un intero array, qualora questo sia molto grande, si possono accedere regioni parziali dell’array stesso usando i metodi Get/Set<tipo>ArrayRegion(). Queste informazioni dovrebbero essere sufficienti per quanto riguarda l’argomento array. Come invocare i metodi Java Vediamo adesso come si invocano i metodi Java da codice nativo. I dati del problema sono: un jobject drive, il nome della sua classe Java Drive, il nome del metodo da chiamare setName. La prima cosa da fare è creare un oggetto che rappresenta Drive nell’ambiente nativo, un jclass dato dalla funzione:
a seconda che
il metodo sia di istanza o di classe (static). jclass lo conosciamo, il
nome del metodo anche, manca il terzo argomento signature. Cerchiamo di
capire cos’è. Un metodo non può essere identificato univocamente
dal suo nome, perché potrebbe essercene un altro con lo stesso nome
ma differenti argomenti. Signature rappresenta proprio la ‘firma’ del metodo,
ossia una stringa che identifica il tipo del valore di ritorno ed il numero
e tipo degli argomenti; quindi, unitamente al nome, un qualcosa che identifica
univocamente il metodo stesso. Anche qui fortunatamente, il JDK ci
viene in aiuto fornendo il tool javap.exe che è in grado di generare
la signature dei metodi di una classe.
L’opzione -s informa javap che vogliamo le signature, l’opzione -p include anche i membri privati della classe. Siccome l’output è a video, redirezioniamolo per comodità nel file Drive.sig. Se cerchiamo setName in questo file vediamo: La signature di setName è dunque "(Ljava/lang/String;)V" ([3]). Ricapitolando, questa è l’istruzione completa per ottenere l’identificativo di setName: Tutte queste funzioni ritornano un jmethodID 0 in caso di errore. Il passo finale consiste nella chiamata del metodo usando una delle funzioni JNI
Come si vede, sono funzioni con un numero variabile di argomenti. I primi due sono facili da intuire, gli altri verranno passati al metodo Java da chiamare e devono rispettarne la signature. Concludiamo ricapitolando i passi necessari per invocare un metodo Java:
La sequenza di operazioni da eseguire per accedere i membri Java è identica a quella descritta per l’invocazione dei metodi: bisogna prima ottenere un oggetto che rappresenta la classe nell’ambiente nativo, poi generare la signature del membro, quindi chiamare una delle funzioni JNI: jfieldID GetFieldID (jclass clazz, const char *name, const char *sig); per ottenere l’identificativo del membro. A questo punto si possono utilizzare:
per ottenere
il valore del membro, oppure
void SetObjectField (jclass clazz, jfieldID fieldID, jobject value); void SetStaticObjectField (jclass clazz,
jfieldID fieldID, jobject value);
per modificarne il valore. JNI definisce funzioni più specifiche per i tipi primitivi. Ad esempio, se il membro è di tipo intero useremo Get/SetIntField, e via discorrendo per gli altri tipi primitivi. Implementazione del codice nativo Finalmente conclusa
la panoramica JNI, possiamo affrontare la fase implementativa del processo
di integrazione del codice nativo. Cos’è la stringa "<init>"? Ebbene, <init> si usa per indicare un costruttore. Il nome di un costruttore nelle chiamate a GetMethodID è sempre <init> indipendentemente dal nome della classe Java. Ottenuti l’ID del costruttore e l’ID del metodo setName, si effettua un loop sulla lista dei drive filtrati e per ognuno di essi si crea un jobject, si invoca setName, ed infine si aggiunge all’array di output. È interessante notare come si crea un nuovo jobject: La funzione JNI da chiamare è NewObject(), a cui si passa l’identificativo del costruttore da invocare durante l’inizializzazione dell’oggetto. Si noti come il metodo setName() in Drive.java chiami refresh() per inizializzare gli altri membri dell’oggetto Java, per cui non abbiamo bisogno di fare altro per inizializzare l’oggetto appena creato. Il metodo refresh() ha bisogno di aggiornare i membri dell’oggetto su cui è invocato; pertanto otteniamo gli ID di tali membri e delle costanti di tipo. A questo punto abbiamo bisogno di una stringa C che rappresenta il nome del drive per poter usare le API Windows che danno le dovute informazioni sul drive. Quindi invochiamo una GetObjectField() per avere la jstring che rappresenta il nome del drive, e poi convertiamo tale oggetto in un char* con la GetStringUTFChars(). A questo punto, ottenute le informazioni di tipo e di spazio sul drive, invochiamo le opportune Set<tipo>Field() per aggiornare l’oggetto Drive. Possiamo concludere questo paragrafo con due parole sugli oggetti restituiti dalle funzioni JNI. Tutti questi oggetti, nonché i parametri di input alle funzioni native, sono dei riferimenti locali. Ciò significa che essi diventano invalidi quando termina l’esecuzione della funzione nativa, ed il garbage collector provvederà a liberare la memoria ad essi associata. Ciò significa anche che un ID di un metodo, per esempio, può essere diverso tra una invocazione e l’altra. Non si tenti pertanto di dichiararlo come static ed aspettarsi che dopo la prima invocazione della funzione nativa esso continui ad avere un valore valido. Per fare ciò bisogna usare un riferimento globale usando la funzione JNI NewGlobalRef(). Il riferimento ottenuto in tal modo rimane valido finché non viene esplicitamente rilasciato con la DeleteGlobalRef(). Caricamento della DLL nel codice Java Compilata la DLL che esporta le funzioni native, bisogna stabilire un collegamento con la classe Java. Da qualche parte nel codice Java dovrà esserci una istruzione che dice dove andare a cercare i metodi dichiarati nativi. Ebbene, la classe java.lang.System definisce il metodo loadLibrary() che può essere invocato staticamente (cioè all’atto dell’inizializzazione della classe) nella classe Drive.
Il runtime di Java caricherà Drives.dll in fase di inizializzazione. In caso di problemi il linker di Java lancerà l’eccezione UnsatisfiedLinkException. Conclusioni In questo articolo abbiamo introdotto il concetto di codice nativo in Java, spiegando quando è opportuno ricorrervi. Abbiamo poi affrontato il processo di integrazione di tale codice in una applicazione Java avvalendoci di un esempio concreto per essere il più possibile aderenti alla realtà. Gran parte dell’articolo è stata dedicata allo studio degli strumenti messi a disposizione dalla JNI per una buona interazione con l’ambiente Java. Ed è infatti questo lo scopo principale che ci siamo prefissi in quanto tutto il processo può essere suddiviso in pochi e meccanici passi che sono totalmente indipendenti dalla piattaforma di sviluppo, a parte la fase implementativa. Si ricordi sempre di prestare particolare attenzione alla progettazione delle classi Java che incapsulano le funzionalità native. Questo è l’unico passo progettuale, e non è bene partire con il piede sbagliato. Il resto è pura tecnica di programmazione. Per motivi di spazio, ed anche per non essere noiosi, abbiamo descritto soltanto i concetti e gli strumenti indispensabili della JNI. Per approfondire l’argomento si può consultare [3]. Scopriranno che la JNI offre altri strumenti che potrebbero rivelarsi utili quali ad esempio la gestione delle eccezioni (è possibile lanciare ed intercettare eccezioni da codice nativo), la sincronizzazione di thread di codice nativo, il caricamento della Java Virtual Machine nel codice nativo. Bibliografia [1] David Flanagan,
"Java in a nutshell 2nd Edition", O’REILLY, 1997.
|
MokaByte Web 1998 - www.mokabyte.it MokaByte ricerca nuovi collaboratori. Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it |