4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kotlin Compiler Pluginを複数の言語バージョンに対応する

Last updated at Posted at 2024-12-19

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

筆者は Kotlin Fest 2024 で紹介した back-in-time-plugin をはじめ、Kondition, suspend-kontext というKotlin Compiler PluginをOSSで公開しています。

Konditionとsuspend-kontextに関しては、既に MavenCentral で公開しています。Kotlin 2.0.0 から最新の 2.1.0 までをサポートしていますので、ぜひ遊んでみてください。

こちらの記事は Kotlin Advent Calendar 2024 20日目の記事となります。

はじめに

本記事では、複数の言語バージョンを意識して Kotlin Compiler Plugin を開発する方法をご紹介します。具体的には以下の内容をカバーします。

  • 言語バージョンによるAPI差異を賢く吸収する
  • 環境変数で言語バージョンを切り替える
  • Gradle Plugin で適切な Compiler Plugin を自動選択する

Gradleによるビルド構成が主となるので、Kotlin Compiler Plugin の理解は必要ありませんが、読者としてはコンパイラプラグインの開発者を想定しています。

通常のプロジェクトではまず必要とならない内容ですが、Gradleについて詳しくなる視点で読んでみても面白い内容かと思いますので、よろしければブラウザバックせず最後まで読んでみてください!

Kotlin Compiler Plugin 自体について詳しく知りたい方は、先にこちらをどうぞ。情報の鮮度的には Kotlin Fest 2024 のセッション動画をお勧めします。

前提: なぜ複数の言語バージョンをサポートするのか

前提として、モチベーションの話をします。

Kotlin Compiler Plugin の拡張対象は、ユーザ空間における Kotlin Compiler です。

ユーザは、プロジェクトのステージに応じて様々な言語バージョンを採用しています。そのため、特別な事情で古めのバージョンを使っているかもしれません。

コンパイラプラグインが単一の言語バージョンのみを想定して開発されている場合、どんな問題が考えられそうでしょうか?

  • 機会損失: ユーザの使っている言語バージョンが未サポートで使えない
  • 高い導入リスク: 重要なバグフィックスがあっても、言語バージョンごとの更新が必要になる可能性がある

このような問題が残っていると、いくら優れたコンパイラプラグインを開発したとしても、なかなか採用してもらえません。できるだけ多くの人に使ってもらうためにも、複数の言語バージョンをサポートするのがベターです。

それでは順に実際の対応方法を見ていきましょう!

ステップ1: 言語バージョンによるAPI差異を賢く吸収する

コンパイラも立派なソフトウェアなので、バージョンアップでAPIが変更されていきます。

ほとんどの部分は同じコードを使いまわせますが、どうしても一部APIの削除やインタフェースの変更が起こります。そこで、バージョン別に一部ソースプログラムを切り替えてビルドする仕組みが必要です。

  1. 言語バージョン別にソースプログラムを分ける
  2. バージョン差異をインタフェースに切り出して吸収する

上記2ステップに分けて解説していきます。

ステップ1-1: 言語バージョン別にソースプログラムを分ける

まず最初に、言語バージョンに応じて一部のソースプログラムを分離する戦略について考えます。

先行事例として、Android の Build Variant という仕組みがあります。これを使うと、デバッグビルドとリリースビルドとで異なるソースセットを使うなど、一部の実装を条件で分岐することができます。

今回はある種これに近いアプローチで対応を行います。少し異なるのは、「ソースセット」を分離するのではなく、「ソースディレクトリ」を分離することです。

「ソースセット」と「ソースディレクトリ」

念のため軽く用語の整理をします。ソースセットはソースディレクトリを束ねる単位です。例えば:

  • ソースセット: main, test, commonMain, commonTest, ...
  • ソースディレクトリ: kotlin, java

といった具合ですね。

具体的に以下のようにソースディレクトリを構成します。

  • core: 全バージョン共通
  • latest: 最新バージョン または 専用ソースディレクトリがない場合のフォールバック先
  • v_x_y_z: Kotlin x.y.z 限定
  • pre_x_y_z: Kotlin x.y.z 以前(x.y.zを含む)

例えば:

  • 2.1.0(最新)の場合は、core + latest
  • 2.0.21 の場合は、core + (v_2_0_21 または pre_x_y_z (x.y.z >= 2.0.21))

をそれぞれ自動的に選択します。図にするとこんな具合ですね。

pre_x_y_z という範囲による区分を用意しているのは、「隣り合うバージョンではAPIの差分が小さい」という特徴があるためです。

この方式で管理すれば、新しい言語バージョン 2.1.x がリリースされても比較的簡単に対処できます。

API変更の影響がない場合

そのままバージョンを上げれば対応が完了します(緑は新規追加部分)。

API変更の影響がある場合

影響箇所をバージョン固有APIのクラス(後述)に切り出して旧最新バージョン用のソースディレクトリを作成します(緑は新規追加部分、オレンジは差分が発生する箇所)。

Gradleで実際に構成する方法

それでは実際に上記のディレクトリ構成を実現するビルドロジックを実装していきます。

  1. core をソースディレクトリに追加
  2. 使用中のKotlinバージョンを取得してパース(x.y.zに分離)
  3. バージョン固有ディレクトリを適切に選択してソースディレクトリに追加

ちょっと長くなりますが頑張ってついてきてください :sob:

core をソースディレクトリに追加

ソースセット(sourceSet)に含めるソースディレクトリ(srcDirs)は、自由に指定できます。

例えば、core というディレクトリをソースディレクトリに追加する Gradle スクリプトは以下のようになります:

kotlin {
    sourceSets.forEach { sourceSet ->
        val srcDirs = sourceSet.kotlin.srcDirs // ex) [.../src/main/kotlin, .../src/main/java]
        val sourceSetRootPath = srcDirs.first().toPath().parent // ex) .../src/main

        val newSrcDirs = srcDirs.toMutableSet()
        newSrcDirs += sourceSetRootPath.resolve("core").toFile()
        sourceSet.kotlin.setSrcDirs(newSrcDirs)
    }
}

上記ビルド設定を適用すると、IDEで 「core」 が kotlinjava と同様に特別なディレクトリとして認識されるようになります:

image.png

使用中のKotlinのバージョンを取得してパース (x.y.zに分離)

ソースディレクトリに含めるべきバージョン固有ディレクトリ特定するために、プロジェクト内で使用している Kotlin のバージョンを解析する必要があります。

バージョンの取得方法は様々かと思いますが、ここでは VersionCatalogs を使っている想定でそこから引っ張ってくることにします。

gradle/libs.versions.toml
[versions]
kotlin = "2.1.0"
val rawKotlinVersion = libs.versions.kotlin.get() // ex) 2.0.0, 2.1.0, ...

続いて、バージョン比較がしやすいように便宜上 KotlinVersion という独自のデータクラスを作り、compareTo を実装して簡単に比較演算できるようにしておきます。

data class KotlinVersion(
    val majorVersion: Int,
    val minorVersion: Int,
    val patchVersion: Int,
) {
    override fun toString(): String {
        return "$majorVersion.$minorVersion.$patchVersion"
    }

    operator fun compareTo(other: KotlinVersion): Int {
        return when {
            this.majorVersion != other.majorVersion -> this.majorVersion - other.majorVersion
            this.minorVersion != other.minorVersion -> this.minorVersion - other.minorVersion
            else -> this.patchVersion - other.patchVersion
        }
    }
}

文字列を KotlinVersion へと変換する関数を作り:

fun String.parseToKotlinVersion(): KotlinVersion {
    val (major, minor, patch) = substringBefore("-").split(".").map { it.toInt() } // substringBefore("-") は後ろにくっつく RC1 とか Beta1 とかを取り除くため
    return KotlinVersion(major, minor, patch)
}

KotlinVersion としてバージョンを取得します。

val kotlinVersion = libs.versions.kotlin.get().parseToKotlinVersion()

例えば、2.0.0, 2.0.0-RC1 であれば KotlinVersion(2, 0, 0) になります。

バージョン固有ディレクトリを適切に選択してソースディレクトリに追加

取得した KotlinVersion を使って、バージョン固有ディレクトリを選択します。

まずRegexパターンを用いて、sourceSetRootPath 配下に存在するバージョン固有のソースディレクトリをかき集めます:

// matches:
// - v_1
// - v_1_9
// - v_1_9_2
// - v_1_9_24
// - pre_1_9_20
// etc.
val versionSpecificSrcDirRegex = "^(v|pre)(_\\d){1,3}\\d?$".toRegex()

val versionSpecificSrcDirs = sourceSetRootPath.toFile().listFiles().orEmpty().filter {
    it.name.matches(versionSpecificSrcDirRegex) && it.exists()
}

続いて、pre_x_y_zv_x_y_z を区別するために RangedKotlinVersion クラスを便宜上作成しておきます:

sealed interface RangedKotlinVersion {
    fun matches(kotlinVersion: KotlinVersion): Boolean

    data class Direct(val kotlinVersion: KotlinVersion) : RangedKotlinVersion {
        override fun matches(kotlinVersion: KotlinVersion) = kotlinVersion == this.kotlinVersion
    }

    data class Pre(val kotlinVersion: KotlinVersion) : RangedKotlinVersion {
        override fun matches(kotlinVersion: KotlinVersion) = kotlinVersion <= this.kotlinVersion
    }
}

matches を呼び出すことで、引数で渡したkotlinVersionが対応バージョンかを判定します。

次に、versionSpecificSrcDirsRangedKotlinVersion の対応づけを行います:

val versionSpecificSrcDirsMap = versionSpecificSrcDirs.associateBy {
    val kotlinVersion = it.name.substringAfter("_") // v_2_0_0 -> 2_0_0, pre_2_0_0 -> 2_0_0
        .replace("_", ".") // 2_0_0 -> 2.0.0
        .parseToKotlinVersion()
    when {
        it.name.startsWith("v_") -> RangedKotlinVersion.Direct(kotlinVersion)
        it.name.startsWith("pre_") -> RangedKotlinVersion.Pre(kotlinVersion)
        else -> error("Unexpected source directory name: ${it.name}.")
    }
}.toList()

最後に、KotlinVersion(x, y, z) について、以下のアルゴリズムでディレクトリを選択します。

このロジックをビルドスクリプトの一部として記述するとこうなります:

val directVersionSpecificSrcDir = versionSpecificSrcDirsMap.find { (version, _) ->
    version is RangedKotlinVersion.Direct && version.matches(kotlinVersion)
}?.second

val preVersionSpecificSrcDir = versionSpecificSrcDirsMap.find { (version, _) ->
    version is RangedKotlinVersion.Pre && version.matches(kotlinVersion)
}?.second

val targetVersionSpecificSrcDir = when {
    directVersionSpecificSrcDir != null -> directVersionSpecificSrcDir
    preVersionSpecificSrcDir != null -> preVersionSpecificSrcDir
    else -> sourceSetRootPath.resolve("latest").toFile()
}

あとは targetVersionSpecificSrcDirnewSrcDirsadd すればOKです。
ここまでで Kotlin のバージョンに応じたソースセットの自動選択ができるようになりました!(差分はこちらを参照

2.1.0 2.0.21 ~ 2.0.10 2.0.0
image.png image.png image.png

ステップ1-2: バージョン固有APIクラスの定義

ここからは、言語バージョンによるAPI差異を特別なインタフェースに切り出すことで解決します(下図)。

core モジュールに VersionSpecificAPI インタフェースを用意して、各ソースディレクトリで個別に実装します。

バージョンによって一意に実装クラスが選択されるので、クラスの多重定義エラーを避けてAPI差異を吸収することができます。

src/main/core/VersionSpecificAPI.kt
interface VersionSpecificAPI {
    companion object {
        lateinit var INSTANCE: VersionSpecificAPI
    }

    fun someVersionSpecificTask()
}
src/main/latest/VersionSpecificAPIImpl.kt
object VersionSpecificAPIImpl: VersionSpecificAPI {
    override fun someVersionSpecificTask() {
        println("latest Kotlin!")
    }
}
使用前の準備・関数呼び出し
fun main() {
    // 適当な場所でインスタンスを注入
    VersionSpecificAPI.INSTANCE = VersionSpecificAPIImpl
    VersionSpecificAPI.INSTANCE.someVersionSpecificTask()
}

このステップの差分はこちらです。確認しやすいように application プラグインを適用しているので ./gradlew run を実行することで動作確認できます。

Kotlin 2.1.0 の時の挙動
> Task :multi-kotlin-module:run
latest Kotlin!
Kotlin 2.0.21 ~ 2.0.10 の時の挙動
> Task :multi-kotlin-module:run
Kotlin pre 2.0.21!
Kotlin 2.0.0 の時の挙動
> Task :multi-kotlin-module:run
Kotlin 2.0.0!

ステップ2: 言語バージョンを環境変数で切り替える

CIなどで自動的に複数の言語バージョンをテスト・デプロイするためには、環境変数で言語バージョンを切り替えられると便利です。今回は、KOTLIN_VERSION という環境変数を参照して言語バージョンを上書きすることを試みます。

gradleディレクトリ配下に libs.versions.toml を配置すると、自動的にバージョンカタログが生成されますが、このようにして生成されるバージョンカタログは上書きができません。そのため、自前で libs バージョンカタログを作成する必要があります。

gradle/libs.versions.tomlversions-root 配下へ移動して、settings.gradle.ktsdependencyResoulutionManagement を次のように変更します:

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files(rootDir.toPath().resolve("versions-root/libs.versions.toml")))

            System.getenv("KOTLIN_VERSION")?.let {
                version("kotlin", it)
            }
        }
    }
}

ターミナルで実行前に環境変数を export して試してみましょう。差分はこちら

$ export KOTLIN_VERSION=2.0.0
$ ./gradlew run
> Task :multi-kotlin-module:run
Kotlin 2.0.0!

うまく切り替えることができていますね!

ステップ3: Gradle Plugin で適切な Compiler Plugin を自動選択する

最後に、Gradle Plugin を用いて自動的に適用先プロジェクトの言語バージョン合致するコンパイラプラグインをクラスパスに追加したくなるはずです。

詳細までは説明しませんが、ここで必要なのはどうやって適用先プロジェクトで使用している言語バージョンを取得するか、でしょう。

まず、Kotlin Gradle Plugin (以後簡単にKGP) のAPIを使いたいので compileOnly で依存を追加します。

[libraries]
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
dependencies {
    compileOnly(libs.kotlin.gradle.plugin)
}

(バージョンの取得方法は様々ありますし、今後変更される可能性はありますが)一番簡単かつ直感的なのは getKotlinPluginVersion を使う方法でしょう。

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion

class PrintTargetProjectKotlinVersionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        val targetKotlinVersion = target.getKotlinPluginVersion()
        println("The target project is using Kotlin $targetKotlinVersion!")
    }
}

これで適用されている KGP のバージョンを取得できます。あとは KotlinCompilerPluginSupportPlugin のインスタンスをバージョン情報を用いて作成し apply するか、dependenciesadd("kotlinCompilerPluginClasspath", "...") するなどすれば大丈夫です。お疲れ様でした(差分はこちらになります!)。

まとめ

本記事では、Kotlin Compiler Pluginで複数言語バージョン対応するための戦略をまとめました。サンプルコードは GitHub にて公開しておりますので、必要に応じてご参照ください。

また、ご紹介した内容は kotlinx.rpc のソースコードを参考にしています。gradle-conventions(build-logic) を作成したい場合はこちらのリポジトリのように gradle-conventions-settings の導入が必要となるかもしれません。

settings.gradle.kts を src 内に配置することで、settings.gradle.kts の内容を使い回すことができます。よければこちらの記事も合わせてご覧ください。

宣伝

最後に、個人で開発しているKotlin Compiler Pluginについてのご紹介です。

Kondition

Kondition は、値に特定の条件を課したり、値を条件に合わせて自動調整するためのコンパイラプラグインです。アノテーションを用いてボイラープレートを軽減します。

値に条件を課す例:

fun divide10(@NonZero value: Int) : Int {
    return 10 / value
}

// コンパイラで変換されたコードのイメージ:
fun divide10(@NonZero value: Int) : Int {
    require(value != 0) { "error!" }
    return 10 / value
}

値を条件に合わせて自動調整する例

fun someTask(@CoereceIn(0, 1) range: Double) {
    println(range)
}

// コンパイラで変換されたコードのイメージ:
fun someTask(@CoereceIn(0, 1) range: Double) {
    var temp = range.coerceIn(0.0, 1.0)
    println(temp) // 0 ~ 1の範囲でのみprintされる
}

suspend-kontext

suspend-kontext は、suspend関数のデフォルトコンテキストをアノテーションを用いて指定することで、コードの可読性を向上させるためのコンパイラプラグインです。

withContext を suspend関数 の内部で呼び出すと、CoroutineDispatcherを切り替えて実行することができます:

suspend fun loadText(file: File): String {
    return withContext(Dispatchers.IO) {
        return@withContext file.readText()
    }
}

とはいえ、値を返したりするとネストが深くなりコードがやや読みづらくなってしまいます。このプラグインは以下のコードをコンパイルタイムに上のコードと同等になるように変換します。

@IOContext
suspend fun loadText(file: File): String {
    return file.readText()
}

どちらも軽い思いつきで作ったものですが、よければ遊んでみてください!

ここまで読んでいただきありがとうございました!! back-in-time-pluginの方も早いところ MavenCentral から落とせるようにしたいんですが、色々調整が必要な部分があり難航しています :innocent:

Kotlin Compiler Plugin は難しいと感じるかもしれませんが、色々いじってると言語の詳細部分がわかってきますし、黒魔術感あって楽しいです。

冬休みの宿題としてやってみるのもいいかもしれません!それでは!!

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?