Kotlin から学ぶ:Kotlin DSL とアノテーション

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

最近 Kotlin で日常のタスクを管理する小さなツールを開発しました。主に、Backendの同僚たちにも一緒にメンテナンスを行ってもらうことを目的としています。また、新しい言語を学ぶことで新しいアイデアやインスピレーションが得られるのも楽しみの一つです。社内には多くの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の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

Kotlinでは、クラスに新しいメソッドを追加することができ、継承やデコレーターを使う必要がありません。以下のような構文で実現できます。

fun Int.add2() {
  this + 2
}

これにより、Int型で次のように呼び出すことができます。

fun add() {
  val a = 100
  a.add2()
}

extensionの実装は、メソッドをクラスに強制的に追加するものではなく、dotメンバー関数を呼び出す際にextension実装のメソッドを別に呼び出すことができるのです。なぜ直接クラスに実装しないのかというと、時にはサードパーティのライブラリの実装や簡単に変更できない場合に、extensionが非常に便利な方法だからです。

Infix notation

Kotlinでは、関数にinfixを追加すると、その関数はdotおよび括弧を省略して直接呼び出すことができます。dotと括弧を省略するためには、infix notationはいくつかの条件を満たす必要があります:

  • メンバー関数またはextension関数である必要があります。
  • 必ずかつ唯一の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は完全に同じです。10 addSome aももちろん合法な表現です。

Kotlinで最も一般的なのは、toというinfix関数です。Kotlinでは、次のようにマップを宣言できます。

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)

このような定義を通じて、コード記述時の視覚的雑音を可能な限り減らし、可読性を向上させることができます。

Lambda 表現

Kotlinのlambda表現は非常に柔軟です。次のように書くことができます。

fun test(a: Int, block: () -> Unit) {

}

test(1, {
  println("hello")
})

lambda表現が最後の引数である場合、このlambda関数を外に移動することができます。次のように:

fun test(a: Int, block: () -> Unit) {

}

test(1) {
  println("hello")
}

両者は完全に同じです。また、Kotlinはlambda関数の型推論を行うため、次のように書いた場合:

fun test(a: Int, block: (i: Int) -> Unit) {

}

test(1) {
  println("hello")
}

IDEでは、itが値パラメータとして表示されます。この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をcontextとして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.ignoreUnknownKeys = true
builder.encodeDefaults = true
builder.coerceInputValues = true
builder.build()

これらのKotlinの構文を駆使し、少しの想像力を加えることで、シンプルで強力なDSL構文を作成し、コードを簡素化することができます。そして、すべてがtype-safeです!

Annotation

Cronjobの実装を書く際、読みやすさを向上させるために、Spring Bootの@Scheduledを参考にしてcronjobのannotationを作成しました。

@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であることが強制されていないため、継承がない場合は直接例外が発生します。アノテーションを特定のクラス型のみに適用する方法があるのかどうか、知りたいです。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee