Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

KotlinとR8を使用して実行可能JARを作成する

自分は Android 開発に関わることが多く、その関係でちょっとした処理をよく Gradle Task で作っていました。
AGP8とJDK17に対応した際の調査結果まとめ で書いた通り、勉強コストが高いため Gradle が好きなわけではありません

そして今回、AWS lambda のようなサーバレス環境でこの Task を実行したいなーと思うことがありました。
しかし、こういう環境は諸々のサイズ制限とか、実行時間制限があって Gradle Task の実行は難しそうでした。

ちなみに今回実行しようとした Gradle Task は大してコード量はなかったのですが、以下のような状況でした。

  • キャッシュのない初回実行が数分単位でかかる
  • .gradle キャッシュが 1.5GB 超える

どうしようかなと検討していたのですが、ふと思いました、Gradle は KTS (Kotlin DSL) を使用して Kotlin で書いているのだしコピペして実行可能JAR化してしまえば良いのでは?と。

🔗実装環境について

実装環境は今まで通り Gradle を利用します。

  • Android Studio Koala | 2024.1.1 Patch 2
  • Kotlin 1.9
  • Gradle 8.7

Android Studio だと実行可能JAR用のモジュールのみを持つプロジェクトを作りづらいのですが、今回は普段使ってる環境でやっています。

もちろんIDEなどは何を使っても問題ないので、別のIDEなどを利用する場合は適宜読み替えてください。

🔗実行可能JARモジュールの作成

今回の実装環境である Android Studio だと新規プロジェクト作成時に以下のように Android に関連する選択肢しかないのでひとまず適当に空Activityのプロジェクトを作ります。

その後、以下の手順を実行します。

  1. File -> New -> New Module... を選択
  2. Create New Module ウィンドウで Java or Kotlin Library を選択し、必要事項を入力して Finish

今回は以下のような内容でモジュールを作成しています。

モジュール作成直後は以下のような状況になっています。

今回の環境だとデフォルトでバージョンカタログを使う形になっています。

🔗実行可能JARモジュールの実装

Android の app モジュールなどがある関係で無駄な記述も多いので、今回触るファイルから必要な記述を抜き出してみます。

バージョンカタログの libs.versions.toml を開くと以下の設定が記載されています。

[versions]
jetbrainsKotlinJvm = "1.9.0"

[plugins]
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }

プロジェクトルートの build.gradle.kts には以下の設定が記載されています。

plugins {
    alias(libs.plugins.jetbrains.kotlin.jvm) apply false
}

モジュールの build.gradle.kts には以下の設定が記載されています。

plugins {
    id("java-library")
    alias(libs.plugins.jetbrains.kotlin.jvm)
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

今回は完全に Kotlin のみを使用しており id("java-library") などは消しても問題なさそうだったため、モジュールの build.gradle.kts は以下のように書き換えました。

plugins {
    alias(libs.plugins.jetbrains.kotlin.jvm)
}

kotlin {
    jvmToolchain(17)
}

Main.kt を開くとデフォルトで Main クラスが存在しますが今回は不要なのでひとまず以下のように書き換えます。

package jp.bpsinc.test.jar

fun main(args: Array<String>) {
    // 適当に Kotlin 標準ライブラリの機能を使っておく
    val map = mapOf(1 to "hoge", 2 to "fuga")
    for (value in map) {
        println("${value.key}: Hello ${value.value}")
    }
    for ((i, value) in args.withIndex()) {
        println("args[$i]: $value")
    }
}

build.gradle.ktsalias(libs.plugins.jetbrains.kotlin.jvm) があるとデフォルトで jar タスクが生成されるので以下の Gradle Task を実行してみます。(test-jar はモジュール名です)

./gradlew test-jar:jar

そうすると test-jar/build/libs に test-jar.jar が生成されます。ただ、この段階では実行可能JARとしては利用できません。

🔗buildSrcディレクトリの作成

必須ではありませんが、複数モジュールでコードを再利用する際などに便利なため buildSrc を作ります。

今回は以下のようにファイルを配置しています。

/project-root
├─ buildSrc
│   ├─ src
│   │   └─ main
│   │        └─ kotlin
│   │             └─ ProjectExt.kt
│   ├─ build.gradle.kts
│   └─ settings.gradle.kts
└─ test-jar
     └─ src

現状全て必須な記述ではありませんが、ひとまず build.gradle.kts は以下のように記述し、

plugins {
    `kotlin-dsl`
}

repositories {
    google()
    mavenCentral()
}

settings.gradle.kts は以下のように記述します。

dependencyResolutionManagement {
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

これで buildSrc 内でも他のモジュール同様にバージョンカタログを利用して dependensies などが書けるようになります。

🔗R8ライブラリを利用出来るようにする

詳細は後述しますが、Android開発者にはおなじみ?の R8 も利用したいため libs.versions.toml に以下を追記します。

[versions]
r8 = "8.5.35"

[libraries]
r8 = { module = "com.android.tools:r8", version.ref = "r8" }

また、適用するルールファイルを test-jar モジュール配下に rule.txt という名称で配置しておきます。

-dontobfuscate
-keepattributes *Annotation*,Signature,SourceFile,LineNumberTable

-keep class jp.bpsinc.test.jar.MainKt {
  public static void main(java.lang.String[]);
}

main メソッドが消えないようにする設定は必須ですが、それ以外は任意に設定します。

🔗JAR生成タスクの改変

前述の buildSrc 配下に作っておいた ProjectExt.kt に以下の実装をします。

import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.MinimalExternalModuleDependency
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.bundling.Jar
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.creating
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getValue
import org.gradle.kotlin.dsl.named
import org.gradle.kotlin.dsl.register

fun Project.registerR8jar(r8Dependency: MinimalExternalModuleDependency, mainClassName: String) {
    val r8: Configuration by configurations.creating
    dependencies {
        r8(r8Dependency)
    }

    val jarName = "${project.name}.jar"
    val fatJarFile = layout.buildDirectory.file("libs/$jarName").get().asFile
    tasks.register<JavaExec>("r8Jar") {
        val r8JarFile = rootDir.resolve(jarName)
        val rulesFile = projectDir.resolve("rules.txt")

        inputs.file(fatJarFile)
        inputs.file(rulesFile)
        outputs.file(r8JarFile)

        classpath = r8
        mainClass = "com.android.tools.r8.R8"
        args = listOf(
            "--release",
            "--classfile",
            "--output",
            r8JarFile.path,
            "--pg-conf",
            rulesFile.path,
            "--lib",
            System.getProperty("java.home").toString(),
            fatJarFile.path,
        )
    }
    tasks.named<Jar>("jar") {
        duplicatesStrategy = DuplicatesStrategy.EXCLUDE
        manifest {
            attributes["Main-Class"] = mainClassName
        }
        from(
            configurations.getByName("runtimeClasspath").map {
                if (it.isDirectory) it else zipTree(it)
            },
        )
        exclude(
            "**/*.kotlin_builtins",
            "**/*.kotlin_metadata",
            "**/*.kotlin_module",
            "**/module-info.class",
            "META-INF/maven/**",
        )
        finalizedBy("r8Jar")
    }
}

そして test-jar モジュールの build.gradle.kts に以下を追記します。

val mainClassName = "jp.bpsinc.test.jar.MainKt"
registerR8jar(r8Dependency = libs.r8.get(), mainClassName = mainClassName)

これで改めて ./gradlew test-jar:jar コマンドを実行すると、プロジェクトのルートディレクトリ配下に test-jar.jar が生成されます。

test-jar モジュールのビルドディレクトリに出力されている build/libs/test-jar.jar はR8適用前のため Kotlin の標準ライブラリを丸ごと抱えており 1.6MB とかになっています。

ルートディレクトリに生成された test-jar.jar はR8が適用された結果サイズは10KBほどになっており、中身を覗くと以下のクラスファイルのみ含んでいます。

試しに以下のコマンドで jar を実行してみると、正常に main の実装内容が出力されました。

java -jar test-jar.jar
1: Hello hoge
2: Hello fuga

🔗JAR生成タスクの改変内容詳細について

改めて ProjectExt.kt に実装した registerR8jar が何を行っているか説明します。

val r8: Configuration by configurations.creating
dependencies {
    r8(r8Dependency)
}

このコードでは creatingConfiguration のオブジェクトを生成し、Configuration.invoke に registerR8jar の引数である r8Dependency を渡して依存関係の追加を行っています。

バージョンカタログを使用した引数での r8Dependency の受け渡しをやめて r8("com.android.tools:r8:8.5.35") をベタ書きしても問題ありません。

val jarName = "${project.name}.jar"
val fatJarFile = layout.buildDirectory.file("libs/$jarName").get().asFile

test-jar モジュール上の project.nametest-jar のため jarName は test-jar.jar となります。
fatJarFile はモジュールのビルドディレクトリ内の libs/test-jar.jar を参照しています。このファイルは通常の jar タスクの出力結果である jar ファイルです。

tasks.register<JavaExec>("r8Jar") {
    ...
}

tasks.register<JavaExec>("r8Jar") では r8Jar という名称の新規JavaExecタスクを登録しています。このタスクがR8の処理を実行します。

内部で設定している各種値の詳細は以下の通りです。

  • inputs: 先ほどの fatJarFile と事前に test-jar モジュール配下に置いておいた rule.txt を入力ファイルとして指定
  • outputs: rootDir.resolve(jarName) を出力ファイルとして指定 (ルートディレクトリ直下に jar を出力)
  • classpath: 最初にR8の依存関係を追加した r8 変数を指定
  • mainClass: R8のメインクラスを指定
  • args: 引数を指定、詳細はR8のドキュメントを参照
tasks.named<Jar>("jar") {
    ...
}

jar タスクを named のラムダ(configuration)内で修正しています。
build.gradle.kts 上であれば alias(libs.plugins.jetbrains.kotlin.jvm) によって追加された tasks.jar にアクセスすることも可能ですが、buildSrc からはアクセス出来ないため tasks.named を利用しています。

ちなみに jar タスクが存在しない環境だと not found でエラーになります。

内部で設定している各種値の詳細は以下の通りです。

  • duplicatesStrategy: 依存関係の多い環境などで高確率でエラーとなるため EXCLUDE を設定
    • 必要に応じて設定変更して問題ありません
    • test-jar のような簡単な処理だとデフォルトの FAIL でも問題なし
  • manifest: attributes["Main-Class"] に registerR8jar の引数で渡されたメインクラス名を設定
    • この指定を追加することで 実行可能 なJARファイルになります
  • from: Kotlin の標準ライブラリなどを含むJARの実行に必要なクラスファイルを configurations.getByName("runtimeClasspath") からかき集めて指定
    • ここも build.gradle.kts 上でなら getByName などを使わず configurations.runtimeClasspath.get() でアクセス出来ます
  • exclude: JARファイル内に含める必要のないものを指定
  • finalizedBy: 前述の tasks.register<JavaExec>("r8Jar") で登録した r8Jar という名称のタスクを後処理として設定

jar タスクが実行されビルドディレクトリに libs/test-jar.jar が生成されたあと、finalizedBy に指定した r8Jar タスクが動作し、ルートディレクトリにR8が適用された jar が出力されます。

🔗JARを生成せずに同じ処理を実行したい場合

開発中などに何度もJARの生成を繰り返すのは面倒くさいため、直接 main 関数を実行出来るようにしておくと便利です。

tasks.register<JavaExec>("run") {
    dependsOn("compileKotlin")
    mainClass = mainClassName
    classpath = sourceSets["main"].runtimeClasspath
    if (project.hasProperty("args")) {
        args = (project.property("args") as String).split(Regex("\\s+"))
    }
}

今回は build.gradle.kts に上記 run タスクを登録して対応してみます。 mainClassName は registerR8jar に指定したのと同じ変数となります。

Gradle Task は通常のプログラムの関数のように引数が渡せず面倒くさいのですが、上記のような記述をすると以下のようにコマンドを入力することで引数入力に対応出来ます。

./gradlew test-jar:run -Pargs="arg1 arg2 arg3"
> Task :test-jar:run
1: Hello hoge
2: Hello fuga
args[0]: arg1
args[1]: arg2
args[2]: arg3

コマンドラインではなく、別の Gradle Task から実行することも可能です。

tasks.register("hoge") {
    doLast {
        rootProject.project("test-jar").tasks.named<JavaExec>("run") {
            args = listOf("arg1", "arg2", "arg3")
        }.get().exec()
    }
}

上記は test-jar とは別のモジュールの build.gradle.kts から test-jar:run を任意の引数を指定して呼び出す例となります。

別のモジュールの Task を取得するには rootProject を経由して該当モジュールの Project を取得してから Task を探しに行く必要があります。
同一モジュールの build.gradle.kts から呼び出す場合は rootProject.project("test-jar"). の記述は不要です。

注意点として、この直接 exec() を呼ぶやり方だと run の中で指定している dependsOn("compileKotlin") などの依存関係が無効になります。
そのため、ビルド前に hoge を実行しようとするとエラーになります。

これを解消したい場合は hoge の方でも dependsOn("compileKotlin") する必要があります。

finalizedBy(":test-jar:run") を使うという手もありますが、この場合は更に注意点があります。

タスクの構成は評価フェーズで行いたいわけですが、register 直下だとまだ run タスクが評価前で named 実行時点でエラーになることがあります。

その場合も一応 doLast の中で同様の処理をすることで対応は可能ですが、doLast の中は実行フェーズのためタスクの構成変更箇所としては推奨されていません。
(このやり方でも doLast 内で行った構成変更が適用されたあと、finalizedBy に指定したタスクが動くのでやりたいことは可能です)

タスクの構成変更箇所としては afterEvaluate もありますが、これだと hoge タスク実行時のみ run の構成変更をする、ということが出来ず、run を直接実行した場合も引数が適用されてしまいます。

あとは named の代わりに getByName を使うことでエラーの回避ができましたが、これは named (戻り値が TaskProvider) と違い、getByName (戻り値が Task)実行時点でタスクの評価がされるからのようです。

タスクの即時評価(タスクの遅延評価がされない)は推奨されるやり方から外れますし、(tasks.getByName("execute") as JavaExec).apply {} のように若干冗長な記述になる問題もあります。
(とは言え、小さいプロジェクトであればこれでも良さそう)

🔗commandLine 実行について

Gradle Task で Exec タスクや exec を利用して commandLine を実行したいことがちょくちょくあると思います。

JARに全て処理を移行するとこれがそのまま使えなくなるため、ただの Kotlin 処理ではありますが一応移行方法を記載しておきます。

private fun commandLine(vararg args: String): Int {
    val process = ProcessBuilder(createCommand(*args))
        .directory(File("."))
        .start()
    return process.waitFor().also {
        process.inputStream.bufferedReader().use { it.readText() }.let {
            if (it.isNotEmpty()) {
                println(it)
            }
        }
        process.errorStream.bufferedReader().use { it.readText() }.let {
            if (it.isNotEmpty()) {
                System.err.println(it)
            }
        }
    }
}

private fun getExecuteFileName(name: String): String = if (isWindows) {
    "$name.exe"
} else {
    name
}

private fun createCommand(vararg args: String): List<String> = if (isWindows) {
    listOf("cmd", "/c", *args)
} else {
    listOf(*args)
}

private val isWindows: Boolean
    get() = System.getProperty("os.name").contains("Windows", ignoreCase = true)

commandLine の中は標準APIである ProcessBuilder に書き換えただけです。

build.gradle.kts に記載する時も同じですが、Windows だと args の先頭に "cmd", "/c" を渡す必要があるので createCommand 内で isWindows して差異を吸収しています。
実行ファイルも同様に getExecuteFileName で差異を吸収する想定です。

例えば ANDROID_HOME にインストールされてる cmake コマンドを実行したい場合、以下のような感じになります。

object AndroidSdk {
    val sdkHome: File by lazy {
        System.getenv("ANDROID_HOME")?.let { path ->
            File(path).takeIf { it.isDirectory }
        } ?: throw IllegalStateException("ANDROID_HOME environment variable is not set")
    }
}
class AndroidCmake(version: String) {
    val cmakeHome: File = AndroidSdk.sdkHome.resolve("cmake/$version").also {
        if (!it.isDirectory) {
            throw IllegalArgumentException("CMake version $version not found: ${it.path}")
        }
    }
    val cmake: File by lazy {
        cmakeHome.resolve("bin/${getExecuteFileName("cmake")}").also {
            if (!it.isFile) {
                throw IllegalStateException("cmake not found: ${it.path}")
            }
        }
    }
}

fun build() {
    commandLine(AndroidCmake("3.22.1").cmake.path, "args")
}

🔗終わりに

今回の作業を終えたあとの感想としては、既存のJDK(JRE)環境で動くものを既存のKotlin資産を流用して作る分には十分アリ、という印象でした。

似た事情がなくとも、実行可能JARを作りたいニーズがあれば是非試していただければと思います。

本音を言うとKotlinで実行可能JARを作成するもっと簡単な方法を公式で用意して欲しいところですが。

関連記事

利用目的別 Kotlin Flow/Channel まとめ

AGP8とJDK17に対応した際の調査結果まとめ


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。