mike-neckのブログ

Java or Groovy or Swift or Golang

Java のモジュールと ServiceLoader その2 マルチプロジェクトで実験

GraalVM の native-image で javac のネイティブ化に失敗したことの原因が Java のモジュールシステムの理解不足にあることからモジュールシステムを一から勉強し直している、日本で 1,000,000 番目に Java のモジュールシステムに詳しいものです。

今回は自分でモジュールを書いて ServiceLoader で実装クラスの名前を知らなくてもインスタンスを取得するという実験をやっていきたいと思います。


例によって時間のない人のための 1 行まとめ

おまけで、まとめの行数を増やしておきました。

時間がないけど、 javadoc 読みたくない人のための 4 行まとめ

  1. 実装クラスを提供するモジュールにて provider configuration(META-INF/services/foo.bar.baz.Interface)ファイルの代わりに module-info.javaprovide <インターフェース名> with <実装クラス名> を書く
  2. インターフェースを利用するモジュールにて module-info.javauses <インターフェース名> を記述する
  3. Gradle でサブプロジェクトによって、モジュールを構成した場合は、 java.modularity.inferModulePathtrue を設定する
  4. いいから ServiceLoaderjavadoc を読め
  5. GraalVM の native-image はモジュールシステムに2021年2月現在は対応していないため、諦めてください

おまけで、まとめの行数を増やしておきました。


設計

モジュールシステムの実験をするにあたり、どのような構成が必要であるかを考えます。

  1. ServiceLoader で読み込む対象のインターフェース(api モジュールとします)
    1. で作ったインターフェースを実装した ServiceLoader に実際に読み込まれるクラス(impl モジュールとします)
  2. 上記のインターフェースをコンパイル時に参照できて、実行時に実装クラスを取得するメインアプリケーション(app モジュールとします)

そして、これらのモジュールと Gradle のサブプロジェクトを同一のものとして扱うようにします。

f:id:mike_neck:20210220134848p:plain

3 つのプロジェクトがあるマルチプロジェクトを上記参照関係で構成すれば、今回の実験を行えそうです。

今回の実験のレポジトリーはこちらです

github.com

api プロジェクト (インターフェース)build.gradle はこんな感じです(主要な部分だけ書いています)

plugins {
  id 'java-library'
}

impl プロジェクト(実装クラス)build.gradle

plugins {
  id 'java-library'
}

dependencies {
  implementation(
      project(':api'))
}

java {
  modularity
      .inferModulePath
      .set(true) //(1)
}

(1) Gradle でモジュールを使う場合にコンシューマー側のプロジェクトで、modularity.inferModulePath に対して true を設定する必要があります。これを設定することにより、 Java モジュールパスが指定されるようになります。詳しくは Gradle のドキュメントを参照ください docs.gradle.org

app プロジェクト(コンシューマー)build.gradle

plugins {
  id 'java'
  id 'application'
}

dependencies {
  implementation(
      project(':api')) //(1)
  runtimeOnly(
      project(':impl')) //(2)
}

java {
  modularity
      .inferModulePath
      .set(true)
}

application {
  mainClass
      .set('com.example.app.App')
  mainModule
      .set('app') //(3)
}

(1) コンパイル時に api プロジェクトのインターフェースが参照できる必要があるので、 implementationapi プロジェクトを指定します

(2) 実行時に参照できればいいので runtimeOnly で impl プロジェクトを指定します

(3) モジュールを利用したアプリケーションの場合に、 application.mainModulemain メソッドのあるモジュールの名前を指定します

これで、プロジェクトを構成できたので、後は適当なインターフェースとその実装と利用するアプリケーションを書くだけです


実装

アプリケーションを書くだけですなんて簡単そうなことを言っておいてから、改めて言うのもおかしな話ですが、 module-info.java を正しく書かないと実験に失敗します。ただし、たいていは IDE が補完してくれるので、それほど難しい作業ではありません。

api プロジェクト (インターフェース)のコード

package com.example.api;

public interface Messager {
  String message();
}

簡単にするため api プロジェクトでは、なんらかのメッセージを返してくれるインターフェースを定義することにしました。

そして、 api プロジェクトの module-info.java は次のような定義になります。

module api {
  requires java.base; //(1)
  exports
      com.example.api; //(2)
}

(1) Messager インターフェースは String を参照するので、 java.base モジュールを requires ディレクティブに設定します。しかし、 java.base の場合は省略可能です。

(2) Messager インターフェースを内包する com.exmple.api パッケージはほかのモジュールに参照してもらいたいので、 exports ディレクティブに com.exmple.api パッケージを指定します。

impl プロジェクト(実装クラス)のコード

Messager の実装クラスには特別な記述は必要ありません。また、 provider configuration ファイル(src/main/resources/META-INF/services/com.example.api.Messager) も作成しません。

module-info.java の記述は以下のようになります。

module impl {
  requires api;

  provides //(1)
    com.example.api.Messager
  with
    com.example.impl.HelloMessager;
}

(1) provides ディレクティブは、 ServiceLoader でロードできるようにする実装クラスを記述します。ここでは、 HelloMessager クラスが Messager インターフェースの実装クラスとして提供されていることを示します。これが Java のモジュールシステムにおける provider configuration(META-INF/services/ 以下のファイル)の代わりとなります。

app プロジェクト(コンシューマー) のコード

ServiceLoader でインターフェースを読み込むアプリケーションのコードも特別なことはしていません。実行時にログがわかりやすくなるようにアプリケーション起動後の文字色を変えています。

package com.example.app;

import com.example.api.Messager;
import java.util.ServiceLoader;

public class App {
  public static void main(String[] args) {
    System.out.println("\u001B[33mapp start\u001B[0m");
    for (Messager messager :
          ServiceLoader.load(Messager.class)) {
      System.out.println(messager.message());
    }
  }
}

アプリケーションのプロジェクトの module-info.java は次のようになっています。

module app {
  requires api;
  uses // (1)
    com.example.api.Messager;
}

(1) ServiceLoader で読み込むインターフェースの名前を uses ディレクティブに記述します。


実行

Gradle でアプリケーションを起動します。(IDE の実行ボタンでもいいです)

f:id:mike_neck:20210221211226p:plain

というわけで、 provider configuration ファイルなしでも ServiceLoader によって実装クラスをロードできました。でも、まあ、これは想定された結果です。


一旦 Gradle でモジュールを使ったアプリケーションの起動ができたところで、今回の目標は達成ですが、もう少しだけ進めたいと思います。

native-image-agent

僕の作っている org.mikeneck.graalvm-native-image という Gradle のプラグインは、 GraalVM の native-image コマンドのラッパープラグインです。そして、このプラグインのオプション機能として、 generateNativeImageConfig というタスクにより native-image の configuration.json ファイルを生成できます。この configuration.json ファイルにリフレクションや JNI 、リソースファイルを指定すると、 native-image は生成するバイナリーにこれらのリソースやメソッド呼び出しをコンパイルしてくれます。 generateNativeImageConfig は単純にアプリケーションを何度も実行して、 native-image-agent によって生成された json をマージして生成しています。では、この generateNativeImageConfig を実行してみましょう。

app プロジェクト(コンシューマー)build.gradle を次のように書き換えます

plugins {
    id 'java'
    id 'application'
    id 'org.mikeneck.graalvm-native-image' version 'v1.2.0' //(1)
}

dependencies {
  implementation(
      project(':api'))
  runtimeOnly(
      project(':impl'))
}

java {
  modularity
      .inferModulePath
      .set(true)
}

application {
  mainClass
      .set('com.example.app.App')
  mainModule
      .set('app')
}

nativeImage {
    graalVmHome = System.getProperty('java.home') //(2)
    mainClass = 'com.example.app.App' //(3)
    executableName = 'jmodl'
    arguments {
        add '--no-fallback'
        add '--enable-all-security-services'
        add '--report-unsupported-elements-at-runtime'
    }
}

generateNativeImageConfig {
    enabled = true
    byRunningApplicationWithoutArguments()
}

これで、 generateNativeImageConfig タスクを実行すると、 build/native-image-config ディレクトリーに、各種 config.json が出力されます。

さて、 ServiceLoader は仕様としてパラメーターなしのコンストラクターを起動することから、内部的にはリフレクションを使って実装クラスのインスタンスを生成します。したがって、生成される config.json のうちの reflection-config.json には必ず HelloMessager クラスの <init> メソッドが記載されているはずです。

では実行してみましょう。

一旦、 clean タスクによって、 build ディレクトリーを消した状態から、 generateNativeImageConfig を実行します。

f:id:mike_neck:20210222010126p:plain

f:id:mike_neck:20210222012332p:plain

あれ? HelloMessagermessage が出力されていないですね…まあ、これは理由はわかっていて、モジュールシステムによって ServiceLoader を利用したため、 provider configuration ファイルがなくて、 Messager の実装クラスとして HelloMessager を認識できていないためです。最初に Gradle の java コンベンションオブジェクトの設定で modularity.inferModulePath を設定したのは、 jar で起動しないためです 。そして、これは僕の graalvm-native-image-plugin の機能不足で jar 起動のみに対応しており、モジュール起動に対応していないためです。

そして、当然ながら、生成された config.json も空っぽです。

f:id:mike_neck:20210222015750p:plain

というわけで、 graalvm-native-image-plugin の generateNativeImageConfig についてはイシューを立てておきました(自分のレポジトリー)。

github.com


では、 graalvm-native-image-plugin の機能ではだめということで、通常の JavaExec にモジュールを利用する設定を行った上で、 native-image-agent を agentlib にして実行してみることにしましょう。

app プロジェクト(コンシューマー)build.gradle に次の記述を追加します

task runByModuleWithAgent(type: JavaExec, group: 'application') {
  modularity
    .inferModulePath
    .set(true)
  mainModule
    .set('app')
  classpath =
    sourceSets.main.output +
    configurations.runtimeClasspath // (1)
  jvmArgs("-agentlib:native-image-agent=config-output-dir=$buildDir/$name")//(2)
}

(1) 依存ライブラリーとjavac でコンパイル・生成したアウトプットを classpath に登録します

(2) native-image-agent を使って、リフレクションの情報などの config.json を出力させます

これで、モジュールによって ServiceLoader の読み込みから config.json を出力させられそうです(フラグ)!では試してみましょう。

f:id:mike_neck:20210223001730p:plain

アプリケーションの実行は問題なくできており、 ServiceLoader によって読み込まれた HelloMessager のメッセージが出力されています。

さあ、それでは reflect-config.json を見てみましょう!

f:id:mike_neck:20210223002012p:plain

あれ? reflect-config.json に何も出力されていませんね…何が良くないのかわかりませんね…

よくわからないので、GraalVM の Slack で質問してみることにしました。

f:id:mike_neck:20210223005312p:plain

どうやれば、モジュールによる ServiceLoader で config.json 出力できる?

f:id:mike_neck:20210223005345p:plain

モジュールのサポートは仕掛中だよ。ワークアラウンドがあるけど、最新のビルドが必要


というわけで、対応してないものはどうしようもないので、とりあえず完成を待つことにしましょう

なお、最初から、 slack で質問しておけば、儂は Java のことが全然わかってないなどと落ち込むこともなかったかもしれません。なお、調べている最中に ServiceLoader に新しい機能が追加されていたようなので、次回に紹介します。