Kaffee mit Vitamin C JNI als ultimativer Zweikomponentenkleber (C) Michael Härtfelder, 2000 <michael@haertfelder.com> Artikel veröffentlicht in der Zeitschrift c't 20/2000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
EinführungWer hätte gedacht was das Schatzkästlein des Java Development Kits (JDK) noch so alles bietet. Da gibt es also die Möglichkeit aus Java heraus Bibliotheken fremder Programmiersprachen (eben "native" Code) aufzurufen. Natürlich mag man einwenden, dass dies einem Rückschritt gleichkommt. Denn schliesslich wurde Java unter der Prämisse entwickelt Betriebssystemunabhängigkeit zu gewährleisten. Und nun soll also wieder auf nicht portierbaren Code zugegriffen werden ?Im Grunde genommen stimmt das schon. Aber in der Praxis steht man nun einmal des öfteren vor Problemen die einen Rückgriff auf native Code erfordern. Da sind zunächst all jene Situationen zu nennen, in denen bereits eine fertige Bibliothek eines externen Anbieters besteht und deren Funktionalität verwendet werden soll. Noch allgemeiner gesprochen sind C(++)-Schnittstellen zu angrenzenden Systemen vorhanden in die oder aus denen Daten transportiert werden soll. Da bleibt einem nur die Wahl zwischen der Neuimplementierung der Bibliothek in Java mit dem entsprechenden immensen Aufwand, dem Verzicht auf Java oder aber man greift eben zu JNI. JNI bietet sich auch in Situationen an in denen Teile der Funktionalität sehr rechenintensiv sind und daher in C(++) oder Assembler geschrieben werden sollen. Verwendet man JNI lässt sich eine komfortable Java-Oberfläche schreiben und bindet als Rechenknecht eine C-Library ein. Alles was man dazu benötigt ist das JDK ab Version 1.1 und einen C(++)-Compiler. Ob der native Code dabei in C oder C++ geschrieben ist spielt keine entscheidende Rolle. Bei den weiteren Ausführungen wird vom kleinsten gemeinsamen Nenner, von C Code, ausgegangen. In der Praxis ist der Aufruf einer C Bibliothek aus einem Java Programm heraus wesentlich häufiger anzutreffen als der Aufruf von Java aus C heraus. Deshalb wird bei den folgenden Ausführungen auch diese Aufrufstruktur im Vordergrund stehen. Der Entwicklungsprozess (vergleiche Schaubild 1) ist dabei relativ einfach zu beschreiben:
In Listing 1 fallen drei Dinge auf: Da ist zunächst die Deklaration einer native Funktion "callnative()". Dieses Statement ist vergleichbar mit einer "extern" Deklaration einer externen Funktion unter C++. Für jede Funktion, die später in einer externen Bibliothek angesprochen werden soll, muss eine native Deklaration im Java Programm vorhanden sein. Weiterhin fällt die "System.loadLibrary()"-Anweisung zum Einbinden der externen Bibliothek mit dem Namen "hello" auf. Native Bibliotheken werden nicht statisch gelinkt sondern dynamisch geladen. Der physische Name der Bibliothek nach der während des Ladevorgangs gesucht wird, ist dabei unter Windows "hello.dll" und unter SunSolaris/Linux "libhello.so". Zuletzt nun der eigentliche Aufruf der externen Funktion "callnative()" der so unauffällig und selbstverständlich erscheint, als ob callnative() eine Java Methode der Klasse HelloWorld wäre. Das mit dem Compilieren der Java Klasse entstehende HelloWorld.class dient nun als Ausgangsbasis für den sich anschliessenden wichtigen Schritt, der Generierung der C-header Datei. Dazu wird das Programm javah.exe aus dem Java Development Kit verwendet und der Befehl javah -jni HelloWorld ausgeführt. Als Ergebnis erhält man eine neue Datei HelloWorld.h mit den Prototypen für den C-Code in einer sehr speziellen Form: JNIEXPORT void JNICALL Java_Helloworld_callnative(JNIEnv *, jobject); JNIEXPORT und JNICALL sind dabei Macros die als gegeben hingenommen werden können. Interessanter ist da schon die Art der Namensgebung für die callnative Funktion. Im Allgemeinen orientiert sich dieser Funktionsname an dem Schema "Java_"<Klassenname>"_"<Funktionsname> Abweichungen von diesem Format existieren nur bei überlagerten Funktionen die aber hier nicht besprochen werden sollen. Merkwürdig erscheint über die dargestellten Syntax hinaus zunächst auch die Tatsache, dass zwei Übergabeparameter auftauchen, die im Java Quellcode gar nicht deklariert wurden. Diese Argumente sind jedoch Standard bei der Verwendung von JNI. Der erste Parameter verweist auf das JNI-Environment Interface während der zweite eine Art "this" pointer auf das HelloWorld Objekt selbst ist. Nun ein Blick auf den C Code "hello.c" in Listing 2.
Kennzeichnend für die JNI Bibliothek ist die schon bekannte Funktionsdefinition gemäss der Prototypendeklaration sowie die header Dateien "jni.h" und "HelloWorld.h". Erstere muss immer eingebunden werden und enthält JNI spezifische Informationen. HelloWorld.h ist die mit javah bereits erzeugte header-Datei. Jetzt bleibt nur noch die Compilierung des C-Codes. Unter SunSolaris sieht das etwa wie folgt aus: cc -G -I/java/include -I/java/include/solaris hello.c -o libhello.so Die Option -G signalisiert dem Compiler, dass eine Shared Library zu erzeugen ist und kein einzeln ausführbares Programm. Die Pfadangabe "/java/" ist im Einzelfall natürlich an den jeweiligen Installationspfad des jdk anzupassen. <jni.h> ist also eine Include-Datei die Bestandteil des Java Develoment Kits ist und nicht des C++ Compilers. Unter Linux wäre folgendes einzugeben: gcc -shared -I/usr/local/java/include -I/usr/local/java/include/genunix hello.c -o libhello.so Die Includepfade sind ggf. anzupassen. Tja, eigentlich war das schon alles, was zu tun wäre. Das Programm kann jetzt wie üblich mit java Helloworld aufgerufen werden. Mitunter tritt an dieser Stelle noch ein Problem mit dem Library-Suchpfad auf. Programmabbrüche mit einem Fehlerhinweis der Art java.lang.UnsatisfiedlinkError: no hello in library path deuten darauf hin, dass (unter Solaris) der LD_LIBRARY_PATH nicht richtig gesetzt bzw. angepasst wurde. In dieser Umgebungsvariablen wird die Suchreihenfolge für Libraries festgelegt. Im Fehlerfalle ist libhello.so in eines der Verzeichnisse im Pfad zu kopieren oder alternativ (falls es sich im selben Verzeichnis wie HelloWorld.class befindet) die Variable mit
LD_LIBRARY_PATH=. neu zu setzen. Ein paar Worte zum Vorgehen unter Windows. Da an den Compiler keine besonderen Ansprüche gestellt werden ist jeder Compiler verwendbar der die Erzeugung von DLLs unterstützt. Im allgemeinen kann das der MS Visual C++ ab V5.0 sowie die gcc Windows-Portierungen. Gelegentlich wird bei speziellen Compilern über Probleme mit dem Datentyp __int64 (aus jni_md.h) berichtet. Dies kann durch die Deklaration von
#ifdef FOOBAR_COMPILER umgangen werden, wobei der unterstützte 64 Bit Typ einzusetzen ist. Das Erzeugen der DLL mit dem MS Visual C++ könnte dann wie folgt aussehen: cl -Ix:\java\include -Ix:\java\include\win32 -MD -LD hello.c -Fehello.dll Sollte bislang mit Visual C++ nur über die GUI entwickelt worden sein, dann müssen vor dem erstmaligen Aufruf über Kommandozeilen noch einmalig die entsprechenden Umgebungsvariablen gesetzt werden. Dazu ist die Batch-Datei vcvars32.bat aufzurufen, die diese Arbeit vollständig für den User übernimmt.
ParameterübergabeAls nächster Schritt bietet sich an das vorherige HelloWorld-Beispiel um die Übergabe von Parametern bzw. den Zugriff auf Java-Klassenvariablen zu erweitern. In der neuen Bibliotheksfunktion "jnipower" soll eine ganze Zahl vom Typ Integer mit einem Wert vom Typ Float potenziert und darauf ein Wert von Typ Double addiert werden. Dabei werden die beiden ersten Werte als Parameter übergeben während der zu addierende Term direkt aus der aufrufenden Klasse abgeholt wird. Das Ergebnis wird als Wert der Funktion an Java zurückgegeben. Eine mögliche Implementierung der Java-Klasse ist in Listing 3 zu sehen und deren dazugehörige C-Library "two.c" in Listing 4.
Aus dem erweiterten Beispiel ist die Handhabung und der Umgang mit Basistypen und mit Strings zu erkennen. Das entscheidende Problem bei der Kommunikation von Programmmodulen aus verschiedenen Programmierwelten ist die physische Konvertierung der Datentypen dem sogenannten Mapping (bzw. Marshalling). Folgende Tabelle gibt Aufschluss wie die Java-Datentypen in C gesehen werden:
Wie zu erkennen, lassen sich die übergebenen Parameter, sofern sie einen Basistyp darstellen, einfach im C-Code einbinden. Etwas anders sieht es mit den Variablen aus, die irgendwo in der Bytecodesuppe der aufrufenden Java-Klasse schwimmen. Deren genaue Position muss erst mit der JNI-Funktion GetStaticFieldID() lokalisiert und in einem zweiten Schritt explizit mit einer JNI-Funktion wie etwa "GetStaticDoubleField()" ausgelesen werden. Aufmerksamkeit verdient der dritte Parameter der Funktion GetStaticFieldID(), in diesem Falle "D". Hierdurch wird der Datentyp des zu suchenden Feldes festgelegt. Folgende kleine Tabelle zeigt eine Übersicht des Mappings der zur Auswahl stehenden sogenannten "Signaturen".
O.k. das ist jetzt a bisserl kryptisch. Am besten fährt man damit wenn man die Kürzel nicht zu hinterfragen versucht, sondern als gegeben hinnimmt. Was suche ich ? Eine double Variable ? Also nehme ich "D" als dritten Parameter. Oder wie wäre es mit einer Long Variablen ? Dann ist ein "J" einzusetzen.
StringsEin paar Bemerkungen zu Strings: Diese sind, da sie keinen atomaren Basistyp darstellen, nicht in der Tabelle 1 aufgeführt und werden aus C-Sicht wie ein Array of chars behandelt bzw. aus Java-Sicht durch die Klasse java.lang.String repräsentiert. JNI verwendet zur physischen Repräsentation der Strings das Format UTF-8. UTF-8 Strings sind dieselben die auch von Java verwendet und unterscheiden sich nur wenig von der Unicode Darstellung. Ohne zu detailliert darauf eingehen zu wollen, benötigt man im allgemeinen nur drei UTF spezifische Funktionen im native Code im Zusammenhang mit JNI. Eine Funktion zum Konvertieren von UTF-8 (Java) String in C-Strings: const jchar *c_str = (*env)->GetStringUTFChars(env, jstring_var, 0); Eine Funktion zum Konvertieren von C-Strings zurück in UTF-8 (Java) Strings:
char buffer[128]; Eine Funktion zum Freigeben von durch UTF-8 gehaltenem Speicherplatz: (*env)->ReleaseStringUTFChars(env, jstring_var, buffer);
Bequemerweise erhält man im zweiten Beispiel schon die Referenz auf den String
als Parameter übergeben. Wie aber müsste man vorgehen, wenn man den String
analog der Variablen "ddd" erst in der aufrufenden
Java-Klasse suchen müsste ?
ObjekteAber JNI bietet selbstverständlich noch sehr viel weiterreichende Möglichkeiten als nur die Übergabe von Basistypen-Parametern und Strings. Komplexere Strukturen wie etwa ganze Klassen können auch über- bzw. zurückgegeben werden. Dazu wiederum ein Beispiel. Zusätzlich zu der aufrufenden Javaklasse "classthree" und der aufgerufenen Bibliotheksfunktion "dem2euro" sind zwei Container-Klassen "Request" und "Response" zu definieren in der analog einem struct in C eine Anzahl von Variablen enthalten sind. Listing 5 gibt einen Überblick über eine Möglichkeit einer Java-Implementierung.
Die hierin referenzierte C Library "three.c" (Listing 6) ist für die Konvertierung von DEM-Beträgen in Euro zuständig.
Um auf die unterschiedlichen Felder eines durch jobject typisierten Java- Objektes zugreifen zu können, muss zunächst die Klassendefinition bekannt sein. Diese wird mit der JNI-Funktion "GetObjectClass()" ermittelt. Mit der jetzt bekannten Referenz auf die Klasse lassen sich auch die einzelnen Felder identifizieren. Da die Variablen nicht wie in Beispiel zwei statisch definiert sind, ist diesmal die JNI-Funktion "GetFieldID()" zu verwenden, um die Referenz auf den Speicherplatz eines Feldes zu erhalten. Dann lässt sich (wie hier für die Variable "whrg_betrag") der Wert mit der für Long-Variablen zuständigen JNI-Funktion "GetLongField()" abgreifen (vergleiche auch Schaubild). Lassen sich eigentlich im native Code nur existierende Objekte empfangen ? Nein, man könnte auch Neue erzeugen und zurückgeben. Dazu ist die Klassendefinition zu suchen (FindClass()), der dazugehörige Konstruktor darin zu lokalisieren (GetMethodID()), das Objekt zu allokieren (NewObjekt()), die Felder zu füllen (SetXXXXField()) und abschliessend die Referenz zurückzugeben (return jobj).
CallbackBislang wurde nur über Aufrufe von Java in Richtung C(++) gesprochen. Dass auch der umgekehrte Weg gangbar ist soll das letzte Beispiel zeigen. Nachdem die Java-Klasse classfour wie in den bisherigen Beispielen auch eine Methode der Library aufgerufen hat, soll diese Library Funktion nun ihrerseits eine Methode der aufrufenden Java-Klasse ansprechen. Dabei ist der Library- Funktion die genaue Aufrufadresse der Callbackmethode zunächst unbekannt. Einzig und allein der Funktionsname muss bekannt sein einschliesslich der genauen Schreibweise (Gross-/Kleinschreibung) Da man darauf angewiesen ist die Funktion Callback() nicht nur irgendeiner Instanz der Klasse classfour anzusprechen, sondern genau diejenige der aufrufenden Instanz, muss entsprechend nicht nur auf Klassenebene, sondern auf Instanzebene danach gesucht werden. Wie aber bekommt man die Objektreferenz auf das aufrufende Objekt ? Eigentlich ist sie schon bekannt: Es ist die zweite Variable der übergebenen Parameterliste. Listing 7 und 8 zeigt eine solche Konstruktion.
Auch in diesem Beispiel taucht der erfrischend offensichtliche Signaturparameter "(I)V" auf, diesmal bei der Verwendung der JNI-Funktion "GetMethodID()". Ähnlich wie beim Zugriff auf einzelne Variablen einer Klasse (siehe zweites und drittes Beispiel) muss auch beim Zugriff auf Methoden einer Java-Klasse die Signatur mit angegeben werden. Diese Signatur folgt dabei dem Schema "Klammer auf, konkatenierte Signaturparameterliste der Übergabeargumente ohne Blank und Komma, Klammer zu, Signatur des Rückgabewertes". Beispiel gefällig ? Es ist aus C heraus eine Java-Methode "blubb" mit folgender Definition zu suchen: long blubb(int n, String s, int[] arr); Der korrekt JNI-Suchbefehl hierfür lautet (vergleiche auch Tabelle 2 mit den Typ-Signaturen): (*env)->GetMethodID(...., "blubb", "(ILjava/lang/String;[I)J"); Mag das auch beim erstmaligen Gebrauch verwirrend erscheinen, einmal korrekt ins Werk gesetzt ist das Mapping stabil und wenig fehlerträchtig.
Packages and moreHinzuweisen bleibt auf die Handhabung von package Strukturen. Ist eine Java- Klasse Teil eines packages, dann hat dies auch Auswirkung auf die Zusammenstellung des Funktionsnamens. Aus der Spezifikation ... Java_classname_functionname(...) wird dann ... Java_packagename_classname_functioname(...). Dies triff auch für jede weitere Packageverschachtelung zu. Somit wird etwa aus package xxx.yyy.zzz; ein Funktionspräfix Java_xxx_yyy_zzz_...... Im Zweifelsfall hilft immer ein Blick auf die Funktionsprototypen in der durch javah generierten headerdatei. Wie eingangs erwähnt können prinzipiell sowohl C als auch C++ Bibliotheken über JNI angesprochen werden. In der Handhabung unterscheiden sie sich nur wenig. Ein Unterschied besteht in der Codierung der JNI spezifischen Funktionen. Aus der C-Schreibweise (*env)->xxxxxx(env,.....) wird unter C++ generell env->xxxxxx(.....).
WrapperklassenNun noch einmal zurück zum Problem einer bereits existierenden Library und wie diese verwendet werden kann. Wie weiter oben schon angesprochen bedarf es nicht nur javaseitig einer geringfügigen Vorbereitung auf JNI sondern auch auf Seiten der Bibliothek. Wie kann das aber geschehen, wenn der Quellcode der Bibliothek nicht vorliegt ? Dazu liegt es nahe einfach eine Wrapperbibliothek zu schreiben, also eine Funktion, die über JNI Anfragen entgegennimmt und dann den eigentlichen Aufruf der vorgegebenen Bibliothek ausführt. Wenn ausser den Typkonvertierungen keine weitere Funktionalität in der Wrapperklasse eingebaut wird, erhält man einen transparenten Layer der einen Zugriff auf precompilierte Binaries ermöglicht.
JNI oder CorbaNachdem nun JNI und dessen Möglichkeiten vorgestellt wurden stellt sich die Frage nach der Abgrenzung zu (scheinbar) verwandten Technologien. Wird nicht in ähnlichen Szenarien, in denen es um die Kopplung unterschiedlicher Programmierwelten geht, auf Corba verwiesen ? Zur Beantwortung dieser Frage müssen die Rahmenbedingungen des Projektes betrachtet werden. Corba, das in letzter Zeit in der Praxis immer deutlicher an Bedeutung zu gewinnen scheint, ist dort erste Wahl wo es um die Nutzung von verteilten funktionalen Ressourcen geht. Der Akzent liegt hier auf dem Wort "verteilt". Bieten mehrere, möglicherweise im lokalen Netz physisch verteilte Objekt-Server Funktionalität an, die von unterschiedlichen Software-Konsumenten genutzt wird, dann ist Corba als Kommunikationsprotokoll eindeutig zu empfehlen. RMI fällt ohnehin als Alternative aus, da hierdurch nur Kommunikation zwischen einzelnen Java Bausteinen, nicht aber zu Komponenten anderer Programmiersprachen aufgebaut werden kann. JNI hingegen sollte immer dann zum Zuge kommen, wenn eine Funktionsbibliothek eindeutig EINER aufrufenden Klasse zugeordnet ist. Dies bedeutet sicherlich nicht, dass eine Funktionsbibliothek nicht auch so angepasst werden könnte, dass sie zugleich Diener zweier (oder mehrerer) Klassen sein könnte. Aber typischerweise besteht eine 1:1 Beziehung. Darüber hinaus ist auch der Kommunikations- und Verwaltungsaufwand in Betracht zu ziehen. Corba erfordert die Installation und Konfiguration von ObjectRequestBrokern (ORBs), d.h. von zusätzlicher (zumeist kostenpflichtiger) Software. Diese Software steuert den Verbindungsaufwand und Datentransport zwischen den Beteiligten. Klar, dass dies auch Performanceeinbussen nach sich zieht. Demgegenüber benötigt man für JNI keine weitere Software. Nichts ist zwischen Java und C(++). Just JNI. Dies eröffnet dem Einsatz von JNI einzigartige Chancen in Projekten in denen es auf die Wiederverwendung existierender externer Komponenten, auf Performancesteigerung sowie auf kostengünstige Realisation ankommt.
Rückfragen und Kommentare an
Literatur:
|