こんにちは、こんばんは、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の削除やインタフェースの変更が起こります。そこで、バージョン別に一部ソースプログラムを切り替えてビルドする仕組みが必要です。
- 言語バージョン別にソースプログラムを分ける
- バージョン差異をインタフェースに切り出して吸収する
上記2ステップに分けて解説していきます。
ステップ1-1: 言語バージョン別にソースプログラムを分ける
まず最初に、言語バージョンに応じて一部のソースプログラムを分離する戦略について考えます。
先行事例として、Android の Build Variant という仕組みがあります。これを使うと、デバッグビルドとリリースビルドとで異なるソースセットを使うなど、一部の実装を条件で分岐することができます。
今回はある種これに近いアプローチで対応を行います。少し異なるのは、「ソースセット」を分離するのではなく、「ソースディレクトリ」を分離することです。
具体的に以下のようにソースディレクトリを構成します。
-
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の差分が小さい」という特徴があるためです。
Gradleで実際に構成する方法
それでは実際に上記のディレクトリ構成を実現するビルドロジックを実装していきます。
-
core
をソースディレクトリに追加 - 使用中のKotlinバージョンを取得してパース(x.y.zに分離)
- バージョン固有ディレクトリを適切に選択してソースディレクトリに追加
ちょっと長くなりますが頑張ってついてきてください
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
」 が kotlin
や java
と同様に特別なディレクトリとして認識されるようになります:
使用中のKotlinのバージョンを取得してパース (x.y.zに分離)
ソースディレクトリに含めるべきバージョン固有ディレクトリ特定するために、プロジェクト内で使用している Kotlin のバージョンを解析する必要があります。
バージョンの取得方法は様々かと思いますが、ここでは VersionCatalogs を使っている想定でそこから引っ張ってくることにします。
[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_z
と v_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
が対応バージョンかを判定します。
次に、versionSpecificSrcDirs
と RangedKotlinVersion
の対応づけを行います:
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()
}
あとは targetVersionSpecificSrcDir
を newSrcDirs
に add
すればOKです。
ここまでで Kotlin のバージョンに応じたソースセットの自動選択ができるようになりました!(差分はこちらを参照)
2.1.0 | 2.0.21 ~ 2.0.10 | 2.0.0 |
---|---|---|
![]() |
![]() |
![]() |
ステップ1-2: バージョン固有APIクラスの定義
ここからは、言語バージョンによるAPI差異を特別なインタフェースに切り出すことで解決します(下図)。
core
モジュールに VersionSpecificAPI
インタフェースを用意して、各ソースディレクトリで個別に実装します。
バージョンによって一意に実装クラスが選択されるので、クラスの多重定義エラーを避けてAPI差異を吸収することができます。
interface VersionSpecificAPI {
companion object {
lateinit var INSTANCE: VersionSpecificAPI
}
fun someVersionSpecificTask()
}
object VersionSpecificAPIImpl: VersionSpecificAPI {
override fun someVersionSpecificTask() {
println("latest Kotlin!")
}
}
fun main() {
// 適当な場所でインスタンスを注入
VersionSpecificAPI.INSTANCE = VersionSpecificAPIImpl
VersionSpecificAPI.INSTANCE.someVersionSpecificTask()
}
このステップの差分はこちらです。確認しやすいように application
プラグインを適用しているので ./gradlew run
を実行することで動作確認できます。
> Task :multi-kotlin-module:run
latest Kotlin!
> Task :multi-kotlin-module:run
Kotlin pre 2.0.21!
> Task :multi-kotlin-module:run
Kotlin 2.0.0!
ステップ2: 言語バージョンを環境変数で切り替える
CIなどで自動的に複数の言語バージョンをテスト・デプロイするためには、環境変数で言語バージョンを切り替えられると便利です。今回は、KOTLIN_VERSION
という環境変数を参照して言語バージョンを上書きすることを試みます。
gradleディレクトリ配下に libs.versions.toml を配置すると、自動的にバージョンカタログが生成されますが、このようにして生成されるバージョンカタログは上書きができません。そのため、自前で libs
バージョンカタログを作成する必要があります。
gradle/libs.versions.toml
を versions-root
配下へ移動して、settings.gradle.kts
の dependencyResoulutionManagement
を次のように変更します:
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
するか、dependencies
に add("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 から落とせるようにしたいんですが、色々調整が必要な部分があり難航しています
Kotlin Compiler Plugin は難しいと感じるかもしれませんが、色々いじってると言語の詳細部分がわかってきますし、黒魔術感あって楽しいです。
冬休みの宿題としてやってみるのもいいかもしれません!それでは!!