mike-neckのブログ

Java or Groovy or Swift or Golang

Gradle3におけるJavaプロジェクトのビルド入門(4) -- 実践編 #gradle

f:id:mike_neck:20151108133321p:plain

Gradle3におけるJavaプロジェクトのビルド入門の一連の記事ではJVM component modelによるJavaプロジェクトのビルド方法について紹介してきた。その過程でJDK9より導入されるJigsawとの関連も指摘しておいた。

この記事では実践的なJVM component modelによるJavaプロジェクトのビルドについて、(簡単ではあるが)若干複雑な例を紹介していく。例題としてはJava8で随分とおなじみになっているOptionalの再実装をおこなう。


コンポーネントの設計

Optionalを使う場合、主に使われるのが次のような機能群である。

  1. 例外(ライブラリー名:exceptions) - Javaなので、完全に関数型スタイルでのプログラミングができないため、Optional自体にmapfmapなどのメソッドを定義することになる。その際にnullを渡してしまった場合などの例外を定義してある。また、Java8のOptionalは検査例外を投げる関数などを引数に取ることができないが、そのような関数を引数にとる場合にRuntimeExceptionにラップした例外なども定義してある。
  2. 関数(ライブラリー名 : functions) - Optionalの各種メソッドmapfmapfilterなどに渡すための関数型インターフェースを定義してある。また、検査例外を投げる関数型インターフェースおよび、それをRuntimeExceptionにラップしてしまう変換のためのメソッド群も定義してある。ラップした例外を送出するためにAPIとしてexceptionsライブラリーに依存する。
  3. データ(ライブラリー名 : data) - Optionalを定義しているライブラリー。実際はOptionalという名前ではなく、Maybeという名前なのだが…。また、Maybeは単なるインターフェースであり、それらの実装クラスとしてSomeNothingを定義してある。これらは関数を引数に取るためにAPIとしてfunctionsライブラリーに依存している他、実際に例外を送出するためにexceptionsライブラリーにもAPI的に依存している
  4. テストフレームワーク(ライブラリー名 : test.framework) - Gradle2.9では外部ライブラリーを解決できないために、仕方なくつくった簡単で機能がほとんどないユニットテストライブラリー。詳しくはこちらを参照
  5. テスト(ライブラリー名 : data.test) - 最終目的であるライブラリーdataを検証するためのテストを記述しているライブラリー。当然だが、dataライブラリー、test.frameworkライブラリーに依存している。

文章で書くと、丁寧に説明できる分、理解に時間がかかるし、僕の文章は語彙が貧弱なためもあるし、気取った文章も書けないので、上記の依存関係を簡単な図に表しておく。google図形描画で描いたので、綺麗な図ではないが、別に理解の妨げになるとは思わない。

f:id:mike_neck:20151126014918p:plain


各ライブラリーの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チェックのためのクラスを定義しており、それらのクラスのインスタンス化を防ぐために内部のみで使われるクラスを持っている。そのため、APIexports指定が行う必要がある。

以上の仕様を満たすために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の実装クラスであるSomeNothingを極力簡単にしたいため、Maybeインターフェースにstaticメソッドを実装するためにtargetPlatformはJava8である必要がある(まあ、別にMaybeを抽象クラスにしても構わないのだが…)。
  • 関数を引数としてとるためにfunctionsライブラリーにAPI的に依存している。
  • 関数にnullを渡された場合にexceptionsライブラリーで容易した例外を送出するが、GradleではAPIのtransitive dependencyの制御はできないので、functionsライブラリーにAPI的に依存した結果、exceptionsライブラリーもAPI的に依存しているため、特に記述を追加する必要はない。
  • SomeNothingも実装クラスは別にユーザーから見えても仕方ないので、これらは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パッケージングタスクはassembledependsOn対象になっている
  • 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'
}

テストの実行の結果は以前のエントリーにも掲載した通りです。

f:id:mike_neck:20151121231945p:plain

よくあるハマりやすいこと

非公開パッケージで抽象クラスを作成して、それを継承したクラスを公開パッケージにいれるパターン

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でコンパイルすることを試みたいと思う。

なお、今回のプロジェクトの内容は以下のレポジトリーから入手可能である。

github.com


おわり