GraalVM の native-image を作る際に利用する config.json を自動生成させて、アプリケーションを複数回実行してマージするというのをやろうとしており、 JNI を使う Java アプリケーション(JNA ではない)を Gradle でビルドする必要が出てきたが、残念なことに JNI で native を呼び出すアプリケーションを作ったことがなかったので、試しに作ってみることにした。
ところが、 Gradle で JNI を構築する場合の手順が Android だったり、古い software model だったりで、最近の Gradle C++ プラグインを利用しているものがないのでメモに残しておこうという理由でこれを書いてる。
目標
- Gradle の C++ プラグインを使って JNI を使うアプリケーションを構築する
- GraalVM の native-image-agent を使ってアプリケーションを実行して、設定値を持った
jni-config.json
を生成する
プロジェクト構成
C++ プラグインと Java アプリケーションプラグインは同じプロジェクトに同居できないので、マルチプロジェクト構成になる
root-project ├── build.gradle ├── cpp-lib ├── java-app └── settings.gradle
build.gadle
ファイルは単なる空ファイルで、 setting.gradle
はプロジェクトの include
だけをおこなっている
include 'java-app', 'cpp-lib'
Java プロジェクト
まずは 普通の Java プロジェクトの build.gradle
を作る
plugins { id 'java' id 'application' } repositories { mavenCentral() } dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.2' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.6.2' } application { mainClass = 'com.example.App' } test { useJUnitPlatform() }
続いて、 native
メソッドを持つクラスを作る
package com.example; class App { native String greetingTo(String name); public static void main(String[] args) { App app = new App(); System.out.println(app.greetingTo("Tom")); } }
これをコンパイルすると、 build/generated/sources/headers/java/main/com_example_App.h
ファイルが生成される
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class com_example_App */ #ifndef _Included_com_example_App #define _Included_com_example_App #ifdef __cplusplus extern "C" { #endif /* * Class: com_example_App * Method: greetingTo * Signature: (Ljava/lang/String;)Ljava/lang/String; */ JNIEXPORT jstring JNICALL Java_com_example_App_greetingTo (JNIEnv *, jobject, jstring); #ifdef __cplusplus } #endif #endif
CPP プロジェクト
こちらもまずは普通の CPP プロジェクトを作る
plugins { id 'cpp-library' } library { // gradle init で作った場合、 init タスクを実行したマシンの OS/Arch が設定される // マルチプラットフォームにしたい場合、それを host に書き換える targetMachines.add(machines.host()) }
しかし、このままでは、以下の2つの問題があって、ビルドできない
- Java プロジェクトで生成したヘッダーファイルが取り込まれていない
jni.h
およびjni_md.h
が取り込まれないので JNI の関数/型を解釈できない- CPP プロジェクトのビルドの段階で Java が未コンパイルでヘッダーが参照できない
生成されたヘッダーを取り込む
まずは前者を解決していく
library
ブロックの中で、 publicHeaders
にヘッダーが置いてあるディレクトリーを追加するとコンパイル時に読み込めるらしいことがドキュメントからわかる
そこで、先程のヘッダーファイルが生成されたディレクトリーを渡すようにする
library { targetMachines.add(machines.host()) def javaProject = project(':java-app') publicHeaders.from( javaProject.file("${javaProject.buildDir}/generated/sources/headers/java/main")) }
include ファイルの追加
#include <jni.h>
を解決するためには JDK の include
ディレクトリーにあるヘッダーファイル(jni.h
)を取り込む必要がある。さらに jint
、 jlong
といったプリミティブはプラットフォーム別に定義されているので、 Mac なら $JAVA_HOME/include/darwin
、 Linux なら $JAVA_HOME/include/linux
と異なるディレクトリーにある jni_md.h
を取り込む必要がある。
ビルドしている環境の OS は CppCompile
タスクの targetPlatform.operatingSystem
により判断できる
ext { include = "${System.getProperty('java.home')}/include" } tasks.withType(CppCompile).configureEach { compilerArgs.addAll targetPlatform.map { platform -> def os = platform.operatingSystem if (os.isMacOsX()) { ["-I$include", "-I$include/darwin"] } else if (os.isLinux()) { ["-I$include", "-I$include/linux"] } else if (os.isWindows()) { ["/I$include", "/I$include${File.separator}windows"] } else { ["-I$include"] } } }
CPP プロジェクトのビルドは Java プロジェクトの後に実行する
Java プロジェクトのビルドの後でないと CPP はビルドできないので、先程の CppCompile
の設定に dependsOn
を追加して、 Java プロジェクトのビルド(classes
タスク)の後になるように設定する
tasks.withType(CppCompile).configureEach {
dependsOn ':java-app:classes'
}
これらの設定により、CPP プロジェクトのビルドが可能になり、 linkRelease
/linkDebug
タスクを実行すると cpp-lib/build/lib/main/release
および cpp-lib/build/lib/main/debug
に libcpp-lib.dylib
(libcpp-lib.so
あるいは libcpp-lib.dll
) ができる
実行結果はこちらを参照
なお、書いたのはこのようなこのようなコードで、初めて C++ のコードを書いた。
#include "com_example_App.h" #include <jni.h> #include <string> jstring throwNewIllegalArgumentException(JNIEnv *env); JNIEXPORT jstring JNICALL Java_com_example_App_greetingTo (JNIEnv *env, jobject thisObject, jstring name) { if (name == NULL) { return throwNewIllegalArgumentException(env); } jsize size = env->GetStringUTFLength(name); if (size == 0) { return throwNewIllegalArgumentException(env); } std::string nameValue(env->GetStringUTFChars(name, JNI_FALSE)); std::string greeting = std::string("Hello, "); greeting += nameValue; return env->NewStringUTF(greeting.c_str()); } jstring throwNewIllegalArgumentException(JNIEnv *env) { env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), "invalid argument"); return NULL; }
アプリケーションの起動
作ったリンクライブラリーをプログラムがロードするように、アプリケーション、ビルドスクリプトを修正する
先程の Java プログラムは System#loadLibrary(String)
を呼ぶようにする
package com.example; class App { native String greetingTo(String name); static void loadLibrary() { System.loadLibrary("cpp-lib"); } public static void main(String[] args) { loadLibrary(); App app = new App(); System.out.println(app.greetingTo("Tom")); } }
また、 build.gradle
ではアプリケーションを走らせるタスク run
に以下の設定を施す
cpp-lib
プロジェクトのビルドの後に実行されるcpp-lib
プロジェクトで生成されたリンクライブラリーのあるディレクトリーをjava.library.path
に指定する
これらを満たすために java-app
プロジェクトの build.gradle
に以下を追加する
run.dependsOn ':cpp-lib:linkRelease' def cppLib = project(':cpp-lib') run.jvmArgs( "-Djava.library.path=${cppLib.buildDir}/lib/main/release", )
これでアプリケーションを実行できるようになる
なお、 java-app
プロジェクトで distZip
/ distTar
を実行して作られる配布物にはリンクライブラリーが入っていないが、これを指定する場合は次を追加する
distributions { main { contents { from ("${cppLib.buildDir}/lib/main/release") } } }
ただし、これだけではまだライブラリーのロードに失敗するが、ここでの目標とは関係がないので、以下のリンクに続きが書いてある
GraalVM native-image config
最後に GraalVM の native-image で生成する際の config.json を生成する
この手順をやる場合、事前に以下の2つの手順が必要になる
- JDK を Graal に変更する(GraalVM がインストールされていない場合は、インストールも)
gu install native-image
を実行して、native-image
コマンドとnative-image-agent
をインストールする
引き続いて、 Java アプリケーションに agent を指定して実行するように、 JVM パラメーターを追加する
また、 condig.json の出力先ディレクトリーを作るタスクに run
が依存するように修正する
class PrepareExecution extends DefaultTask { @OutputDirectory DirectoryProperty targetDirectory = project.objects.directoryProperty() @TaskAction void run() { def target = targetDirectory.get().asFile if (!target.exists()) { target.mkdirs() } } } task prepareExecution(type: PrepareExecution) { targetDirectory.set(file("$buildDir/native-image-config")) } run.dependsOn ':cpp-lib:linkRelease', 'prepareExecution' def cppLib = project(':cpp-lib') run.jvmArgs( "-Djava.library.path=${cppLib.buildDir}/lib/main/release", "-agentlib:native-image-agent=config-output-dir=$buildDir/native-image-config" )
これで run
タスクを実行すると、アプリケーションが実行されると同時に、 native-image config json が生成される。
ところで期待した jni-config.json
は以下の通り空欄になる。
jni-config.json
は JNI から Java のオブジェクトを生成したり、メソッドを呼び出したりする場合に出力されるらしい。そして、このアプリケーションでは正常ルートで実行される場合は JNI から Java のオブジェクトが生成されないので、あえて例外を発生させるコードに変更してみる。
package com.example; class App { native String greetingTo(String name); static void loadLibrary() { System.loadLibrary("cpp-lib"); } public static void main(String[] args) { loadLibrary(); App app = new App(); System.out.println(app.greetingTo(null)); } }
これは先程の CPP のコードからも明らかな通り、 greetingTo
メソッドに null
を渡した場合に例外が発生するようになっている。
if (name == NULL) { return throwNewIllegalArgumentException(env); }
では、これを実行させてみると次のようになる。
というわけで、 jni-config.json
に出力させることができた
まとめ
以下の調査をおこなった。また調べてわかった。