· 7分で読了

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 のことだ。

Screenshot from 2020-10-25 19-29-58

これは以下と等価になる:

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関数に渡す必要があることを意味しており、JavaScriptapply(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 が発生してしまうからだ。アノテーションを特定のクラス型にのみ適用するよう制限する方法はあるのだろうか。

関連記事

他のトピックを探索