算譜王におれはなる!!!!

偏りはあると思うけど情報技術全般についてマイペースに書くよ。

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アプリケーションが爆誕しつつあるんですが、リリースされて本格的に運用が始まったらこの仮説は崩れるのだろうか。。 ベスプラ知りたい。

*1:KweetはSPAがREST APIを叩くというよりもページ遷移 + レンダリングなアプリケーションなので、今回の例とはそこらへんが異なりますが本質的な問題ではないでしょう。