Kotlinから学んだこと:Kotlin DSLとAnnotation
最近 Kotlin で日常タスクを管理するちょっとしたツールを開発した。主にバックエンドの同僚たちにも一緒にメンテナンスしてもらいたいという狙いがあり、また新しい言語を学ぶことで常に新しい刺激やアイデアが得られること、そして社内にたくさんいるJavaやJVMの達人たちから知識を吸収する良い機会だったからだ。
この日常タスク管理ツールの機能はとてもシンプルだ:
- TODOのCRUD操作が可能で、各Todoは作成後にスケジュールが設定され、時間内に完了しなかった場合はSlackに通知が飛ぶ
- 毎日のPull Request確認やJiraの進捗確認など、繰り返しのスケジュールを設定できる
Kotlin は書いていて非常に心地よい。静的型付けの安全性とコンパイラによる恩恵を保ちつつ、Java 特有の冗長さを抑えている。さらに、Kotlin にはコードを簡潔にするための便利な構文が数多く用意されている。この記事では、僕がKotlinから学んだいくつかの概念を紹介する。
Kotlin DSL
以下のコードは、正当な Kotlin の構文だ。
Bot.sendMessage(channelId) {
section {
plainText("This is a plain text section block.")
}
section {
markdownText("This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>")
}
divider()
}
このコードを実行すると、以下のように変換される:
{
"blocks": [
{
"type": "section",
"text": {
"type": "plain_text",
"text": "This is a plain text section block.",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>"
}
}
]
}
Slackのブロック仕様に合わせて定義することで、メッセージを自由に組み立てることができる。
Slackにメッセージを送信する場合、SlackではBlock Kitを使ってメッセージを作成できる。公式提供のAPI では、次のように書くことができる:
val slack = Slack.getInstance()
val token = System.getenv("token")
val response = slack.methods(token).chatPostMessage { req -> req
.channel("C1234567")
.blocks {
section {
// "text" fields can be constructed via `plainText()` and `markdownText()`
markdownText("*Please select a restaurant:*")
}
divider()
actions {
button {
text("Farmhouse", emoji = true)
value("v1")
}
button {
text("Kin Khao", emoji = true)
value("v2")
}
}
}
}
とても分かりやすく、まるで何らかの Component を書いているような感覚になり、一瞬でKotlinの構文に好感を抱いた。
また、exposed というライブラリでは、SQL構文を次のように書くことができる:
val status = "DONE"
Todos.select {
Todos.status eq (status)
}
これもKotlin DSLの特性を活かして、「まるでSQLを書いているかのような」感覚を実現している。
では、Kotlin DSLは裏でどのように実現されているのだろうか?主に以下の概念が組み合わさっている:
- extension(拡張関数)
- infix notation(中置記法)
- ラムダ式
- function literal with receiver(レシーバ付き関数リテラル)
Extension
Kotlinでは、継承やデコレータパターンを使わずに、既存のクラスに新しいメソッドを追加できる。例えば以下のような構文だ:
fun Int.add2() {
this + 2
}
こうすることで、Int 型に対して直接次のように呼び出すことができる:
fun add() {
val a = 100
a.add2()
}
拡張の実装は、クラス内に無理やりメソッドを埋め込んでいるわけではなく、dot(ドット)でメンバー関数を呼び出す際に、拡張関数として実装されたメソッドを呼び出せるようにしている。では、なぜ直接クラス内に実装しないのか?サードパーティ製ライブラリの実装であったり、元のコードを簡単に変更できない場合には、拡張関数が非常に役立つ。
Infix notation
Kotlinでは、関数に infix を付けると、その関数を dot や括弧を省略して直接呼び出すことができる。dotや括弧を省略できるようにするためには、infix記法はいくつかの条件を満たす必要がある:
- メンバー関数または拡張関数であること
- パラメータが1つだけであり、かつ1つ必須であること
- デフォルト値を持てないこと
動作は以下のようになる:
infix fun Int.addSome(num: Int) {
this + num
}
fun add() {
val a = 100
a.addSome(10)
a addSome 10
}
ここで、a.addSome(10) と a addSome 10 の2つは完全に等価だ。もちろん 10 addSome a も正当な式となる。
Kotlinで最もよく見かけるのは、おそらく to というinfix関数だろう。Kotlinでは次のようにMapを宣言できる:
val map = mapOf("name" to "kalan", "age" to 25)
to は実際にはinfix関数であり、Pairを生成するための便利な糖衣構文(シンタックスシュガー)だ。実装は以下のようになっている:
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
このような定義によって、コードを書く際の視覚的なノイズを極力減らし、可読性を大幅に向上させることができる。
ラムダ式
Kotlinのラムダ式は非常に柔軟で、次のように書くことができる:
fun test(a: Int, block: () -> Unit) {
}
test(1, {
println("hello")
})
ラムダ式が最後の引数である場合、このラムダ関数を括弧の外に出すことができる。こんな感じだ:
fun test(a: Int, block: () -> Unit) {
}
test(1) {
println("hello")
}
両者は完全に同じである。さらに、Kotlinはラムダ関数の型推論を行ってくれるため、もし次のように書いた場合:
fun test(a: Int, block: (i: Int) -> Unit) {
}
test(1) {
println("hello")
}
IDE上では it が値パラメータ(value parameter)として表示される。このitは、block内で渡された i のことだ。

これは以下と等価になる:
fun test(a: Int, block: (i: Int) -> Unit) {
}
test(1) { i ->
println("hello", i)
}
これについては、Kotlinの Scope Functions を参照すると、どのように動作しているかがよく理解できるだろう。
Function Literal with receiver
ここでは公式ドキュメントの例を直接見てみよう。Kotlinの関数ではこのように書くことができる:
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
まず init という引数を見てみよう。これは関数として宣言されており、同時にこの関数は HTML.() -> Unit と表されている。これをレシーバ付き関数型(function type with receiver)と呼ぶ。これは、ある時点で html をコンテキストとしてinit関数に渡す必要があることを意味しており、JavaScript の apply(context) に似ているが、型安全性を容易に保てる点が優れている。T.() -> K は、型Tのメンバー関数を実行し、K を返すことを表す。
次に別の例を見てみよう。kotlinx.serialization では、このように Json を宣言できる:
val serializer = Json {
ignoreUnknownKeys = true
encodeDefaults = true
coerceInputValues = true
}
裏側の実装はこうなっている:
public fun Json(from: Json = Json.Default, builderAction: JsonBuilder.() -> Unit): Json {
val builder = JsonBuilder(from.configuration)
builder.builderAction()
val conf = builder.build()
return JsonImpl(conf)
}
つまり、先ほどのコードにおいて、実際の実行状況は次のようになる:
val builder = JsonBuilder()
builder.ignoreUnkownKeys = true
builder.encodeDefaults = true
builder.coerceInputValues = true
builder.build()
上述したいくつかのKotlinの構文と少しの想像力を組み合わせることで、シンプルかつ強力なDSL構文を作り、コードを簡潔にすることができる。しかも、すべてが型安全(type-safe)なのだ!
Annotation
僕がCronjobの実装を書いていたとき、可読性を高めるためにcronjob用のアノテーションを実装した。これはSpring Bootの @Scheduled から着想を得たものだ:
@CronSchedule("00 18 * * 1-5")
class NotifyMeCheck: Scheduler(), Workable {
override fun work(args: Arg) {
// job implementation
}
}
@Target(AnnotationTarget.CLASS)
@MustBeDocumented
annotation class CronSchedule(val cronExpression: String) {}
そしてランタイム時に、すべての CronWorkEntry から CronSchedule が付与されたクラスを探し、cronjobのスケジュールを登録する。大体このような感じだ:
CronWorkEntry::class.nestedClasses.forEach {
val annotation = it.findAnnotation<CronSchedule>()
if (annotation != null) {
val worker = it.createInstance() as Scheduler
worker.performCron(annotation.cronExpression,"${CronWorkEntry::class.qualifiedName!!}$${it.simpleName!!}", "", true)
}
}
ただ、ここで it.createInstance() as Scheduler としている部分に少し違和感がある。コンパイル時にクラスが必ず Scheduler であることが強制されていないため、継承していない場合は直接 Exception が発生してしまうからだ。アノテーションを特定のクラス型にのみ適用するよう制限する方法はあるのだろうか。
関連記事
- 測定が目標になるとき:窓税からPull Request数まで かつて僕は小さなツールを自作し、四半期で自分がどれだけPRに貢献したか、レビューコメントをどれだけ残したか、チケットをどれだけ消化したかを集計して、上司にアウトプットを証明しようとしたことがある。上司は淡々と、評価はアウトプットだけで見るものではないと言った。数年後、僕はようやく理解した――測定が目標になるとき、それはもはや良い測定ではなくなるのだ。英国の窓税、ハノイのネズミ駆除の報奨金から、現代のPR数による開発者評価に至るまで、そのメカニズムはまったく同じだ。
- Cloudflare Images を画像ストレージ・変換ソリューションとして使う ウェブページに画像を1枚置くのはフロントエンドにとって最も簡単なことだが、リサイズや各種フォーマットの生成、さらにはトラフィックの負荷に耐えることまで完璧にやろうとすると、実際には一つの包括的なソリューションが必要になる。僕はその後、すべて Cloudflare Images に任せるようになり、オリジナル画像1枚だけを渡すようにしている。
- もう AWS Access Key を使うのはやめよう Access Key は AWS において見落とされがちなセキュリティリスクだ。OIDC と IAM Role を組み合わせることで、GitHub Actions にシークレットを一切保持させることなく、安全に AWS リソースを操作できるようにする。
- データベース主キー:AUTO_INCREMENT、UUID、そしてUUIDv7 バックエンド開発で度々直面する主キーの決定。auto incrementを使うべきか、それともUUIDか?衝突への懸念は?UUIDv7とcreated_at + インデックスの性能差はどれほどか?実際に2,000万件のデータで検証したベンチマークと設計上の意思決定を解説する。