mike-neckのブログ

Java or Groovy or Swift or Golang

Jigsawについて雑にまとめてみた

以前、Gradle3におけるJavaプロジェクトのビルドについてのエントリーを書きましたが、Gradle3のJVM component modelって、まあ、わりと、Java9のJigsawみたいなものなんですね。で、Jigsaw自体知っている人が多いと思いますが、あらためてJigsawが解決したい問題(と僕が勝手に認識しているもの)とその解決方法について、雑にまとめてみることにしました。

なお、Gradle3のJVM component modelでのビルドについてはこちらを参照。

mike-neck.hatenadiary.com

JVM component modelとJigsawとの関連について、少し言及があるエントリーについてはこちらを参照。

mike-neck.hatenadiary.com

あと、Jigsawについて一番よくまとまっている資料見れば、このエントリーを見る必要はないと思います。

www.slideshare.net


依存性地獄

まあ、rt.jarを小さく分割して、必要なjarだけをロードするようにして、Javaの起動を速くしたいという問題もあるわけですが、それはそれで置いといて、もう少しだけ高いレベルでみた時に、依存性地獄を解決したいという問題があるかな、と勝手に解釈しています。

例えば、あるアプリケーションを作った時に、次のような依存性のツリーができたとします。

app-1.1.jar
├── service-1.1.jar
│   └── util-1.1.jar ←こいつと
└── repo-1.1.jar
    └── util-1.0.jar ←こいつがコンフリクトしてる

上記のツリーの例ではutil-1.1.jarutil-1.0.jarがバージョンのコンフリクトを起こしています。このようなバージョンのコンフリクトが発生することはよくある話です。Gradleでは、特に指定がない限り、最新のバージョンのjarを使用するように依存ツリーを修正します。したがって、アプリケーションの依存性は次のようになります。

app-1.1.jar
├── service-1.1.jar
│   └── util-1.1.jar
└── repo-1.1.jar
    └── util-1.1.jar

ただ、このように依存性を解決した時に、コンパイルが落ちてしまうことがあります。「クラスが見つかりません」的なエラーあるいはコンパイルできたとしても、実行時にNoClassDefFoundError。ただ、このアプリケーションはバージョンが1.0のときはコンパイルできていたんです。その頃の依存性ツリーはこのような感じでした。

app-1.0.jar
├── service-1.0.jar
│   └── util-1.0.jar
└── repo-1.0.jar
    └── util-1.0.jar

なぜ、こんなことに…

依存ライブラリー

さて、問題となっているutil.jarの中身が次のような構成になっているとします。

util.jar
├── com.util.api      <- ユーザーにはこっちだけ使ってもらいたい
└── com.util.internal <- 内部用なのでユーザーには使ってもらいたくない

アクセス修飾子

さて、Javaのクラスに付与できるアクセス修飾子には次の3つがあり、アクセス範囲を限定できます。

修飾子 利用可能な範囲
public どのパッケージからでも利用可能
(package private) 同一パッケージ内のみ利用可能
private(インナークラスのみ) 同一のクラス内のみ利用可能

しかし、apiパッケージにあるインターフェースの実装をinternalパッケージで行ったとしても、インスタンスを割り当てるなどする必要があるため、どうしてもユーザーには使ってもらいたくないinternalパッケージのクラスをpublicにせざるを得ない状況が発生します。

するとユーザーであるrepo.jarにこんなことをするクラスが現れます。

package com.repo.user;

import com.util.internal.SecretImpl;

public class Mal {
    // 実装省略
}

依存性地獄

さて、util.jarのバージョンがあがり、util-1.1.jarとなりました。internalパッケージの中身を更新させた結果、com.util.internal.SecretImplがなくなりました。別にこれは悪いことではありません。元々内部的に使っていたクラスを削除しただけですので。

するとユーザーであるrepo.jarutil-1.0.jarからコンパイルエラーになってしまうutil-1.1.jarにあげることができなくなります(普通はこの段階で修正するのですが、リソースの関係上修正できなかった)。仕方なしにutil-1.0.jarに依存することになります。

そしてアプリケーション全体としてutil.jarのバージョンコンフリクトが発生して、バージョンが最新のものを利用する選択をおこなったため、アプリケーション全体としてコンパイルエラーまたは、実行時にNoClassDefFoundErrorが発生します。

このような状態を依存性地獄とかDependency Hellとか呼びますね。


明示的な公開・依存の設定

依存性地獄の発生までの経緯を見てきましたが、Jigsawではこれらをどのように解決するか雑に見ていきます。

先ほどのutil.jarのパッケージ構成を再掲します。

util.jar
├── com.util.api      <- ユーザーにはこっちだけ使ってもらいたい
└── com.util.internal <- 内部用なのでユーザーには使ってもらいたくない

このutil.jarの公開ポリシーを設定するために、module-info.javaというファイルをルートディレクトリーに作成します。例えば、src/util以下にutil.jarソースコードがある場合、src/utilの下にmodule-info.javaを作成します。そして、作成したmodule-info.javaを次のように記述します。

module util {
    exports com.util.api;
}

exportsでマークされたcom.util.apiパッケージはこのutilを使うユーザーが利用できるパッケージになります。一方、exportsされなかったcom.util.internalパッケージはutilを利用するユーザーからは見ることができません。

一方のutilのユーザーであるrepoの方はどのように記述するのか見てみます。repoソースコードsrc/repo以下にあるとした場合、src/repo/module-info.javaを作成します。

module repo {
    requires util;
    exports com.repo.spi;
    exports com.repo.api;
}

このように、utilを利用することを宣言するrequiresを記述します。なお、このrepoに依存するappではrepoにて公開されているcom.repo.spicom.repo.apiパッケージを利用することができ、utilで公開されているcom.util.apiを利用することができません。

なお、apputilcom.util.apiを利用できるようにする場合は次のように記述します。

module repo {
    requires public util;
    exports com.repo.spi;
    exports com.repo.api;
}

requirespublicというキーワードを付与することで、依存しているライブラリー共々公開することができるようになります。


では、先ほどの問題になっていたcom.repo.user.Malクラスはコンパイルができるのでしょうか?

ここまで読まれた方ならすぐに答えられると思います。答えは「com.util.internal.SecretImplクラスが見つからないためコンパイルエラーが発生する」になります。


以上、Jigsawについて雑にまとめました。slideshareの資料のほうが絶対に内容は充実しています(僕のはつまみ食いだけです)。

で、Gradle3におけるJavaプロジェクトのビルド入門(3)というエントリーを今度書きますが、ここに書いたJigsawのような仕組みをGradle3でも実現する方法を紹介する予定です。


【追記】公開した後にさくらばさんから指摘があったので修正した。

module-info.javaにはバージョンを記述せず、コンパイル時に指定するようです。

【誤】

module util@1.0 {
    exports com.util.api;
}

【正】

module util {
    exports com.util.api;
}

で、コンパイル方法は次のとおりですが、まあ、モダンなビルドツールがすぐに対応してくれるでしょう。

$ #とりあえずjavacのコンパイル結果を置いておくディレクトリー
$ mkdir tmp
$ #生成されたjarを置いておくディレクトリー 
$ mkdir jars 
$ # srcディレクトリー以下の全javaファイルをコンパイルしてモジュールごとに分ける
$ javac -d -modulesourcepath src $(find src -name "*.java") 
$ # tmp/util以下のclassファイルをutil-1.0.jarというバージョン1.0のjarに固める
$ jar --create --file=jars/util-1.0.jar --module-version=1.0 -C tmp/util 
$ # tmp/repo以下のclassファイルをrepo-1.0.jarというバージョン1.0のjarに固める
$ jar --create --file=jars/repo-1.0.jar --module-version=1.0 -C tmp/repo
$ # tmp/service以下のclassファイルをservice-1.0.jarというバージョン1.0のjarに固める
$ jar --create --file=jars/service-1.0.jarr --module-version=1.0 -C tmp/service