try-catch 以外で例外ハンドリングがしたい
Ktor で例外ハンドリングを実装する方法が気になって夜も眠れませんでしたが、公式ドキュメントに書いてあったのでまとめておきます。
"Ktor 例外ハンドリング" で検索して出てこないのが Ktor の辛みだなあと思いながらこの記事を書いていますが、それはそれとして。
Status pages plugin を用いた例外ハンドリング
ルーティングからアプリケーションロジックが書かれたメソッドを呼んで、ルーティングはその返り値からレスポンスを返す、というパターンが多いと思います。
例外ハンドリング機構がない場合、全体を try で囲って考えられる例外を catch し、適当なレスポンスを返すコードを書く必要があります。
しかし、例外を気にしなくてもコードが書けるようになっているのにもかかわらず、ハッピーパスのコードと例外のことを気にしたコードが混在するのは美しくないでしょう。
Kotlin は全ての例外が unchecked なんだから、例外のことを気にしたコードを分離したいです。
そこで、Status pages plugin を使います。
このようなルーティングがあったとします。
import io.ktor.server.application.Application
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun Application.createExceptionRoute() {
routing {
get("/my-exception") {
throw MyException()
}
get("/exception") {
throw RuntimeException("throw by /exception")
}
}
}
class MyException() : RuntimeException("throw by /my-exception")
どちらのエンドポイントも例外を投げ、レスポンスを返していません。
レスポンスは、例外ハンドラーで書きます。
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respondText
import io.ktor.util.logging.error
import org.slf4j.event.Level
fun Application.createGlobalExceptionHandler() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.application.environment.log.error(cause)
when (cause) {
is MyException -> call.respondText(status = HttpStatusCode.BadRequest, text = cause.message ?: "")
is RuntimeException -> call.respondText(status = HttpStatusCode.InternalServerError, text = cause.message ?: "")
else -> call.respondText(status = HttpStatusCode.InternalServerError, text = cause.message ?: "")
}
}
}
}
例外がハンドリングされずに Ktor まで届いた場合、Status pages plugin を使って記述した処理が実行され、レスポンスが返ります。
例外処理を記述する順に依存しない
上の例では Throwable の例外ハンドラーのみを実装し、内部でパターンマッチを使って処理を分岐していましたが、例外ハンドラー自体を例外の種類で分けることが可能です。
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respondText
import io.ktor.util.logging.error
import org.slf4j.event.Level
fun Application.createGlobalExceptionHandler() {
install(StatusPages) {
exception<Throwable> { call, cause ->
call.application.environment.log.error(cause)
call.respondText(status = HttpStatusCode.InternalServerError, text = "")
}
exception<RuntimeException> { call, cause ->
call.application.environment.log.error(cause)
call.respondText(status = HttpStatusCode.InternalServerError, text = cause.message ?: "")
}
exception<MyException> { call, cause ->
call.application.environment.log.warn(cause.message?: "Exception of type ${cause::class}", cause)
call.respondText(status = HttpStatusCode.BadRequest, text = cause.message ?: "")
}
}
}
しかも、記述順に依存しないです。
MyException は RuntimeException を継承し、RuntimeException は Exception、Exception は Throwable を継承しています。
なので、Throwable の例外ハンドラーが最初に定義されていると、全ての例外をここでハンドリングしてしまいそうですが、/my-exception にリクエストした場合、BadRequest が返ります。
Status pages plugin を複数回呼べない
ルーティングをコンテキストで分けるようにしているので、例外ハンドラーも各ルーティングで実装したかったのですが、Status pages plugin を複数回呼ぶとエラーになり Ktor が起動しません。
例外ハンドラーは 1 つにまとめましょう。
まとめ
Status pages plugin を使うことで、Ktor でも例外ハンドリングをシンプルに実装することができました。
検証に使用したコードを置いておきます。