Kotlin/Native で CLIツールを作る

暇な時間にちょいちょい Kotlin/Native で CLIツールを作っていた。
お題としては、最近自分の会社で GHE を使っていて、 GitHub の Schedule Reminder という 2021-01-04 に書かれている機能が GHE になく PR のレビューをする際に困っていたというのを解決したかった。

https://github.com/omuomugin/hubber に作ったツールは置いてある。

# Review を要求されている PR 一覧を見る
hubber fetch

# Review が要求されている PR をブラウザで全部開いたり
hubber open --all | xargs open -a '/Applications/Google Chrome.app' $1

その中で使っていた技術スタックとかをメモしておく

技術スタック

build.gradle を見せるのが早い気がするので貼る

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-cli:0.3.2")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-native:1.3.8")
    implementation("io.ktor:ktor-client-core:1.6.1")
    implementation("io.ktor:ktor-client-curl:1.6.1")
    implementation("io.ktor:ktor-client-serialization:1.6.1")
    implementation("io.ktor:ktor-client-logging:1.6.1")
    implementation("com.squareup.okio:okio-multiplatform:3.0.0-alpha.8")
}

CLI Parser

Kotlin/kotlinx-cli を利用した。
今まで、 ajalt/clikt を使ったことはあったが、せっかくだしということで Kotlin 公式のものを使ってみた。
まだかなり Experimental な API みたいでドキュメントが充実していなくて困ったことは多かったが、テストコードがあるのである程度はどうにかなった。

Serialization

これもせっかくなので Kotlin/kotlinx.serialization を利用した。
昔使った時は、対応フォーマットも少なかったが今は割と充実していた。

1点だけ戸惑ったのは、

@Serializable
data class Hoge(
    @SerialName("fuga")
    val fuga: String
)

という感じで、 @Serializable に対応するフィールド名の指定が @SerialName だったこと。

あとはドキュメント上は、

@Serializable
data class Project(val name: String, val language: String)

fun main() {
    // Serializing objects
    val data = Project("kotlinx.serialization", "Kotlin")
    val string = Json.encodeToString(data)  
    println(string) // {"name":"kotlinx.serialization","language":"Kotlin"}
    // Deserializing back into objects
    val obj = Json.decodeFromString<Project>(string)
    println(obj) // Project(name=kotlinx.serialization, language=Kotlin)
}

ってなっているが、 Json には、引数が1つだけの encodeToStringdecodeFromString はなく以下のように Json の設定をした後に引数が1つのメソッドを実行できる。

val defaultJson = Json {
    isLenient = false
    ignoreUnknownKeys = true
    allowSpecialFloatingPointValues = true
    useArrayPolymorphism = false
}

defaultJson.encodeToString(data)

Network I/O

Ktor を利用した。
これは特に意図はなかったが、Multiplatform 対応していて有名だから程度で利用した。

File I/O

これは意外だったのだけれど、Multiplatform でまともに動く File I/O がまだあまりなかった。
Kotlin/kotlinx-io があるけれどかなり機能が限定的になっている。

結局 square/okio の Multiplatform 対応版を利用した。
残念ながらドキュメントがほとんどなかったので okio/src/commonTest/kotlin/okio あたりのテストを解読しながら実装していった。

特に AbstractFileSystemTest.kt のテスト を多く参考にした。

課題点

概ね期待する動作は実装できたが、いくつかまだ解決していない課題点がありやる気が出たら対応する。

MacOS 以外でエラーになる

MacOS 以外に Linux 向けのバイナリを生成しようとしたのだけれど、Ktor の Curl Engine がどうやら Linux 版でエラーを吐いてるっぽい。
家に Linux 環境ないので GitHub Actions の環境で調べるしかなくやる気が起きたら調べる。

.kexe が生成される

https://github.com/JetBrains/kotlin-native/issues/967 の Issue でも話されてるようにどの環境向けのバイナリかを区別するために MacOS 向けのバイナリは .kexe という拡張子で出力される。
当然バイナリを利用する人たちには馴染みのない概念なので、自分は GitHub Action でバイナリを生成するときに拡張子を消している。

まとめ

今回の CLI ツールは実働でいえば12時間くらいで作れたので割とさくっと MacOS でも動くような CLI ツールは Kotlin でもさくっと作れるようになっていそうな感じ。
I/O 周りがもうちょっとよくなることを祈ってる。