mike-neckのブログ

Java or Groovy or Swift or Golang

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 にヘッダーが置いてあるディレクトリーを追加するとコンパイル時に読み込めるらしいことがドキュメントからわかる

docs.gradle.org

そこで、先程のヘッダーファイルが生成されたディレクトリーを渡すようにする

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> を解決するためには JDKinclude ディレクトリーにあるヘッダーファイル(jni.h)を取り込む必要がある。さらに jintjlong といったプリミティブはプラットフォーム別に定義されているので、 Mac なら $JAVA_HOME/include/darwinLinux なら $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/debuglibcpp-lib.dylib(libcpp-lib.so あるいは libcpp-lib.dll) ができる

実行結果はこちらを参照

github.com

なお、書いたのはこのようなこのようなコードで、初めて 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")
    }
  }
}

ただし、これだけではまだライブラリーのロードに失敗するが、ここでの目標とは関係がないので、以下のリンクに続きが書いてある

github.com


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 が生成される。

f:id:mike_neck:20200607201051p:plain

ところで期待した jni-config.json は以下の通り空欄になる。

f:id:mike_neck:20200607202343p:plain

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);
  }

では、これを実行させてみると次のようになる。

f:id:mike_neck:20200607203408p:plain

というわけで、 jni-config.json に出力させることができた


まとめ

以下の調査をおこなった。また調べてわかった。

  • Gradle の CPP プラグインで JNI を実装するパターンを調べてみた
  • GraalVM の native-image-agent を実行してみた
    • GraalVM の native-image config jsonjni-config.json には JNI から Java オブジェクトが操作される場合に、操作する内容を記述する