最近、日常タスクを管理するための小さなツールを 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")
}
}
}
}
これは非常に明確でわかりやすく、あたかも特定の「コンポーネント」を作成しているような感覚を与えます。これにより、Kotlinの構文に対する好感が一瞬で湧きます。
また、exposed
というライブラリでは、次のようにSQLクエリを書くことができます。
val status = "DONE"
Todos.select {
Todos.status eq (status)
}
これもKotlin DSLの機能を活用して、まるでSQLを書いているかのような感覚を実現しています。
それでは、Kotlin DSLはどのように実現されているのでしょうか?主に次の概念を組み合わせています:
- 拡張関数 (extension)
- 中置記法
- ラムダ式 (lambda expression)
- レシーバ付き関数リテラル (function literal with receiver)
拡張関数
Kotlinでは、継承やデコレータの使用なしに、クラスに新しいメソッドを追加することができます。以下はその例です。
fun Int.add2() {
this + 2
}
これにより、Int
型で直接次のように呼び出すことができます。
fun add() {
val a = 100
a.add2()
}
拡張関数の実装は、メソッドをクラスに直接追加するのではなく、ドットメンバー関数を呼び出す際に拡張関数の実装を呼び出すことができるようにするものです。なぜクラス内に直接実装しないのかというと、サードパーティライブラリの実装や簡単に変更できない場合など、拡張関数が非常に便利な方法だからです。
中置記法
Kotlinでは、関数に infix
を付けると、ドットと括弧を省略して直接呼び出すことができます。中置記法を使用するためには、次の条件を満たす必要があります:
- メンバー関数または拡張関数である必要があります。
- 引数は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
です。Kotlinでは、次のようにマップを宣言できます。
val map = mapOf("name" to "kalan", "age" to 25)
to
は実際には中置関数であり、ペアを簡単に生成するためのシンタックスシュガーです。実装は次のようになっています。
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
が値パラメータとして表示されます。この it
は、ブロックに渡された i
のことです。
これは次のと同じ意味です。
fun test(a: Int, block: (i: Int) -> Unit) {
}
test(1) { i ->
println("hello", i)
}
これらの部分は、Kotlinの Scope Functions を参照して、それらがどのように動作するかを理解するのに役立ちます。
レシーバ付き関数リテラル
ここでは、公式ドキュメントの例を直接使用して説明します。Kotlinの関数内で次のように書くことができます。
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
まず、init
パラメータを見てみましょう。これは関数として宣言され、HTML.() -> Unit
で表されます。これは関数リテラルのレシーバ付き型です。つまり、ある時点で 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()
これらの機能を使用して、想像力を働かせることで、簡単でパワフルなDSL構文を作成し、コードを簡素化することができます。また、すべてが型安全です!
アノテーション
Cronjobの実装時に、読みやすさを向上させるために @Scheduled
から学んだ cronjob
のアノテーションを作成しました。
@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
のアノテーションが付いているクラスをスケジュールに登録するような処理を行います。以下はその例です。
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
をスローするからです。アノテーションが特定のクラスタイプにのみ適用されるようにするための方法があるかどうかはわかりません。