Gradle の C++ プラグインを使って JNI から C++ のコードを呼び出すアプリケーションをビルドする(Android プロジェクトではない)
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 に出力させることができた
まとめ
以下の調査をおこなった。また調べてわかった。