Java
Java9

java 9のmodule機能を試してみる

More than 1 year has passed since last update.

これはTIS Engineer Advent Calendar 2015の19日目の記事である。


色々と遅延が噂されているjava 9だが、今のところ2017年3月リリースが有力視されている。(OpenJDK9のサイトや、2015/12/15のPublicKeyの記事を参照)

実際にエンタープライズシステムでjava 9を利用するようになるのはかなり先になるだろうが、java 9に導入される(と言われている)Module Systemは、javaの保守性を向上させる強力なツールになると思われる。これはlambdaやstreamのような、javaプログラムの表現能力を向上させる攻めの技術とは対照的だ。

OSGiとの類似性(2015/10/12のInfoQの記事を参照)が指摘されるなど、Module Systemの仕様が固まっているのかと言えば微妙な気がするが、とりあえず現在手に入るEarly AccessでModule Systemのさわりを試してみよう。

環境

version
OS Ubuntu 14.04.3 (kernel 3.13.0-71-generic x86_64)
jdk 9-ea (build 9-ea+95-2015-12-07-122822.javare.4009.nc)

jdk 9 インストール

Module System(コードネーム Project Jigsaw)を含む次のバイナリ(JDK 9 Early Access with Project Jigsaw)をjava.netより取得し、適当なディレクトリに展開しパスを通すせば良い。

java9インストール
$ tar xvfz jigsaw-jdk-9-ea+95_linux-x64_bin.tar.gz
$ export JAVA_HOME="/home/vagrant/jdk-9"
$ export PATH="$JAVA_HOME/bin:${PATH}"
$ java -version
java version "9-ea"
Java(TM) SE Runtime Environment (build 9-ea+95-2015-12-07-122822.javare.4009.nc)
Java HotSpot(TM) 64-Bit Server VM (build 9-ea+95-2015-12-07-122822.javare.4009.nc, mixed mode)

OpenJDK9をリポジトリから取得してビルドしてみても良いが、非常に手間と時間がかかる。しかも2015/12/15時点のコードベースにはまだ、Module System(JSR376)のコードがマージされていないようで。。。

Module Systemの基本

Module Systemとは、javaの伝統である「classpathによって依存ライブラリを解決する」仕組みが内包する、次のような問題を解決するものだ。

  • publicなクラスはclasspath上に居るどのクラスからでも呼び出すことができるため、予期せぬjarがimportしていることに気づかずにクラスを修正し、実行時例外が発生して始めて修正の影響範囲が大きかったことに気づく
  • あるjarが他のjarに依存し、さらに連鎖して別のjarにも依存し・・・といったjarの依存関係を追い切れず、実行時例外が発生して始めてjarが足りなかったことに気づく

静的型付けやチェック例外など、「プログラムの不具合はコンパイル時になるべく検出されるべきだ」というjavaのポリシーからすると、この「実行時例外が起きて始めて不具合に気がつく」classpathとjarの関係は、イケテナイと言わざるをえない。そこで登場するのがModule Systemである。

Module Systemの詳細は、Oracleのjavaプラットフォームチームのチーフアーキテクト Mark Reinholdが書いたレポートThe State of the Module Systemを見ると良いだろう。

現在の仕組みが抱える問題

やはりサンプルコードを動作させてみるほうが、理解が進むだろう。ということで、classpathをベースにした現在の仕組みが抱える問題の典型例を二つ例示する。

※ 動作するソースコードはGithubを参照

case1 : クラス隠蔽の問題

例えば複数のクラスでサービスを形作る場合、役割に応じて復数のpackageを使い分けることもあるだろう。しかしpackageが分かれるとクラスの可視性をpublicにせざるを得ず、本来サービスの内部だけで使われる想定だったクラス(以下の例ならばUtilクラス)であっても、サービス外からimportできてしまう。

サービスを形作るクラス群

外部から使われる想定のクラス。

service/src/com/example/service/Gateway.java
package com.example.service;

import com.example.service.util.Util;

public class Gateway {
  public void method() {
    System.out.printf("%s start%n", this.getClass().getName());
    Util util = new Util();
    util.method();
    System.out.printf("%s end%n", this.getClass().getName());
  }
}

外部から使われる想定では無いクラス(文法上は単なる公開クラスでしかない)。

service/src/com/example/service/util/Util.java
package com.example.service.util;

public class Util {
  public void method() {
    System.out.printf("%s start%n", this.getClass().getName());
    System.out.printf("%s end%n", this.getClass().getName());
  }
}

コンパイルしてservice.jarにまとめる。

$ javac -d service/classes/ $(find service/src -name "*.java")
$ jar cvf jars/service.jar -C service/classes/ .

サービスを利用するクライアント

想定通りの利用方法を取るクライアント。

client/src/net/example/client/Expected.java
package net.example.client;

import com.example.service.Gateway;

public class Expected {
  public static void main(String argv[]) {
    Gateway gateway = new Gateway();
    gateway.method();
  }
}

想定外のクラスを呼び出しているクライアント(コンパイルは問題なく通る)。

client/src/net/example/client/UnExpected.java
package net.example.client;

import com.example.service.util.Util;

public class UnExpected {
  public static void main(String argv[]) {
    Util util = new Util();
    util.method();
  }
}

コンパイルしてclient.jarにまとめる。

$ javac -d client/classes/ -cp client/src:"jars/*" $(find client/src -name "*.java")
$ jar cvf jars/client.jar -C client/classes/ .

動作させる

Utilクラスを直接呼び出すことを開発者は想定していないが、問題なくコンパイルが通り動作もする。そのため「Gatewayクラスからしか使われていないはずだから」とUtilクラスを修正すると、予期せぬ実行時例外に遭遇することになる。

$ java -cp "jars/*" net.example.client.Expected
com.example.service.Gateway start
com.example.service.util.Util start
com.example.service.util.Util end
com.example.service.Gateway end

$ java -cp "jars/*" net.example.client.UnExpected
com.example.service.util.Util start
com.example.service.util.Util end

case2 : jarの依存追跡の問題

例えばあるjarを利用するクラスを書く場合、そのjarがよりプリミティブなjarに依存していることがある。しかしコンパイル済みのjarをダウンロードして使う場合、連鎖的に依存するjarが無くてもコンパイルが通ってしまうため、必要なjarが不足していることに気づかない場合がある。
(この問題を解決するためにMavenなどの様々な依存性解決ツールがあり、もはやそれらのツールの助けがなければjava PJを運営することはできないと言っても過言ではないだろう。)

例えばclient.jar → servce.jar → commons.jar という連鎖的な依存関係があるとしよう。

サービスが必要とするライブラリ

commons.jarに含まれるクラス。

commons/src/com/example/commons/lib/Library.java
package com.example.commons.lib;

public class Library {
  public void method() {
    System.out.printf("%s start%n", this.getClass().getName());
    System.out.printf("%s end%n", this.getClass().getName());
  }
}

コンパイルしてcommons.jarにまとめる。

$ javac -d commons/classes/ $(find commons/src -name "*.java")
$ jar cvf jars/commons.jar -C commons/classes/ .

サービスのクラス

service.jarに含まれるクラス。

service/src/com/example/service/Gateway.java
package com.example.service;

import com.example.commons.lib.Library;

public class Gateway {
  public void method() {
    System.out.printf("%s start%n", this.getClass().getName());
    Library lib = new Library();
    lib.method();
    System.out.printf("%s end%n", this.getClass().getName());
  }
}

コンパイルしてservice.jarにまとめる。

$ javac -d service/classes/ -cp service/src:"jars/*" $(find service/src -name "*.java")
$ jar cvf jars/service.jar -C service/classes/ .

サービスを利用するクライアント

サービスを利用するクライアント。

client/src/net/example/client/Client.java
package net.example.client;

import com.example.service.Gateway;


public class Client {
  public static void main(String argv[]) {
    Gateway gateway = new Gateway();
    gateway.method();
  }
}

ここで、commons.jarをjarsディレクトリから削除してコンパイルしてみよう。commons.jarが無くても、クライアントのソースコードはコンパイルできてしまう。

$ rm jars/commons.jar
$ javac -d client/classes/ -cp client/src:"jars/*" $(find client/src -name "*.java")
$ jar cvf jars/client.jar -C client/classes/ .

動作させる

commons.jarが無いため、当然ながら実行時例外で異常終了する。

$ java -cp "jars/*" net.example.client.Client
com.example.service.Gateway start
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/commons/lib/Library
    at com.example.service.Gateway.method(Gateway.java:8)

Module Systemによる解決

それでは、Module Systemを導入してみよう。

case3 : クラス隠蔽の解決

module宣言を用いて「外部へ公開するpackage」を明示することにより、公開宣言されていないpackageへの外部からのアクセスを制限する。これは静的にチェックされるため、違反をしているクラスがあればコンパイルエラーとして検出できる。

サービスを形作るクラス群

Gateway.javaやUtil.javaといったjavaソースコードは、従来と全く同じ。

これらのソースコードに加え、ソースツリーのトップレベル(今回の例であれば、service/srcディレクトリ)にmodule-info.javaを配置する。

service/src/module-info.java
module service {
  exports com.example.service;
}

今回のモジュールはserviceという名前で、com.example.serviceパッケージを公開している。復数のパッケージを公開してもかまわない。

なお今回はわかりやすくするためにシンプルな名前にしたが、本来はパッケージ名と同様に、ドメインの逆順を用いてユニークになるように定めたモジュール名を付ける方が良い、

コンパイルしてservice.jarにまとめる。

$ javac -d service/mods/ $(find service/src -name "*.java")
$ jar cvf mlibs/service.jar -C service/mods/ .

サービスを利用するクライアント

公開パッケージを利用する側も、module-info.javaで利用するモジュールを明示的に宣言する。

client/src/module-info.java
module client {
  requires service;
}

コンパイル時にmodule-infoのチェックが行われるため、公開パッケージ以外の公開クラスをimportしようとするとコンパイルエラーとなる。これにより、開発者が想定されていないクラスが外部から利用されることを防ぐことができる。

$ javac -d client/mods/ -mp mlibs client/src/module-info.java 
checking com/example/service/module-info

$ javac -d client/mods/ -mp mlibs client/src/net/example/client/Expected.java 
checking com/example/service/module-info

$ javac -d client/mods/ -mp mlibs client/src/net/example/client/UnExpected.java 
checking com/example/service/module-info
client/src/net/example/client/UnExpected.java:3: error: package com.example.service.util does not exist
import com.example.service.util.Util;
                               ^
client/src/net/example/client/UnExpected.java:7: error: cannot find symbol
    Util util = new Util();
    ^
  symbol:   class Util
  location: class UnExpected
client/src/net/example/client/UnExpected.java:7: error: cannot find symbol
    Util util = new Util();
                    ^
  symbol:   class Util
  location: class UnExpected
3 errors

コンパイルできたクラスをclient.jarにまとめる。

$ jar cvf mlibs/client.jar -C client/mods/ .

動作させる

モジュール内のクラスを実行する場合、従来とは異なりjava -mp <モジュールが格納されたディレクトリ> -m <モジュール名>/<実行クラスのFQDN>という形式を取る。

$ java -mp mlibs -m client/net.example.client.Expected
com.example.service.Gateway start
com.example.service.util.Util start
com.example.service.util.Util end
com.example.service.Gateway end

case4 : jarの依存追跡の解決

module-infoによるモジュール依存関係のチェックは、jar内のmodule-info.classも解析し依存関係を遡って静的にチェックされる。そのため依存するjarが欠損している場合、コンパイルエラーとして検出できる。

サービスが必要とするモジュール

従来と同様のLibrary.javaに加え、module-info.javaを配置する。

commons/src/module-info.java
module commons {
  exports com.example.commons.lib;
}

コンパイルしてcommons.jarにまとめる。

$ javac -d commons/mods/ $(find commons/src -name "*.java")
$ jar cvf mlib/commons.jar -C commons/mods/ .

サービスのクラス

従来と同様のGateway.javaに加え、module-info.javaを配置する。serviceの公開だけでなく、commonsモジュールに依存していることも宣言する。

service/src/module-info.java
module service {
  exports com.example.service;
  requires commons;
}

コンパイルしてservice.jarにまとめる。

$ javac -d service/mods/ -mp mlib/ $(find service/src -name "*.java")
checking commons/module-info
$ jar cvf mlib/service.jar -C service/mods/ .

サービスを利用するクライアント

Client.javaに加え、serviceモジュールに依存することを宣言したmodule-info.javaを配置する。

client/src/module-info.java
module client {
  requires service;
}

ここで、mlibsディレクトリからcommons.jarを削除してコンパイルしてみよう。javacはclientモジュールのmodule-infoだけでなく、service.jarに含まれるmodule-infoも遡ってチェックし、commons.jarが無いことに気づいてコンパイルエラーとする。よって実際に実行せずとも、依存するjarが欠損していることに気づくことができる。

$ rm mlib/commons.jar
$ javac -d client/mods/ -mp mlib $(find client/src -name "*.java")
checking service/module-info
error: cannot find module: commons
1 error

最後に

java 9で導入されると言われているModule Systemについて、実際に動作させてその機能性を確認してみた。暗黙的な依存関係やReflectionの扱いなど、Module Systemにはこれら以外の機能も今後実装されていく(ハズ)なので、今後も注視していると良いだろう。