Gradle3におけるJavaプロジェクトのビルド入門の一連の記事ではJVM component modelによるJavaプロジェクトのビルド方法について紹介してきた。その過程でJDK9より導入されるJigsawとの関連も指摘しておいた。
この記事では実践的なJVM component modelによるJavaプロジェクトのビルドについて、(簡単ではあるが)若干複雑な例を紹介していく。例題としてはJava8で随分とおなじみになっているOptional
の再実装をおこなう。
コンポーネントの設計
Optional
を使う場合、主に使われるのが次のような機能群である。
- 例外(ライブラリー名:
exceptions
) - Javaなので、完全に関数型スタイルでのプログラミングができないため、Optional
自体にmap
やfmap
などのメソッドを定義することになる。その際にnull
を渡してしまった場合などの例外を定義してある。また、Java8のOptional
は検査例外を投げる関数などを引数に取ることができないが、そのような関数を引数にとる場合にRuntimeException
にラップした例外なども定義してある。 - 関数(ライブラリー名 :
functions
) -Optional
の各種メソッドmap
、fmap
、filter
などに渡すための関数型インターフェースを定義してある。また、検査例外を投げる関数型インターフェースおよび、それをRuntimeException
にラップしてしまう変換のためのメソッド群も定義してある。ラップした例外を送出するためにAPIとしてexceptions
ライブラリーに依存する。 - データ(ライブラリー名 :
data
) -Optional
を定義しているライブラリー。実際はOptional
という名前ではなく、Maybe
という名前なのだが…。また、Maybe
は単なるインターフェースであり、それらの実装クラスとしてSome
とNothing
を定義してある。これらは関数を引数に取るためにAPIとしてfunctions
ライブラリーに依存している他、実際に例外を送出するためにexceptions
ライブラリーにもAPI的に依存している - テストフレームワーク(ライブラリー名 :
test.framework
) - Gradle2.9では外部ライブラリーを解決できないために、仕方なくつくった簡単で機能がほとんどないユニットテストライブラリー。詳しくはこちらを参照。 - テスト(ライブラリー名 :
data.test
) - 最終目的であるライブラリーdata
を検証するためのテストを記述しているライブラリー。当然だが、data
ライブラリー、test.framework
ライブラリーに依存している。
文章で書くと、丁寧に説明できる分、理解に時間がかかるし、僕の文章は語彙が貧弱なためもあるし、気取った文章も書けないので、上記の依存関係を簡単な図に表しておく。google図形描画で描いたので、綺麗な図ではないが、別に理解の妨げになるとは思わない。
各ライブラリーのDSL
exceptions
ライブラリー
これは特に依存するライブラリーもないので、最も簡単に記述できる。なお、別にJava8にこだわる必要もなかったので、あえてJava6でコンパイルしている。もちろんJava6でなくてJava8でもよい。
model {
components {
exceptions(JvmLibrarySpec) {
targetPlatform 'java6'
}
}
}
なお、ソースコードは以下のディレクトリーに格納していく。
- javaソース :
src/exceptions/java
- resources :
src/exceptions/resources
functions
ライブラリー
これは以下の仕様を満たさなければならないライブラリーである。
exceptions
ライブラリーに依存する。- 関数合成をおこなうためにインターフェースに
default
メソッドを付与したかったためJava8でコンパイルする必要がある - その他の(モノイド的な)関数合成や、関数の
null
チェックのためのクラスを定義しており、それらのクラスのインスタンス化を防ぐために内部のみで使われるクラスを持っている。そのため、APIのexports
指定が行う必要がある。
以上の仕様を満たすためにDSLは次のようになっている。
model { components { functions(JvmLibrarySpec) { targetPlatform 'java8' api { exports 'com.sample.func.api' dependencies { library 'exceptions' } } } } }
ソースコードの格納ディレクトリーは下記の通り
- javaソース :
src/functions/java
- resources :
src/functions/resources
data
ライブラリー
これは次のような仕様をもつライブラリーである。
Maybe
の実装クラスであるSome
もNothing
を極力簡単にしたいため、Maybe
インターフェースにstatic
メソッドを実装するためにtargetPlatform
はJava8である必要がある(まあ、別にMaybe
を抽象クラスにしても構わないのだが…)。- 関数を引数としてとるために
functions
ライブラリーにAPI的に依存している。 - 関数に
null
を渡された場合にexceptions
ライブラリーで容易した例外を送出するが、GradleではAPIのtransitive dependencyの制御はできないので、functions
ライブラリーにAPI的に依存した結果、exceptions
ライブラリーもAPI的に依存しているため、特に記述を追加する必要はない。 Some
もNothing
も実装クラスは別にユーザーから見えても仕方ないので、これらはexports
しない。
以上の条件を満たすために、data
ライブラリーのDSLは次のようになっている
model { components { data(JvmLibrarySpec) { targetPlatform 'java8' api { exports 'com.sample.data.api' dependencies { library 'functions' } } } } }
ソースコードの格納ディレクトリーは下記の通り
- javaソース :
src/data/java
- resources :
src/data/resources
test.framework
ライブラリー
もともとこれはtest-framework
ライブラリーとして定義していたのだが、Jigsawで-
(ハイフン)をモジュール名として利用できないことが後でわかったために、-
を.
に変更してtest.framework
として定義することになった。
これは特に何かに依存するライブラリーでもないが、以前の記事に記述したようにラムダ式を使ってテストデータの変換を行っていくためにJava8で記述している。したがって、そのDSLはこのような形になっている。
model { components { 'test.framework'(JvmLibrarySpec) { targetPlatform 'java8' } } }
ソースコードの格納ディレクトリーは下記の通り
- javaソース :
src/test.framework/java
- resources :
src/test.framework/resources
data.test
ライブラリー
これは単にテストを実行するだけのライブラリーである。したがって、何らかのライブラリーをexports
する必要もないので、api{}
ブロックを使わず、data
ライブラリー、test.framework
ライブラリーへの依存関係を記述している。
model { components { 'data.test'(JvmLibrarySpec) { targetPlatform 'java8' sources { java { dependencies { library 'data' library 'test.framework' } } } } } }
ソースコードの格納ディレクトリーは下記の通り
- javaソース :
src/data.test/java
- resources :
src/data.test/resources
以上、個々のライブラリーのDSLを書くためにいちいちcomponents{model{}}
の記述をとったが、これらはひとまとまりにして構わない。したがって、model
の記述は次のようになっている。
model { components { exceptions(JvmLibrarySpec) { targetPlatform 'java6' } functions(JvmLibrarySpec) { targetPlatform 'java8' api { exports 'com.sample.func.api' dependencies { library 'exceptions' } } } data(JvmLibrarySpec) { targetPlatform 'java8' api { exports 'com.sample.data.api' dependencies { library 'functions' } } } 'test.framework'(JvmLibrarySpec) { targetPlatform 'java8' } 'data.test'(JvmLibrarySpec) { targetPlatform 'java8' sources { java { dependencies { library 'data' library 'test.framework' } } } } } }
ビルド
各ライブラリーのJarへのパッケージングはassemble
タスクのdependsOn
に指定されているので、assemble
タスクを実行すればよい。また、build
タスクはassemble
タスクに依存しているので、build
タスクの実行でも良い。
各ライブラリーは前回のAPI
の説明にもあった通り、apiJar
と内部用のjarの二種類が生成される。それらはbuild/jars
ディレクトリーの下に生成される。それらも含めて確認してみたい。
$ gradle assemble :compileExceptionsJarExceptionsJava 警告: [options] ブートストラップ・クラスパスが-source 1.6と一緒に設定されていません 警告1個 :createExceptionsJar :createExceptionsApiJar :exceptionsJar :compileFunctionsJarFunctionsJava :createFunctionsJar :createFunctionsApiJar :functionsJar :compileDataJarDataJava :createDataJar :createDataApiJar :dataJar :compileTest.frameworkJarTest.frameworkJava :createTest.frameworkJar :createTest.frameworkApiJar :test.frameworkJar :compileData.testJarData.testJava :createData.testJar :createData.testApiJar :data.testJar :assemble BUILD SUCCESSFUL Total time: 2.247 secs $ tree build/jars build/jars/ ├── data.testApiJar │ └── data.test.jar ├── data.testJar │ └── data.test.jar ├── dataApiJar │ └── data.jar ├── dataJar │ └── data.jar ├── exceptionsApiJar │ └── exceptions.jar ├── exceptionsJar │ └── exceptions.jar ├── functionsApiJar │ └── functions.jar ├── functionsJar │ └── functions.jar ├── test.frameworkApiJar │ └── test.framework.jar └── test.frameworkJar └── test.framework.jar 10 directories, 10 files
以上から明らかなようにライブラリー1つにつき、APIのjarとinternal jarが1つずつ作成されている。基本的には参照するjarの管理はGradleの方で行ってくれるが、別のコンポーネントを作成する場合はfooApiJar
の中に入っている方のjarファイルを参照する。
例えば、dataApiJar
およびdataJar
を解凍した場合、次のようなファイル構成になっている。
$ mkdir -p build/files/data/api build/files/data/internal $ unzip -q build/jars/dataApiJar/data.jar -d build/files/data/api $ unzip -q build/jars/dataJar/data.jar -d build/files/data/internal $ tree build/files/data/ build/files/data/ ├── api │ ├── META-INF │ │ └── MANIFEST.MF │ └── com │ └── sample │ └── data │ └── api │ └── Maybe.class └── internal ├── META-INF │ └── MANIFEST.MF └── com └── sample └── data ├── api │ └── Maybe.class └── internal ├── MaybeBase.class ├── Nothing.class └── Some.class 13 directories, 7 files
dataApiJar
ではない方のjarファイル(dataJar/data.jar
)を参照して次のコンポーネントを作成した場合、dependency hellに陥ることは目に見えている。
テストの実行
せっかくtest.framework
を作ったことだし、data.test
ライブラリーがあるので、テストを実行しましょう。
現在のGradleにはリンカーもないし、Main属性を与えるDSLもないため、Gradleの知識を総動員して、テスト実行タスクを作成します。利用できる条件は次のとおりです。
- ライブラリーのjarパッケージングタスクは
assemble
のdependsOn
対象になっている - jarパッケージングタスクは調査の結果、
JarBinarySpec
のインスタンス - 生成される二つのjarファイルはタスクの
getJarFile()
とgetApiJarFile()
メソッドで取得できる
これで必要なjarを集めることが可能で、main
メソッドを持つクラスも判明しているので、テスト実行タスクは次のように書くことができます。
task dataTestExec(type: JavaExec, dependsOn: 'assemble') { def jarFiles = tasks.assemble.dependsOn.findAll { it instanceof JarBinarySpec }.collect { [it.jarFile, it.apiJarFile] }.flatten() classpath = files(jarFiles) main = 'com.sample.Main' }
テストの実行の結果は以前のエントリーにも掲載した通りです。
よくあるハマりやすいこと
非公開パッケージで抽象クラスを作成して、それを継承したクラスを公開パッケージにいれるパターン
package com.foo.api; import com.foo.internal.AbstractInternalType; public class ApiType extends AbstractInternalType { // 実装省略 }
これはコンパイル時にAbstractInternalType
を解決しようとすると非公開パッケージに到達して、アクセスできなくなるために、コンパイルエラーとなる。これは推測ではあるが、API jarは、そのAPI jarが依存する別のAPI jarのみを参照して、非公開パッケージの方のjarファイルを参照せずにコンパイルしているためだと考えられる。しかし、--info
を用いて出力される情報を見てみたが、JDKのCompiler APIをいじったコンパイラーを利用しているようなので、具体的な内容についてはわからなかった。
結論
以上がGradle3におけるJVM component model によるJavaプロジェクトのビルドである。
なお、この機能はまだincubating
が外されていない不安定なAPIとされているため、今後に変更が入るかもしれない。特にmain
のあるクラスのMANIFEST.MF
をいじることができないことや、外部依存性解決機能がないことなど、機能がまだまだ不十分である(そのためにテスティングフレームワークも自作せざるをえなかった)。外部依存性解決機能についてはGradle2.10にて導入されるという話を伺ってはいるが、実際にそれが使い物になるかどうかはまだわからない。また、Jigsawにあるrequire public
のようなtransitive dependencyの選択機能も取り込まれるかもしれない。
とはいえ、ここまでで紹介した基本的な機能で大分安定してビルドが可能なので、ここまでで紹介したDSLが変更されることもないと思われる。実際に、Gradleのコミットログを追っていると、このあたりに関する大きな変更がないためである。
次のエントリーではこのプロジェクトをJigsawでコンパイルすることを試みたいと思う。
なお、今回のプロジェクトの内容は以下のレポジトリーから入手可能である。
おわり