最近Ktorを触りだしたのですが、あまりにもドキュメントがなかったのでLocations
で試したものを書いてみました。
Versionは0.9.0
です。
Ktorとは?
Kotlin製のWeb frameworkです。
Easy to use, fun and asynchronous.
を謳っており、現状まだExperimentalなKotlinの言語機能coroutine
を利用した実装がされています。
Getting startedや詳細は、ktor.ioやGitHubのリポジトリを参照してください。
Locationsとは?
KtorはFeature
と呼ばれるもの必要に応じてinstall
することでWebアプリケーションに必要な機能を拡張するようになっています。(公式ページ)
Feature
はここなどで実装が進められており、Auth
, WebSocket
, Jackson
, Gson
, Guice
, HtmlBuilder
などがあります。
Locations
はFeature
のうちのひとつで、install
することでType safe routingを実現できます。
でも公式にあるのは空のページだけ…
なので、ktor-locations
の実装やテストコードを読みながら試しました。
installとは?
前述の通り、Webアプリケーションに機能を追加するための仕組みです。
コードでは以下のようになります。
// アプリケーションのエントリポイント
fun Application.main() {
install(Locations) // ← Locations featureをinstall
install(WebSockets) { // ← WebSockets featureをinstall
pingPeriod = Duration.ofMinutes(1) // ← WebSocketsの設定
}
install(Routing) { // ← Routing featureをinstall
get("/") { // ← GET `/` の時の処理を定義
call.respond("Hello world from Ktor!")
}
}
}
このように必要な機能をinstall
することで、その機能が使えるようになります。
Webアプリケーションに必須なRouting
でさえもFeature
として提供されています。
自前でFeature
の実装もできるようで、簡単なサンプルもあります。
Locations featureの使い方
何ができるのか?
公式のドキュメントがまだないので自分の理解できている範囲での話になりますが、
- パスを
class
やobject
を使って構造化でき、一箇所にまとめることができるようにする - 型安全にリクエストパラメータやクエリパラメータを扱えるようにする
- 各パスでの処理の指定を
String literal
ではなく、構造化されたclass
やobject
で行えるようにする
などかなと思っています。
以下のエンドポイント(GET)でのパス、リクエストパラメータ、クエリパラメータの扱いをSpringと比較してみます。
- /users
- /users/search
- /users/{id}
User
クラスやUserService
クラス、そのメソッドなどはある前提で🙏
Spring
@RestController
@RequestMapping("/users")
class UserController(private val userService: UserService) {
@GetMapping
fun index(): List<User> {
val users = userService.findAll()
return users
}
@RequestMapping(value = "/search", method = RequestMethod.GET)
fun search(@RequestParam(name = "id", required = false) id: Int?, @RequestParam(name = "name", required = false) name: String?): List<User> {
val users = userService.search(id, name)
return users
}
@RequestMapping(value = "/{id}", method = RequestMethod.GET)
fun get(@PathVariable int id): User {
val user = userService.find(id)
return user
}
}
Locations
// エントリポイント
fun Route.main() {
install(Locations)
install(Routing) {
users()
}
}
// /users のパスを組み立てる
@location("/users") class Users {
@location("/search") data class Search(val id: Int? = null, val name: String? = null)
@location("/{id}") data class Id(val id: Int)
}
// /users 以下の実装
fun Route.users() {
val userService = UserService()
get<Users> {
val users = userService.findAll()
call.respond(users)
}
get<Users.Search> {
val users = userService.search(it.id, it.name)
call.respond(users)
}
get<Users.Id> {
val user userService.find(it.id)
call.respond(user)
}
}
ひとつずつ見ていきましょう。
パスの構造化とリクエスト・クエリパラメータの扱い
Springでは@RequestMapping
などでパスを指定し、UserController
クラス全体を使って構造化しています。
@RequestMapping("/users") // ← ここ
class UserController(private val userService: UserService) {
@GetMapping // ← ここ
fun index(): List<User> {...}
@RequestMapping(value = "/search", method = RequestMethod.GET) // ← ここ
fun search(@RequestParam(name = "id", required = false) id: Int?, @RequestParam(name = "name", required = false) name: String?): List<User> {...}
@RequestMapping(value = "/{id}", method = RequestMethod.GET) // ← ここ
fun get(@PathVariable int id): User {...}
}
それに対し、KtorのLocations featureでは@location
をつけたクラスで構造化します。
// /users の定義
@location("/users") class Users {
// /users/search?id={id}&name={name} の定義
@location("/search") data class Search(val id: Int? = null, val name: String? = null)
// /users/{id} の定義
@location("/{id}") data class Id(val id: Int)
}
@location
に渡すString
でパスを表し、@location
をつけたclassのフィールドでリクエストパラメータやクエリパラメータの値を扱うことができるようになります。
上記の通り、内部クラスに@location
をつけることでネストしたパスも表現ができます。
また、以下のように定義することもできます。
@location("/users/search") data class UserSearch(val id: Int? = null, val name: String? = null)
@location("/users/{id}") data class UserId(val id: Int)
ちなみに、object
でも定義できます。
@location("/single") objet Signle
@RequestMapping
と違い、ここでHttp Methodを指定することはできません。
クエリパラメータの扱い
Nullableは普段通り?
の有り無しで表現します。
Requiredはデフォルト引数を設定するかどうかで表現するようです。
// Non nullかつ`name`が必須
@location("/non-null-required") data class NonNullRequired(val name: String)
// Non nullかつ`name`が必須ではない
@location("/non-null-option") data class NonNullOption(val name: String = "hoge")
// Nullableかつ`name`が必須
@location("/nullable-required") data class NullableRequired(val name: String?)
// Nullableかつ`name`が必須ではない
@location("/nullable-required") data class NullableOption(val name: String? = null)
なので上記の実装から、Search
classのデフォルト引数を無くして、
@location("/search") data class Search(val id: Int? = null, val name: String? = null)
// ↓こうする
@location("/search") data class Search(val id: Int?, val name: String?)
/users/search
にクエリパラメータなしでリクエストを送ると/users/{id}
にマッチしてしまい、search
は数字じゃないと言われます。
同一名のクエリパラメータを複数扱いたい場合は、List
で受け取ることができます。
@location("/multiple-id") data class MultipleId(val id: List<Int>)
リクエストパラメータの扱い
リクエストパラメータは@location
に渡す文字列のうち、{}
で囲ったものをフィールドにマッピングすることができます。
リクエストパラメータは1つ以上指定でき、
@location("/users/{userId}/friends/{friendName}") data class UsersFriend(val userId: Int, val friendName: String)
のように定義することも可能です。
また、同一のリクエストパラメータ名を指定してList
として受け取ることもできます。
@location("/users/{id}/{id}") data class UserIds(val id: List<Int>)
ちなみに、
get<UserIds> {
call.respond(it.id)
}
に対して/users/1/2?id=3&id=4
のようにアクセすると[3, 4, 1, 2]
が返ってきます。
クエリパラメータに対して処理をしてからリクエストパラメータの処理をしているようです。
{}
で囲う文字とフィールド名は同じである必要があり、一致していない場合はアプリケーション起動時にio.ktor.locations.RoutingException
が発生しますが、ビルドは問題なく通ってしまいます。(もちろんIntelliJ IDEAも教えてくれない)
また、リクエストパラメータがフィールドの型と合わない場合はjava.lang.NumberFormatException
などが発生します。
パラメータを持つクラスのネスト
classを分けて構造化できるので、パラメータを持つクラスの中にさらにクラスを作ることができます。
その時は、1つ上の階層のクラスをフィールドとして持っていないといけないようです。
@location("/users") class Users {
@location("/{id}") data class Id(val id: Int) {
@location("/friends") data class Friends(val id: Id) {
@location("/{friendId}") data class FriendId(val friendId: Int, val friend: Friend)
}
}
}
最後のFriendId
classをId
classにすることはできません。
また、このような定義はビルド、起動までできますが、ランタイムでエラーになります。
@location("/users") class Users {
// このリクエストパラメータ名と
@location("/{id}") data class Id(val id: Int) {
@location("/friends") data class Friends(val id: Id) {
// このリクエストパラメータ名が一緒
// 🔥ここにアクセスするとエラーになる🔥
@location("/{id}") data class FriendId(val id: Int, val friend: Friend)
}
}
}
各パスでの処理の指定
Springの場合、@RequestMapping
などが同時に処理の指定となります。
@GetMapping // ← ここ
fun index(): List<User> {...}
@RequestMapping(value = "/search", method = RequestMethod.GET) // ← ここ
fun search(@RequestParam(name = "id", required = false) id: Int?, @RequestParam(name = "name", required = false) name: String?): List<User> {...}
@RequestMapping(value = "/{id}", method = RequestMethod.GET) // ← ここ
fun get(@PathVariable int id): User {...}
Location featureの場合、@location
を付与したclassとio.ktor.locations
packageにあるメソッドを使用して指定します。
// /users GETの処理
get<Users> {}
// /users/search GETの処理
get<Users.Search> {}
// /users/{id} GETの処理
get<Users.Id>
io.ktor.locations
packageにはHttp Methodに対応する以下のメソッドが定義してあります。
- get
- post
- put
- patch
- delete
- head
- options
メソッド名が違うだけですべて定義は以下のようになっており、
inline fun <reified T : Any> Route.get(noinline body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Unit): Route
イメージ的には
Users.Search().let {
hoge(it.name)
piyo(it.id)
}
のように使うことができます。
もちろん名前をつけることができるので、
get<Users.Search> { param ->
val users = userList.filter { it.id == param.id || it.name == param.name }
call.respond(users)
}
のように書くこともできます。
疲れたのでここまで。Springの例とか記憶を頼りに書いたので間違っているかもしれない。
Ktorのドキュメントがほんとにないので、調べたらちょくちょく書いていきたい。
install(Route) {
route("/users") {
get {...}
post {...}
route("/{id}") {
get {...}
patch {...}
route("/friends") {
get {...}
post {...}
}
}
}
}
みたいなことをLocations
でできないのかなぁと思ってるけど、わからないや。