Gradleで特定のJarをtestRuntimeで使いたいけどWarからは除外したい

Twitterで@ruimoさんがつぶやかれていたので色々試してみた結果をまとめておきます。 (試行錯誤で徐々に追記しています。)

経緯

解決案

案1 標準APIで一応あるはずのexclude/excludes (効かない)

warタスクでもexcludesによるパターン除外が効けば良いんですけど、どうもできない感じですね...。パターン指定の仕方が悪いのかしら。Antパターン指定とかで試してみたんですが、何にも変化なしな感じ。

war {
    excludes = ["**/hoge*"]
}

案2 warタスクで自前でフィルタリング

結局「warに入らなくしたいだけ」なので、余計なruntimeなどをいじるのではなくwarに入れる処理のことろで自前フィルタリングするのが良さげです。

war {
    // 自前でフィルタリングすると一応除外できた。
    // この場合、推移的依存関係は使えないのですべての除外Jarを指定しなければならない。
    def myExcludes = ["hogehoge", "foofoo"]
    classpath = classpath.collect { file ->
        // 除外リスト内のパタンと部分一致に入っているものはnullにしておいて後でfindAllで除外する。
        myExcludes.any { file =~ it } ? null : file
    }.findAll { it } // null除外
}

どうでもいいけど、Groovyコレクション操作でfindAllの反対が欲しいなぁ。removeAllはJava標準APIに引きずられて破壊的なのが駄目な感じ。

で、この方式でWarに入るJarは除外できます。ただし、これだと推移的依存関係は辿れなくて、ファイル名マッチングをするしかないので、すべての除外Jarを指定しなければならないのが面倒です。上記でfoofooが実はbarbarにも依存してたりすると、Warの中にbarbarだけが含まれてしまいます。

案3 warタスクで自前でフィルタリング(推移的除外関係の自動解決版) (ただし、中途半端)

で、ちょっとトリッキーだけど解決策としてこんなのを考えてみました。

configurations {
    excludeFromWar
}

dependencies {
    //...

    // warタスクでの自前フィルタリングで、推移的依存関係を全て列挙したくないので、
    // このように除外目的のconfigurationsを作ってしまうと楽な気がする。
    excludeFromWar 'hoge:hogehoge:1.0'
    excludeFromWar 'foo:foofoo:1.2'
}

war {
    // 推移的依存関係を自動的に辿れるように除外用のconfigurationsを作ると便利。
    classpath = classpath.collect { file ->
        // 除外リスト内のパタンと部分一致に入っているものはnullにしておいて後でfindAllで除外する。
        configurations.excludeFromWar.contains(file) ? null : file
    }.findAll { it } // null除外
}

こうすると、たとえfoofooが実はbarbarに依存していても、foofooの除外指定からたどられて除外対象に含まれるのでbarbarも除外できます。

でも、これもまだ半端な実装になっていて、Warに含めたいbazbazがbarbarに依存していると、foofooの推移的除外関係から単純にbarbarを除外しようとするので、Warの中にbarbarが含まれず、bazbazがまともに動かなくなってしまいます。もうちょっとその辺まじめに実装すればよいかもしれませんが、特定プロジェクトの都合で確実に除外指定したいブツが分かってるなら、案2あたりで止めておく方がよさげ。

案4 providedRuntimeを使う方法 (ボツ)

Warプラグインで用意されているprovidedRutimeを使えば、もしかしてこれだけで良かったりする?

War プラグインは providedCompile と providedRuntime の2つの依存構成を追加します。 これらの構成は WAR アーカイブには追加されないという点を除けば、それぞれ compile、runtime と同じスコープを持ちます。 provided 構成が推移的に機能することは特筆すべき点です。 http://gradle.monochromeroad.com/docs/userguide/war_plugin.html

configurations {
    // まずはwarで不要なものをruntimeから除外してしまう。
    runtime.exclude module: 'hogehoge'
    runtime.exclude module: 'foofoo'
}

dependencies {
    // warには不要であるがruntime/testRuntimeで必要なJarをあらためてprovidedRuntimeで宣言しなおす。
    providedRuntime 'hoge:hogehoge:1.0'
    providedRuntime 'foo:foofoo:1.2'
}

ruimoさんのユースケースだとこれで良いような気がしてます(動作確認してない)。

案5 providedRuntimeを使う方法(DRY版) (ボツ)

だいぶシンプルになりましたが、除外設定とprovidedRuntime指定の2箇所でJarの情報が分散してるのがDRYではありませんね。でも、そこは心配ありません。Groovyなのでこんな書き方ができます。

// こうして一元管理もできる。そう、Groovyならね。
def excludesFromWar = [
    [group: 'hoge', module: 'hogehoge', version: '1.0'],
    [group: 'foo', module: 'foofoo', version: '1.2'],
]

configurations {
    // まずはwarで不要なものをruntimeから除外してしまう。
    excludesFromWar.each { runtime.exclude it }
}

dependencies {
    // warには不要であるがruntime/testRuntimeで必要なJarをあらためてprovidedRuntimeで宣言しなおす。
    excludesFromWar.each { providedRuntime "${it.group}:${it.module}:${it.version}" }
}

案6 直接testRuntime/testCompileに再設定する方法 (ボツ) [4/17 21:00頃追記]

providedRuntimeはテスト実行時(testRuntime)には入ってこないようです。 と、よく考えたらテストで使えれば良いのだから直接testRuntime、コンパイル時も必要ならtestCompileに設定すればいいじゃないか、と気がつきました。なお、testCompileにいれておけばtestRuntimeにも含まれます。

ということで、たぶんこれでFAな気がするんですがどうでしょうかね。

// こうして一元管理もできる。そう、Groovyならね。
def excludesFromWar = [
    [group: 'org.codehaus.groovy', module: 'groovy-all', version: '2.1.6'],
    [group: 'org.spockframework', module: 'spock-core', version: '0.7-groovy-2.0'],
]

configurations {
    // まずはwarで不要なものをruntimeから除外してしまう。
    excludesFromWar.each { runtime.exclude it }
}

dependencies {
    compile project(':subproject-a')

    // warには不要であるがtestCompileで必要なJarをあらためて追加する。
    excludesFromWar.each { testCompile "${it.group}:${it.module}:${it.version}" }
}

できあがるWarファイルの中身: (groovy-allもspock-coreも含まれていない)

  Length     Date   Time    Name
 --------    ----   ----    ----
        0  04-17-14 21:36   META-INF/
       25  04-17-14 21:36   META-INF/MANIFEST.MF
        0  04-17-14 21:36   WEB-INF/
        0  04-17-14 21:36   WEB-INF/lib/
     2329  04-17-14 21:35   WEB-INF/lib/subproject-a.jar
 --------                   -------
     2354                   5 files

Gradle上の依存関係:

$ gradle dep
:dependencies

------------------------------------------------------------
Root project
------------------------------------------------------------

archives - Configuration for archive artifacts.
No dependencies

compile - Compile classpath for source set 'main'.
\--- project :subproject-a
     \--- org.codehaus.groovy:groovy-all:2.1.6

default - Configuration for default artifacts.
\--- project :subproject-a

groovy - The Groovy libraries to be used for this Groovy project. (Deprecated)
No dependencies

providedCompile - Additional compile classpath for libraries that should not be part of the WAR archive.
No dependencies

providedRuntime - Additional runtime classpath for libraries that should not be part of the WAR archive.
No dependencies

runtime - Runtime classpath for source set 'main'.
\--- project :subproject-a

testCompile - Compile classpath for source set 'test'.
+--- project :subproject-a
|    \--- org.codehaus.groovy:groovy-all:2.1.6
+--- org.codehaus.groovy:groovy-all:2.1.6
\--- org.spockframework:spock-core:0.7-groovy-2.0
     +--- junit:junit-dep:4.10
     |    \--- org.hamcrest:hamcrest-core:1.1 -> 1.3
     +--- org.codehaus.groovy:groovy-all:2.0.5 -> 2.1.6
     \--- org.hamcrest:hamcrest-core:1.3

testRuntime - Runtime classpath for source set 'test'.
\--- project :subproject-a

案7 直接testRuntime/testCompileに再設定する方法(正常動作版) [4/18 16:30 追記]

案6は実は駄目ですね。うまく動いてると勘違いしたのはサンプルコードがまずかったです。依存先のsrc/mainのクラスとして何かに外部モジュールBに更に依存していて、そのBに対する制御をメイン側のPJでやるというサンプルじゃないと駄目なのでした。

何が問題かというと、上の出力を見てわかるようにtestRuntimeに追加したはずの依存モジュールが入っていない(testRuntimeはtestCompileを引き継ぐので本来はtestCompileと同じだけの品揃えがあるべき)。 実は、configurationsでごっそりruntimeからexcludeしてしまうと、メインPJのruntime系の依存性もすべてexcludeされてしまうのでした...。 runtimeから除外してtestRuntimeに追加してるのに、excludeの評価が「runtime系全体」にかかるのはおそらく内部実装の都合上な印象を受けます。が、そうなっているものは仕方がない。

依存先モジュール/プロジェクトごとに個別に推移的依存性をexcludeすればOKなので、こうやれば意図通りに動きました。

// こうして一元管理もできる。そう、Groovyならね。
def shouldExcludesFromWar = [
    [group: 'org.apache.commons', module: 'commons-lang3', version: '3.3.1'],
]

def excludesFromWar = {
    // configurationsの代わりに個別にexcludeすれば、
    // このプロジェクト自体のtestCompile/testRuntimeへの追加は意図通りに動作する。
    shouldExcludesFromWar.each { exclude module: it.module }
}

dependencies {
    compile project(':subproject-a'), excludesFromWar
    compile 'org.codehaus.groovy:groovy-all:2.1.6'
    testCompile 'org.spockframework:spock-core:0.7-groovy-2.0'

    // warには不要であるがtestCompile/testRuntimeで必要なJarをあらためて追加する。
    shouldExcludesFromWar.each { 
        // test配下の実行時だけ必要な場合
        testRuntime "${it.group}:${it.module}:${it.version}"

        // test配下のコンパイル時も必要な場合
        //testCompile "${it.group}:${it.module}:${it.version}"
    }
}

案8 直接testRuntime/testCompileに再設定する方法(超シンプル版) [4/18 16:30 追記]

http://stackoverflow.com/a/23131137/1257166 のOpal氏の2案目をみて気づきましたが、依存モジュールごとの推移的依存先について選択的ではなくごっそりすべてexcludeして良いなら、これだけでOKです。

dependencies {
    compile project(':subproject-a'), { transitive = false }
    compile 'org.codehaus.groovy:groovy-all:2.1.6'
    testCompile 'org.spockframework:spock-core:0.7-groovy-2.0'

    // warには不要であるがtestCompile/testRuntimeで必要なJarをあらためて追加する。
    testRuntime 'org.apache.commons:commons-lang3:3.3.1'
}

ぐるっと一周してすごくシンプルになりましたね。

参考コード

実際にテストまで動くサンプルは https://github.com/nobeans/gradle-war-excludes-jar-sample/ においてあります。