gradle.kts ベースで Gradle Convention Plugin を作る

こんにちは、こんばんは、kitakkunです。

Gradleビルドスクリプトの共通化・外部化する際、**Composite Build** という仕組みを活用して Convention Plugin を実装することが一般的になってきました。

https://docs.gradle.org/current/userguide/composite_builds.html

しかし、現在広く使われている `org.gradle.api.Plugin` を実装する手法には、いくつか欠点があります。
- ボイラープレートが増える。
- `settings.gradle.kts` の共通化が難しい。

本記事では、**ktsファイル** を使ってより効率的かつ柔軟に Convention Plugin を構成する手法を紹介します。

# 従来の方法

説明上、簡単なものの方が好ましいと思うので、適用時に `"Hello, convention plugin!"` と出すだけのものを作ります。

## gradle-conventions モジュールの作成
まず、Convention Pluginの実装を含むモジュール(`gradle-conventions`)を作ります。

```
gradle-conventions
 ├─ src/main/kotlin/MyConventionPlugin.kt
 ├─ build.gradle.kts
 └─ settings.gradle.kts
```

```kotlin:gradle-conventions/src/main/kotlin/MyConventionPlugin.kt
class MyConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        println("Hello, convention plugin!")
    }
}
```

```kotlin:gradle-conventions/build.gradle.kts
plugins {
    `kotlin-dsl`
}

gradlePlugin {
    plugins {
        register("my-convention") {
             id = "my-convention"
             implementationClass = "MyConventionPlugin"
        }
    }
}
```
```kotlin:gradle-conventions/settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}
```


まあよくある構成ですね。

## プラグインを includeBuild する

次に、ルートプロジェクトの `settings.gradle.kts` 内の `pluginManagement` で `includeBuild` します。

```kotlin:settings.gradle.kts
pluginManagement {
    includeBuild("gradle-conventions")
}
```

すると、以下のようにカスタムプラグインが適用可能になります。

```kotlin:build.gradle.kts
plugins {
    id("my-convention")
}
```

Gradle Sync して以下のように出ればOKです。
```
> Configure project :
Hello, convention plugin!
```

このように、一般的には `org.gradle.api.Plugin` クラスを実装する方法が使われます。
しかし、実は **src の中に直接 gradle.kts を配置する**方法もあります。

# gradle.kts を src 配下に置く手法

`src` の配下に `println` だけ書いた `my-convention.gradle.kts` を作成します。

```kotlin:gradle-conventions/src/main/kotlin/my-convention.gradle.kts
println("Hello, convention plugin! (from kts)")
```

`gradlePlugin` の定義は不要なので消してしまいます。
```diff:gradle-conventions/build.gradle.kts
plugins {
    `kotlin-dsl`
}

- gradlePlugin {
-     plugins {
-         register("my-convention") {
-             id = "my-convention"
-             implementationClass = "MyConventionPlugin"
-        }
-     }
- }
```

`gradle-conventions/src/main/kotlin/MyConventionPlugin.kt` も削除します。

```diff:gradle-conventions/src/main/kotlin/MyConventionPlugin.kt
- class MyConventionPlugin : Plugin<Project> {
-     override fun apply(target: Project) {
-         println("Hello, convention plugin!")
-     }
- }
```

もう一度 Gradle Sync してみましょう。同じ結果になるはずです。

```
> Configure project :
Hello, convention plugin! (from kts)
```

随分と短く簡潔に作れましたね。ボイラープレートも減って見やすくなりました。

## 何が起きているのか？

`src` 内に配置された `gradle.kts` は、Pluginクラスにプリコンパイルされます。
ですから、`my-convention.gradle.kts` からは `MyConventionPlugin` が生成されます。

```kotlin:gradle-conventions/build/generated-sources/kotlin-dsl-plugins/kotlin/MyConventionPlugin.kt
/**
 * Precompiled [my-convention.gradle.kts][My_convention_gradle] script plugin.
 *
 * @see My_convention_gradle
 */
public
class MyConventionPlugin : org.gradle.api.Plugin<org.gradle.api.Project> {
    override fun apply(target: org.gradle.api.Project) {
        try {
            Class
                .forName("My_convention_gradle")
                .getDeclaredConstructor(org.gradle.api.Project::class.java, org.gradle.api.Project::class.java)
                .newInstance(target, target)
        } catch (e: java.lang.reflect.InvocationTargetException) {
            throw e.targetException
        }
    }
}
```

自動生成された `MyConventionPlugin` は、ファイル名の `my-convention` の部分を`id`として適用できます。

## `settings.gradle.kts` を共通化する

kts化するもう一つのメリットは、`settings.gradle.kts` が共通化しやすいことです。

:::note
`settings.gradle.kts` の共通化が必要なケースはそう多くないと思いますが、動的に生成する versionCatalog をプロジェクト全体で一貫して使用したい場合などに役立ちます。
例: Kotlinバージョンを動的に切り替えてコンパイラプラグインをビルドしたい時
:::

### repositories を設定する Convention Plugin を作成する例

簡単な例として、プロジェクトの依存アーティファクトの参照先となる `repositories` を設定するConvention Plugin を作ってみましょう。

現在、`settings.gradle.kts` には `repositories` の定義が欠けていますので、

```kotlin:settings.gradle.kts
pluginManagement {
    includeBuild("gradle-conventions")
}
```

`build.gradle.kts` で Kotlin JVMプラグインを適用しようとすると・・

```kotlin:build.gradle.kts
plugins {
    id("my-convention")
    id("org.jetbrains.kotlin.jvm") version "2.0.0"
}
```

以下のように「プラグインが解決できないよ」と怒られます。

```
conventions:main: Cannot resolve external dependency org.jetbrains.kotlin:kotlin-stdlib:2.0.0 because no repositories are defined.
Required by:
    root project :

Possible solution:
 - Declare repository providing the artifact, see the documentation at https://docs.gradle.org/current/userguide/declaring_repositories.html
 ```

 ゴールは、このエラーを消すことです。具体的な手順は2ステップ。
 1. `settings-conventions.settings.gradle.kts` に `repositories` 設定を書く。
 2. ルートの`settings.gradle.kts`内でプラグインを適用する。

まず、`settings-conventions.settings.gradle.kts` は以下のように書きます。
```kotlin:gradle-conventions/src/main/kotlin/settings-conventions.settings.gradle.kts
pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}
```

これをルートプロジェクトの`settings.gradle.kts`で適用してあげます。

```diff:settings.gradle.kts
 pluginManagement {
     includeBuild("gradle-conventions")
 }

+ plugins {
+     id("settings-conventions")
+ }
```

すると、Gradle Sync が通るようになるはずです。

ついでに `gradle-conventions` にも冗長な `repositories` 定義があるので、このプラグインで代替します。

```diff:gradle-conventions/settings.gradle.kts
- pluginManagement {
-     repositories {
-         mavenCentral()
-         gradlePluginPortal()
-     }
- }

- dependencyResolutionManagement {
-     repositories {
-         mavenCentral()
-     }
- }

+ apply(from = "src/main/kotlin/settings-conventions.settings.gradle.kts")
```

こちらも問題なく Gradle Sync 成功するはずです。

# まとめ

本記事では、gradle.kts の形式でConvention Pluginを実装することで、ボイラープレートを減らしつつ `settings.gradle.kts` の共通化も含め柔軟にビルド設定が行えることをご紹介しました。

この手法は、kotlinx.rpc リポジトリをコードリーディングしていた際に発見したものです。

https://github.com/Kotlin/kotlinx-rpc

`settings.gradle.kts` の共通化などはエッジケースかと思いますが、Convention Pluginの実装において面倒な作業を極限まで省けるという恩恵は、どなたでも享受できるものかと思います。

kts convention、やっていきましょう :muscle: 

# 宣伝

現在個人的に開発している Kondition というコンパイラプラグインがあります。

https://github.com/kitakkun/Kondition

簡単に説明すると、アノテーションをつけることで特定の条件が満たされていることを保証できるプラグインとなっています。

```kotlin
fun myFunction(@NonEmpty value: String) {
    // この中では value が空文字でないことが保証される
    // ※require(value.isNotEmpty()) がIRレベルで生成される
}
```

Kotlinの複数バージョン対応を行う際に、ktsベースの Convention Plugin がかなり役に立ちました。具体的には、versionCatalogs の共通化など少し高度なことを行っています。もし興味がありましたらご覧ください！

ここまで読んでいただきありがとうございました！ 
Gradle、何もわからん！！！！