KtorのカスタムFeatureをつくる
Ktorの面白い特徴のひとつとしては「Webアプリケーションに必要な機能を"install"していくスタイル」を採っていることだと思います。 その典型的な例はルーティングです。よく見るルーティング設定のコードは
routing { get("/") { call.respondText("Hello, world!") } }
のように routing
を使うものが多いと思いますが、これは
install(Routing) { get("/") { call.respondText("Hello, world!") } }
と記述するのとだいたい同じです。
その他にも標準で提供されているFeatureはたくさんあって、例えばContentNegotiation
やCompression
のようなものがあります。
install(ContentNegotiation) { jackson() } install(Compression) { gzip { priority = 1.0 } } install(Routing) { get("/") { call.respondText("Hello, world!") } }
今回は、このFeatureを自作してみます。 有用なものを作って配布したら、いろんな人にinstallして使ってもらえるかもしれませんね。
参考
とは言っても役に立ちそうなアイデアがパッと浮かばないので、単純なロギングFeatureを実装したいと思います。 ポイントは
- 大元となるFeatureクラスを定義する(今回は
MyLogging
クラス) - そこに設定用クラスをネストする(名前は何でもいいけど
Configuration
クラスとします) ApplicationFeature
インタフェースを実装したcompanion objectを定義する
です。 必ずしもこれに従うことはないとは思いますが、上記参考URLの公式ドキュメントではこうなっていました。 ざっとこんな感じになります(importは割愛)。
class MyLogging(configuration: Configuration) { val decoration: String = configuration.decoration class Configuration { var decoration: String = "✨" } companion object : ApplicationFeature<ApplicationCallPipeline, Configuration, MyLogging> { override val key: AttributeKey<MyLogging> = AttributeKey("MyLogging") override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): MyLogging { val configuration = Configuration().apply(configure) val feature = MyLogging(configuration) /* TODO */ return feature } } // あとで使う private fun log(message: String) { println("$decoration $message $decoration".trim()) } }
このコードで一旦はFeatureとしての体を成しています。が、現時点では何も仕事はしていません。
メソッドinstall
の引数configure
が、独自定義の設定用クラスConfiguration
の拡張関数になっていることに注目してください。
このFeatureのユーザは、ラムダ式の中でConfiguration
のプロパティにアクセスすることで設定を組み立てていきます。
install(MyLogging) { decoration = "🌟" } routing { get("/") { call.respondText("Hello, world!") } }
さて、まだ何の面白いこともしていないMyLogging
ですが、リクエストの前後でログを出力したいと思います。
2つ前のコードのメソッドinstall
に再び注目してください。第1引数pipeline
を扱います。
PipelineもKtorの面白い特徴のひとつなんですが、ドキュメントコメントの言葉を借りればこれは「非同期の拡張可能な計算のための実行パイプラインを表す」ものです。
Pipelineにはフェーズがあって、そのフェーズをインターセプトすることができます。
override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): MyLogging { val configuration = Configuration().apply(configure) val feature = MyLogging(configuration) pipeline.intercept(ApplicationCallPipeline.Features) { feature.log("${call.request.path()}へのリクエストが始まるよ!") proceed() feature.log("${call.request.path()}へのリクエストが終わったよ!") } return feature }
余談ですが、メインのApplicationCallPipeline
には5つのフェーズがあって、今回は"Features"というフェーズをインターセプトしました。
本来であれば"Monitoring"でやるべきだったのかなと思いつつCallLoggingという標準のロギングFeatureのコードを読んでみたら、新たに"Logging"というフェーズを作り、"Monitoring"の前に挿入していました。そういうプレイングもあるのか…。
なお、今回つくってみたMyLogging
の使ってみた結果はこんな感じです。
CallLogging
も併用しています。
Ktorのルーティングとコントローラについて考える
アドベントカレンダーの季節なので、KtorでWebアプリケーションを作るときのことを考えたメモを残します。
いわゆるコントローラ、リクエストを受けてごにょごにょしてレスポンスを返すところを担うやつ。 そしてルーティング、パスとコントローラを紐づけるやつ。 Ktorでこの両者をどう扱うかを考えてみました。
HelloWorldの延長線上でズラズラとコードを並べるとこんな感じになります(完全なコードではありません)。 コントローラの役割とルーティングが同居する世界です。 これを基本形として、いくつかの工夫を見ていきたいと思います。
val userRepository = MyUserRepository() routing { route("users") { get { val users = userRepository.findUsers() call.respond(users) } post { ... } route("{id}") { get { val id = call.parameters["id"]?.toLongOrNull() val user = id?.let { userRepository.findUser(it) } if (user == null) { call.response.status(HttpStatusCode.NotFound) return@get } call.respond(user) } delete { ... } } } }
公式サンプルではどうやっているか
Ktorはサンプルプログラムを豊富に提供していて、その中にはそこそこ本格的なアプリケーションも含まれています。
例えばTwitterみたいなKweetというサンプルです。
このサンプルで、今回注目したいのはLocationsというfeatureと Route
の拡張関数を使っているところです。
Locationsはパスを表現するクラスをいい感じに扱えるようにしてくれます。
詳細は公式ドキュメントに譲りますが、Locationsがどのように使われているのかざっくり説明します。
単純のためKweetのコードを引用せず、上記のようなUser APIについて考えます*1。
まず @Location
にパスを指定して、クラスにくっつけます。
@Location("users") class Users { @Location("{id}") class Member(val id: Long) }
@Location
がついたクラスを入れ子にすることで user/{id}
のようなパスを表現することが可能です。
ルーティングをこのように書き直すことができます。
routing { get<Users> { val users = userRepository.findUsers() call.respond(users) } post<Users> { ... } get<Users.Member> { val user = userRepository.findUser(it.id) if (user == null) { call.response.status(HttpStatusCode.NotFound) return@get } call.respond(user) } delete<Users.Member> { ... } }
users/{id}
で指定したパスパラメータが Users.Member
オブジェクトのプロパティ id
にセットされるので、これを簡単にアクセスすることができます。
Kweetではさらに、パスとHTTPメソッドの組みそれぞれに対して Route
の拡張関数を別ファイルに定義しています。
fun Route.showUser(userRepository: UserRepository) { get<Users.Member> { val user = userRepository.findUser(it.id) if (user == null) { call.response.status(HttpStatusCode.NotFound) return@get } call.respond(user) } }
このような拡張関数を定義して、 routing
のラムダ式の中身をこのように書き換えます。
routing { listUsers(userRepository) createUser(userRepository) showUser(userRepository) deleteUser(userRepository) }
ファイルを分割し、見通しはよくなった気がしますが、実質的にはコントローラの役割とルーティングが合わさっているように思えます。
拡張関数 showUser
は、パスパラメータ id
をより抽象的な概念として捉えることができますが、自分のパスは Users.Member
クラスと対応していることを知っています。
コントローラクラスを導入する
個人的にこれを改善しうるのって、いつものコントローラクラスだと思っています。 リソース1種類につき、ひとつのコントローラクラス。 素直にコーディングすると、今回の場合はこのような感じになりそうです。
class UserController(private val userRepository: UserRepository) { suspend fun index(call: ApplicationCall) { val users = userRepository.findUsers() call.respond(users) } suspend fun create(call: ApplicationCall) { ... } suspend fun show(call: ApplicationCall, id: Long) { val user = userRepository.findUser(id) if (user == null) { call.response.status(HttpStatusCode.NotFound) return } call.respond(user) } suspend fun delete(call: ApplicationCall, id: Long) { ... } }
そしてルーティング側はこうなります。 もはやLocationsはなくていいかもしれませんね。
val userRepository = DummyUserRepository() val userController = UserController(userRepository) routing { get<Users> { userController.index(call) } post<Users> { userController.create(call) } get<Users.Member> { userController.show(call, it.id) } delete<Users.Member> { userController.delete(call, it.id) } }
という感じで、一旦の僕の中での結論としてはパスとHTTPメソッドを知らないコントローラを定義して、その各メソッドを routing
の中でパスとHTTPメソッドに紐づける方式がよいのではなかろうかと。
で、Locationsも要らない、ややこしくしているだけ説。
UbieではKtorアプリケーションが爆誕しつつあるんですが、リリースされて本格的に運用が始まったらこの仮説は崩れるのだろうか。。
ベスプラ知りたい。