🐘

Gradle再入門

2023/04/11に公開

はじめに

Javaプロジェクトの依存関係解決やjarのビルドなど、Java固有のビルドツールというイメージのあるGradleですが、
一般的なファイル操作やコマンド呼び出しなども容易に行える汎用性を備えています。

Gradleの解説は、どうしてもJavaのビルドに必要な「おまじない」が話の中心になってしまい、
そこから少しでもレールを外れるとどうすればいいかわからなくなりがちです。

ここでは、Gradleのフレームワークとしての輪郭をおさえつつ
やりたいことをやるための部品と、その組み上げ方について開発者があたりをつけられるような記事を目指しています。

前提知識

  • プロになるJavaをひととおり読んだことがあるぐらいの知識を想定しています
    • Javaで数値・文字列の演算、ファイルの読み書きをしたことがある
    • Javaでオブジェクト指向に触れている
    • javac, javaコマンドを叩いたことがある
    • ごく小さなGradleのスクリプト(build.gradle)を書いたことがある、あるいはテンプレをコピペして実行したことがある
    • build.gradle以外にGroovyは書いたことがない

1. 準備

Gradleを動かすため、次のものをインストールしておきます。

2. 少しずつ書いてみる

Gradleは汎用的なビルドスクリプトのためのフレームワークです。

ビルドスクリプトの記述には次の言語を選択できます

最近はKotlin版がファーストチョイスになると思いますが、新しめの機能をKotlinで書くと細かいハマりどころがあるため、とりあえず歴史の古いGroovyを使って説明します。

(落ち着いたらKotlinで書き直します)

JavaのスクリプトとしてのGradle

まずはGradleのフレームワークが提供する機能、Groovy言語の機能を使わず、Javaだけで書いてみます。

build.gradleというファイルを作り、

System.out.println("Hello World!");

と書いてターミナルから gradle build を同ディレクトリで実行すると、GradleのログとともにHello World!が出力されます。
このように、Javaをスクリプトとして実行しているかのようにコードが書けてしまいます。

しかも最近のJavaはファイル操作やコマンド実行が簡単にできるようになっているので、例えば

import java.nio.file.Path;
import java.nio.file.Files;

Files.writeString(Path.of("./abc.txt"), """
aaa
bbb
ccc
""");

var str = Files.readString(Path.of("./abc.txt"));
System.out.println(str);

Files.copy(Path.of("./abc.txt"), Path.of("./abc2.txt"))

Files.delete(Path.of("./abc.txt"))

// プロセス起動(https://github.com/gradle/gradle/issues/16716)
new ProcessBuilder("java", "-version").inheritIO().start().waitFor();

と書くことで、普段シェルスクリプトでやっているような作業をJavaで実現出来ます。

ただし、Java9で追加されたjshellを使えばgradleを使わずとも同じことが出来てしまいます。
実際に上のコードに対してjshell build.gradleを実行すると、gradle buildと同じ結果になることがわかります。

GroovyスクリプトとしてのGradle

次に、Groovyを使ってbuild.gradleを書いてみます。

ここでは最低限の紹介しかしませんが、大まかに次の特徴があります。

defで変数を定義する

def abc = "abc"

関数の括弧を省略できる

println("Hello World!")
println "Hello World!"

ただし、引数のない関数の場合は省略できません。

// ok
println System.getenv()

// ng
println System.getenv

シングルクォート, ダブルクォートどちらでも文字列が扱える

シングルクォート, ダブルクォートどちらもStringとして評価されます。

// java.lang.String
println "World"
println "World".getClass().getName()

// java.lang.String
println 'World'
println 'World'.getClass().getName()

ただし、ダブルクォートに${}が含まれる場合 GString というGroovy独自の型に評価され、${}内の変数が置換されます。

def greet = 'Hello'

// java.lang.String
println "%s World".formatted(greet)
println "%s World".formatted(greet).getClass().getName()

// org.codehaus.groovy.runtime.GStringImpl
println "${greet} World"
println "${greet} World".getClass().getName()

Groovyのクラス

Groovyでは、フィールドとメソッドはすべてpublic固定です。

class TestClass {
    def hoge = 'abc'
    def fuga() {
        println hoge
    }
}

def obj = new TestClass()

println obj.hoge
obj.hoge = 'cdf'

obj.fuga()

getter, setterを用意した場合、フィールドアクセスがgetter, setter呼び出しに読み替えられます。

class TestClass {
    def hoge = 'abc'
    def getHoge() {
        println 'getHoge() called'
        return this.hoge
    }
    def setHoge(String str) {
        println 'setHoge() called'
        this.hoge = str
    }
}

def obj = new TestClass()

obj.hoge // getHoge() called
obj.hoge = 'def' // setHoge() called

波括弧{}で囲んだ領域はクロージャになる

Groovyのクロージャは、本来の「環境つき関数」としての意味合いと、「無名関数」「ラムダ」としての意味合いを併せ持ちます。

// 本来のクロージャ
def c0 = {
    def local = 1
    return { x -> local += x; println local }
}
def closure = c0()
closure(100) // 101
closure(100) // 201
closure(100) // 301

// 無名関数・ラムダとしてのクロージャ
def c1 = { a, b -> println a + b }
c1(1, 2)
def c2 = { a -> println a }
[10, 20, 30].each(c2)

// 引数が1つだけの場合、記述を省略できる(引数がitとして予約される)
def c3 = { println it }
c3(1000)
def c4 = { it() }
c4 {
    println 'abc'
}

引数の最後がクロージャである場合、外側に記述できる

Gradleで多用される書き方なので、覚えておくと理解が捗ります。

def c = { a, closure -> 
  closure(a)
}

// A
c('abc', { println it })

// B
c('abc') {
    println it
}

// AとBは同じ結果になる

クロージャのデリゲート

Gradleで多用される書き方なので、覚えておくと理解が捗ります。

def c = {
    hoge(123)
    fuga('abc')
}

class HogeFuga {
    def hoge(int num) {
        println num
    }
    def fuga(String str) {
        println str
    }
}
def hf = new HogeFuga()

c.delegate = hf
c()

クロージャ上にhoge、fugaは定義しておらず、delegateするオブジェクト次第で如何様にも動作が変わります。

後述しますが、Gradleのフレームワークはbuild.gradle上のスクリプトを1つの大きなクロージャとして扱い、
Gradle組み込みのオブジェクトをdelegateに与え実行することで動作しています。

Gradleの関数を使いながらGroovy(+Java)で書く

Gradleのフレームワークに浸る前に、Gradleが提供する便利な関数を使ってみます。

JavaのFilesユーティリティ, Groovyのjdk enhancementも使えますが、ファイル操作やコマンド実行は通常これらの関数を組み合わせて記述します。

file / files / fileTree

// file()でFileオブジェクト(Java)が生成できる
file('./dir1').mkdir()
file('./dir1/a.txt').createNewFile()
file('./dir1/b.txt').createNewFile()
file('./dir1/dir2').mkdir()
file('./dir1/dir2/c.txt').createNewFile()


// files()でFileCollectionオブジェクト(Gradle固有)が生成できる
// - Iterable<File>の実装クラス
//   - https://docs.gradle.org/current/javadoc/org/gradle/api/file/FileCollection.html
def list = files('c.json', 'a.txt', 'a.csv', 'a.json', 'b.csv', 'b.json')
list.filter({ f -> f.getName().endsWith('.json') }).sort().each({ println it.getName() })

// ネストしてもフラットなリストとして扱える
def list2 = files(files('c.json', 'a.txt', 'a.csv'), files('a.json', 'b.csv', 'b.json'))
list2.filter({ f -> f.getName().endsWith('.csv') }).each({ println it.getName() })


// fileTreeでFileTreeオブジェクト(Gradle固有)が生成できる
// - FileCollectionのサブクラス
//   - https://docs.gradle.org/current/javadoc/org/gradle/api/file/FileTree.html
fileTree('./dir1').visit({ println it.getPath().toString() })

file('./dir1/dir2/c.txt').delete()
file('./dir1/dir2').delete()
file('./dir1/b.txt').delete()
file('./dir1/a.txt').delete()
file('./dir').delete()

delete

ファイル、ディレクトリを再帰的に削除できます。

file('./tmp').mkdir()
file('./tmp/abc.txt').createNewFile()

delete './tmp'

copy

引数にクロージャを渡すことで、様々なカスタマイズができる関数です。

ファイル、ディレクトリを再帰的にコピーできます。

delete './from'
delete './into'
file('./from').mkdir()
file('./from/a.txt').createNewFile()
file('./from/dir').mkdir()
file('./from/dir/b.txt').createNewFile()
file('./into').mkdir()

copy {
    from './from'
    into './into'
}

include, excludeでコピー対象を制御できます。

// a.txtだけ
copy {
    from './from'
    include '**/a.txt'
    into './into'
}

// a.txt, ./dir だけ
copy {
    from './from'
    exclude '**/b.txt'
    into './into'
}

指定した名前でコピーできます。

copy {
    from './from/a.txt'
    into './into'
    rename { name -> 'aaa.txt' }
}

コピー時にファイル内のテキストを変数置換できます。

Files.writeString(Path.of('./from/hogefuga.txt'), '''
Hello, ${hoge} !
Hello, ${fuga} !
''');

copy {
    from './from/hogefuga.txt'
    into './into'
    expand [
        hoge: 'aaa',
        fuga: 'bbb'
    ]
}

なお、このクロージャが実装するインターフェースはCopySpecといって、Gradleの様々な場所で登場します。

同じ作法で設定が書けるため、覚えておくことをお勧めします。

{
    from()
    into()
    include()
    exclude()
    rename()
    expand()
    ...
}

exec

外部コマンドを呼び出すことができます。

exec {
    commandLine 'java', '-version'
}

// 環境変数の指定
// - mac/linux
exec {
    environment [ 'test1': 'aaa' , 'test2': 'bbb', 'test3': 'ccc' ]
    commandLine 'sh', '-c', 'echo $test1 $test2 $test3'
}
// - win
exec {
    environment [ 'test1': 'aaa' , 'test2': 'bbb', 'test3': 'ccc' ]
    commandLine 'cmd', '/c', 'echo %test1% %test% %test3%'
}

// カレントディレクトリの変更
file('./tmp').mkdir()
file('./tmp/a.txt').createNewFile()
exec {
    workingDir './tmp'
    commandLine 'ls', '-la'
}
delete './tmp'

// リダイレクト
exec {
    standardOutput new FileOutputStream(file('./out.txt'))
    commandLine 'ls', '-la'
}

ちなみに、このクロージャはExecSpecというGradleのインターフェースを実装しています。

{
    environment()
    args()
    standardInput()
    standardOutput()
    errorOutput()
    commandLine()
    ...
}

javaexec

javaコマンドを呼び出すことができます。

下のスクリプトは、execでコンパイルしたクラスファイルをjavaexecで実行する例です。

import java.nio.file.Path;
import java.nio.file.Files;

delete('./Main.java')
delete('./Main.class')

Files.writeString(Path.of('./Main.java'), '''
import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        System.out.println(Arrays.toString(args));
    }
}
''');

exec {
    commandLine 'javac', 'Main.java'
}

javaexec {
    main = 'Main'
    classpath = files('.')
    args = ['a', 'b', 'c']
}

ちなみに、このクロージャはJavaExecSpecというGradleのインターフェースを実装しています。

{
    main()
    args()
    classpath()
    ...
}

3. TaskとConfiguration

ここからGradleのフレームワークに触れていきます。

Gradleの中心的な機能は、TaskとConfigurationという2種類の依存関係解決の仕組みにあります。

Task

Taskを定義しておくと gradle タスク名 としてコマンドから直接呼び出すことができます。

tasks.register('a') {
    doLast {
        println 'aaa'
    }
}

gradle aを実行すると、次のようにログ出力されるはずです。

> Task :a
aaa

BUILD SUCCESSFUL
1 actionable task: 1 executed

ところで、doLastの部分は何を表しているのでしょうか。

Taskは actions という名前のプロパティを持っていて、それは Action インターフェースのリストになっているのですが、 doLastは引数として与えられたクロージャをActionのオブジェクトに変換し、actionsの末尾に追加するはたらきをします。

そしてgradle タスク名を実行すると、タスクが作られたのち actions に登録された処理が先頭から順番に実行されていきます。

tasks.register('a') {
    doLast {
        println 'aaa'
    }
    doLast {
        println 'bbb'
    }
    doLast {
        println 'ccc'
    }
}

// aaa -> bbb -> ccc の順で出力される

実は、doLastを使った上のコードは下のコードのシンタックスシュガーになっています。

tasks.register("a") {
    actions.add(new Action<Task>() {
        @Override
        public void execute(Task task) {
            println("aaa")            
        }
    })
    actions.add(new Action<Task>() {
        @Override
        public void execute(Task task) {
            println("bbb")            
        }
    })
    actions.add(new Action<Task>() {
        @Override
        public void execute(Task task) {
            println("ccc")            
        }
    })
}

(ちなみにdoLastだけでなくdoFirstなどもあります。詳しくは TaskのJavadoc を参照してください。)

もう少しdoLastで遊んでみます。一度 register したタスクはこのように

tasks.register('a') {
    doLast{
        println 'aaa'
    }
}

a {
    doLast {
        println'bbb'
    }
}

直接タスク名で呼び出すことができ、同様に actions へ処理を追加することが可能です。

これは、たとえばマルチプロジェクト構成をとったとき同じタスク名で前処理と本体を分割するのに役立ちます(タスク定義によるプロパティの追加、マルチプロジェクトについては後の章で説明)。

// 親プロジェクトのbuild.gradle
tasks.register('a') {
    doLast{
        println '前処理'
    }
}
// サブプロジェクトのbuild.gradle
a {
    doLast {
        println'本体'
    }
}

しかし、特に理由がなければ下のコードのように

tasks.register('a') {
    doLast{
        println 'aaa'
    }
    doLast{
        println 'bbb'
    }
    doLast{
        println 'ccc'
    }
}

1つのタスク内で細かく分割することはせず、

tasks.register('a') {
    doLast{
        println 'aaa'
        println 'bbb'
        println 'ccc'
    }
}

1つのdoLastにまとめたほうが良いと思います。

設定フェーズと実行フェーズ

もう少しだけdoLastの話をします。

もしdoLastを書かなかった場合はどうなるのでしょうか。試しに次のコードをbuild.gradleとして保存しgradle aを実行してみてください。

tasks.register('a') {
    println 'a'
}

tasks.register('b') {
    println 'b'
}

(出力結果)

> Configure project :
a
b

BUILD SUCCESSFUL

aだけでなくbも表示されてしまいました。

Gradleのフレームワークにおいて、build.gradleの実行は設定フェーズ実行フェーズに分けられます。

doLastの外に書いた内容はすべて設定フェーズ時に処理され、実行フェーズでは指定したタスク内のdoLastの中身のみが処理されます。

例えば

tasks.register('a') {
    println 'config a' // --- (1)
    doLast {
        println 'exec a' // --- (2)
    }
}

tasks.register('b') {
    println 'config b' // --- (3)
    doLast {
        println 'exec b' // --- (4)
    }
}

上のbuild.gradleに対してgradle aを実行すると

> Configure project :
config a
config b

> Task :a
exec a

BUILD SUCCESSFUL
1 actionable task: 1 executed

「Configure project」=設定フェーズで(1)(3)が呼ばれ、「Task :a」= 実行フェーズで(2)が呼ばれています。

このように、呼び出したTaskに関わらず 全Taskの設定が設定フェーズで呼ばれることがわかりました。

Taskの依存関係

Task同士に依存関係を定義することができます

tasks.register('a') {
    doLast {
        println 'aaa'
    }
}

tasks.register('b') {
    dependsOn 'a'
    doLast {
        println 'bbb'
    }
}

この状態でgradle bを実行すると、aが呼ばれた後にbが呼ばれます。

> Task :a
aaa

> Task :b
bbb

BUILD SUCCESSFUL
2 actionable tasks: 2 executed

では、次のケースはどうでしょうか。

tasks.register('a') {
    doLast {
        println 'aaa'
    }
}

tasks.register('b') {
    dependsOn 'a'
    doLast {
        println 'bbb'
    }
}

tasks.register('c') {
    dependsOn 'a'
    dependsOn 'b'
    doLast {
        println 'ccc'
    }
}

依存関係はこのような構造です。

c
├── a
└── b
    └── a

gradle cを実行すると

> Task :a
aaa

> Task :b
bbb

> Task :c
ccc

BUILD SUCCESSFUL
3 actionable tasks: 3 executed

a -> a -> b -> c というようにはならず a -> b -> c の順序でTaskが呼び出されます。
つまり、依存関係が解決される過程で重複が排除されることがわかります。

組み込みTask

出来合いのタスク=組み込みタスクを継承することで、記述を減らすことができます。

組み込みタスクはすべてdoLastを使わずに設定を行います。

できるだけ組み込みタスクを使った記述にすることで、宣言的でメンテナンスしやすいビルドスクリプトになるはずです。

Copyタスク

delete './from'
delete './into'
file('./from').mkdir()
file('./from/a.txt').createNewFile()
file('./from/dir').mkdir()
file('./from/dir/b.txt').createNewFile()

tasks.register('copy', Copy) {
    from './from'
    into './into'
}

Execタスク

tasks.register('exec', ExecSpec) {
    environment [ 'test1': 'aaa' , 'test2': 'bbb', 'test3': 'ccc' ]
    commandLine 'sh', '-c', 'echo $test1 $test2 $test3'
}

JavaExecタスク

import java.nio.file.Path;
import java.nio.file.Files;

delete('./Main.java')
delete('./Main.class')

Files.writeString(Path.of('./Main.java'), '''
import java.util.Arrays;
public class Main {
    public static void main(String[] args) {
        System.out.println(Arrays.toString(args));
    }
}
''');

tasks.register('javac', Exec) {
    commandLine 'javac', 'Main.java'
}

tasks.register('javaExec', JavaExec) {
    dependsOn 'javac'
    main = 'Main'
    classpath = files('.')
    args = ['a', 'b', 'c']
}

Zipタスク

delete './from'
delete './into'
file('./from').mkdir()
file('./from/a.txt').createNewFile()
file('./from/dir').mkdir()
file('./from/dir/b.txt').createNewFile()

tasks.register('zip', Zip) {
    from './from'
    archiveFileName.set('test.zip')
    destinationDirectory.set(layout.projectDirectory) // build.gradleがあるディレクトリ
}

Tarタスク

  • tar.gzファイルを生成するタスクです
  • 設定その他はZipタスク同様になります
delete './from'
delete './into'
file('./from').mkdir()
file('./from/a.txt').createNewFile()
file('./from/dir').mkdir()
file('./from/dir/b.txt').createNewFile()

tasks.register('tar', Tar) {
    from './from'
    archiveFileName.set('test.tar.gz')
    destinationDirectory.set(layout.projectDirectory) // build.gradleがあるディレクトリ
}

Taskのインターフェース

こちらを参照してください。

https://docs.gradle.org/current/dsl/org.gradle.api.Task.html

Configuration

Configurationはファイルやライブラリの依存関係をグループ化する仕組みです。

ローカルのファイルシステムからファイルの取得

configurations {
    conf
}

dependencies {
    conf files('./a.txt')
    conf files('./b.txt')
    conf files('./c.txt')
}

copy {
    from configurations.conf
    into './dir'
}

上のスクリプトをbuild.gradleとして保存し、gradle buildを実行すると./dir配下にa.txt, b.txt, c.txtがコピーされます。

このように、configurationsとdependenciesを組み合わせてローカルのファイルをグループ化することが可能です。

configurations.confの実体はFileCollectionのインスタンスですので、上の例については次のように書いても同じです。

def conf = files './a.txt', './b.txt', './c.txt'
copy {
    from conf
    into './dir'
}

セントラルリポジトリからjarの取得

configurations {
    conf
}

dependencies {
    // https://mvnrepository.com/artifact/org.slf4j/slf4j-api/1.7.25
    conf 'org.slf4j:slf4j-api:1.7.25'
}

repositories {
    mavenCentral()
}

copy {
    from configurations.conf
    into './lib'
}

上のbuild.gradleに対してgradle buildを実行すると、./libslf4j-api-1.7.25.jarが保存されます。

このように、configurations・dependencies・repositoriesを組み合わせてリモート(maven repository)にあるjarをグループ化することが可能です。

また、それらのjarがあたかもローカルにあるファイルであるかのように扱うことができます。

推移的依存関係の解決

configurations {
    conf
}

dependencies {
    conf 'org.slf4j:slf4j-api:1.7.25'
    conf 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.2'
}

repositories {
    mavenCentral()
}

copy {
    from configurations.conf
    into './lib'
}

repositoriesにリポジトリを指定した場合、グループに含まれるjarは依存関係を辿った全jarが含まれ、gradle dependenciesコマンドでその内容を確認できます。

conf
+--- org.slf4j:slf4j-api:1.7.25
\--- org.apache.logging.log4j:log4j-slf4j-impl:2.17.2
     +--- org.slf4j:slf4j-api:1.7.25
     +--- org.apache.logging.log4j:log4j-api:2.17.2
     \--- org.apache.logging.log4j:log4j-core:2.17.2
          \--- org.apache.logging.log4j:log4j-api:2.17.2

重複が排除された結果、上のスクリプトで./libに配置されるライブラリは

log4j-api-2.17.2.jar
log4j-core-2.17.2.jar
log4j-slf4j-impl-2.17.2.jar
slf4j-api-1.7.25.jar

の4つになります。

Latest戦略

Gradleでは、jarのバージョンが食い違った場合に最新のバージョンが採用されます(=Latest戦略)。

configurations {
    conf
}

dependencies {
    conf 'org.slf4j:slf4j-api:1.6.6'
    conf 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.2'
}

repositories {
    mavenCentral()
}

copy {
    from configurations.conf
    into './lib'
}

gradle dependenciesの結果は次の通りです。

conf
+--- org.slf4j:slf4j-api:1.6.6 -> 1.7.25
\--- org.apache.logging.log4j:log4j-slf4j-impl:2.17.2
     +--- org.slf4j:slf4j-api:1.7.25
     +--- org.apache.logging.log4j:log4j-api:2.17.2
     \--- org.apache.logging.log4j:log4j-core:2.17.2
          \--- org.apache.logging.log4j:log4j-api:2.17.2

重複が排除&最新が選択された結果、gradle build./libに配置されるライブラリは

log4j-api-2.17.2.jar
log4j-core-2.17.2.jar
log4j-slf4j-impl-2.17.2.jar
slf4j-api-1.7.25.jar

の4つになります。

推移的関係の無効化

掲題の通り、推移的な依存関係を無効化できます。

configurations {
    conf {
        transitive false
    }
}

dependencies {
    conf 'org.slf4j:slf4j-api:1.6.6'
    conf 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.2'
}

repositories {
    mavenCentral()
}

copy {
    from configurations.conf
    into './lib'
}

gradle dependenciesの結果は次の通りです。

conf
+--- org.slf4j:slf4j-api:1.6.6
\--- org.apache.logging.log4j:log4j-slf4j-impl:2.17.2

gradle buildしたときに./libへ配置されるjarもこれら2つになります。

推移的関係のexclude

部分的に依存関係を無効化することができます。

configurations {
    conf {
        exclude module: 'log4j-core'
    }
}

dependencies {
    conf 'org.slf4j:slf4j-api:1.6.6'
    conf 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.2'
}

repositories {
    mavenCentral()
}

copy {
    from configurations.conf
    into './lib'
}

gradle dependenciesの結果は次の通りです。

conf
+--- org.slf4j:slf4j-api:1.6.6 -> 1.7.25
\--- org.apache.logging.log4j:log4j-slf4j-impl:2.17.2
     +--- org.slf4j:slf4j-api:1.7.25
     \--- org.apache.logging.log4j:log4j-api:2.17.2

結果、gradle build./libに配置されるライブラリは

log4j-api-2.17.2.jar
log4j-slf4j-impl-2.17.2.jar
slf4j-api-1.7.25.jar

の3つになります。

Configurationのインターフェース

こちらを参照してください。

https://docs.gradle.org/current/dsl/org.gradle.api.artifacts.Configuration.html

TaskとConfigurationを組み合わせてjarを作る

これまでの知識をもとに、Configurationを使ってライブラリの依存関係解決をしつつ、Taskを使ってjavac, jarコマンドを実行してみます。

import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Main {
    private static final Logger logger = LoggerFactory.getLogger(Main.class);
    public static void main(String[] args) {
        logger.info("Main start.");
        System.out.println(Arrays.toString(args));
        logger.info("Main end.");
    }
}

(↑./Main.javaとして保存)

configurations {
    conf
}

dependencies {
    conf 'org.slf4j:slf4j-api:1.6.6'
    conf 'org.slf4j:slf4j-simple:1.7.25'
}

repositories {
    mavenCentral()
}

tasks.register('resolveDependencies', Copy) {
    from configurations.conf
    into './lib'
}

tasks.register('javac', Exec) {
    dependsOn resolveDependencies
    delete './Main.class'
    commandLine 'javac', 'Main.java', '-cp', './lib/*', '-encoding', 'UTF-8', '-source', '17', '-target', '17', '-d', './classes'
}

tasks.register('jar', Exec) {
    dependsOn javac
    delete './jar'
    file('./jar').mkdirs()
    commandLine 'jar', 'cfe', './jar/main.jar', 'Main', '-C', './classes', '.'
}

上のスクリプトをbuild.gradleとして保存し、gradle jarを実行すると./jar/main.jarとしてjarが生成されます。

試しに次のコマンドを実行すると

java -cp './lib/*:./jar/main.jar' Main a b c

このような出力になるはずです。

[main] INFO Main - Main start.
[a, b, c]
[main] INFO Main - Main end.

4. Gradleの手続きと機能拡張のしくみ

クロージャのデリゲートで少し触れましたが、build.gradleに書く内容は1つの大きなクロージャとして扱われ、
そのクロージャのdelegateにGradleフレームワークのオブジェクトが指定されることでGradleが動作します。

(フレームワーク側がライブラリ側に渡されることで動くという、主従が逆転していて面白い仕組みだと思います。)

これまで登場したconfiguration, dependencies, repositories, tasksといったワードは
Gradleのフレームワーク内部にあるProjectオブジェクトのメソッドおよびプロパティに対応しています。

つまり、build.gradleは Project オブジェクトのメソッドやプロパティを呼び出すコードとして動くということがわかります。

例えば

configurations {
    conf {
    }
}

dependencies {
    conf 'org.slf4j:slf4j-api:1.7.25'
    conf 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.2'
}

repositories {
    mavenCentral()
}

tasks.register('test') {
    doLast {
        println 'aaa'
    }
}

上のbuild.gradle

Project project = getProject();

project.getConfigurations().create("conf")

project.getDependencies().add("conf", "org.slf4j:slf4j-api:1.7.25");
project.getDependencies().add("conf", "org.apache.logging.log4j:log4j-slf4j-impl:2.17.2");

project.getRepositories().mavenCentral();

project.getTasks().register("test", 
    new Action<Task>() {
        @Override
        public void execute(Task task) {
            task.doLast {
                println 'aaa'            
            }
        }  
    }
);

こちらのコードと同じ動作になります(試してみてください)。

3つのフェーズ

build.gradle実行時、次の3つのフェーズを経てProjectオブジェクトが初期化・実行されていきます。

Projectオブジェクトは

  • 初期化フェーズ
    • Projectオブジェクト生成
  • 設定フェーズ
  • 実行フェーズ
    • gradleコマンドの引数に対応するTaskのactionsを実行

フェーズ内の処理は他にも沢山ありますが、おおまかに上のような流れで進むことを覚えておいてください。

プラグインによるProjectの拡張

GradleはPluginを通して機能を拡張することができます。
プラグイン内部でProjectオブジェクトを操作することによって、さまざまなTaskやConfigurationを事前に用意することができます。

Javaのプロジェクトに必須な機能もすべてGradle本体から切り離し可能な形で実装されており、
それがjavaプラグイン, applicationプラグインなどお馴染みの組み込みプラグインとして提供されています。

このように、Gradleの中心はあくまで TaskとConfiguration をはじめとした抽象的な仕組みにあって、
用途に応じたプラグインを組み込むことによりビルドツールとして様々なユースケースに応えるフレームワークになっています。

プラグインを適用すると、先ほどの手続きは次のようになります。

  • 初期化フェーズ
    • Projectオブジェクト生成
  • 設定フェーズ
    • build.gradleに書かれた設定用の内容を実行(以下は例)
      • Project#configuration 実行
      • Project#dependencies 実行
      • Project#repositories 実行
      • Projectオブジェクトのtasksプロパティを初期化し、各Taskを追加
      • プラグインの登録(Projectオブジェクトを受けとり、任意の操作を行う)
        • Project#configuration 実行
        • ProjectオブジェクトのtasksプロパティにTaskを追加
        • ...
  • 実行フェーズ
    • gradleコマンドの引数に対応するTaskのactionsを実行

拡張の対象

プラグインによって拡張される機能は次の3種類です。

  • Task
  • Configuration
  • Extention

動くコードを例にして説明します。
まず次のコードをbuild.gradleとして保存してください。

apply from: 'test_plugin.gradle'

printABC {
    doLast {
        println 'ABC'
    }
}

tasks.register('printConf') {
    doLast {
        configurations.testConf.forEach({ println it })
    }
}

tasks.register('printProps') {
    doLast {
        testExt.printProps()
    }
}

続いて次のコードをtest_plugin.gradleとして同じディレクトリに保存してください。

class TestPlugin implements Plugin<Project> {
    void apply(Project project) {
        project.getConfigurations().create("testConf")
        project.getDependencies().add("testConf", "org.slf4j:slf4j-api:1.7.25");
        project.getDependencies().add("testConf", "org.apache.logging.log4j:log4j-slf4j-impl:2.17.2");
        project.getRepositories().mavenCentral();
        project.getTasks().register("printABC", 
            new Action<Task>() {
                @Override
                public void execute(Task task) {
                    println 'abc'            
                }  
            }
        );
        ExtensionContainer extensions = project.getExtensions();
        extensions.add('testExt', TestExtension.class)
    }
}

class TestExtension {
    String p1 = 'aaa'
    String p2 = 'bbb'
    void printProps() {
        println p1 + ', ' + p2
    }
}

apply plugin: TestPlugin

上のスクリプトによって、3つのタスクが次のように動作するはずです。

gradle printABC
#
# abc
# 
# BUILD SUCCESSFUL

gradle printConf
#
# > Task :printConf
# /Users/xxx/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-slf4j-impl/2.17.2/183f7c95fc981f3e97d008b363341343508848e/log4j-slf4j-impl-2.17.2.jar
# /Users/xxx/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-api/1.7.25/da76ca59f6a57ee3102f8f9bd9cee742973efa8a/slf4j-api-1.7.25.jar
# /Users/xxx/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-core/2.17.2/fa43ba4467f5300b16d1e0742934149bfc5ac564/log4j-core-2.17.2.jar
# /Users/xxx/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-api/2.17.2/f42d6afa111b4dec5d2aea0fe2197240749a4ea6/log4j-api-2.17.2.jar
#
# BUILD SUCCESSFUL
# 1 actionable task: 1 executed

gradle printExt
#
# > Task :printExt
# aaa, bbb
# 
# BUILD SUCCESSFUL
# 1 actionable task: 1 executed

このように、プラグインを通して

  • Taskを事前に追加することができます
    • 上の例では、事前に定義された printABC に対して処理を追加しています
  • Configurationを事前に追加することができます
    • 上の例では、事前に定義された conf を printConf で利用しています
  • Extention という種類のプロパティを事前に追加することができます
    • 上の例では、事前に定義された testExt を printExt で利用しています

javaプラグイン, applicationプラグインなどの組み込みプラグインによって使える機能は、これら Task, Configuration, Extention の拡張によって実現されています。


長くなったので別記事にします。
できれば以下のような流れで書いていきたいです。

  • (4.の続き)
    • 組み込みプラグイン
      • javaプラグイン
      • applicationプラグイン
    1. アーティファクト管理
    • アーティファクトのpublish, 参照
    • アーカイブタスクを利用したpublish
    • 直接ファイルを指定したpublish
    1. マルチプロジェクト
    • マルチプロジェクト構成
    • settings.gradle
    • プロジェクト参照
    • buildSrc
    • バージョンカタログ
    • キャッシュ
  • 7.開発環境
    • gradleラッパー
    • プロジェクトプロパティ

Discussion