Rails
Kotlin
exposed
Ktor
KotlinDay 7

Ktor+Exposedで考えるMVCライクなサーバサイドアーキテクチャ

今回Kotlinで,router + MVCのようなアーキテクチャによってAPIサーバを書いた時の話のうち,技術的なトピックのみをピックアップしてここに書くことにします.//TODO ポエムにならないようにする

目指す(していた)もの

Ruby on Railsのようなアーキテクチャ.といってもRailsにあんまり詳しくないのでいわゆるMVCのように層を分離した上で,アクセスをroutingすることができるもの.可能な限り100%Kotlinを目指します.

使用ライブラリ

知ってる人は読み飛ばしてください.

Ktor

Ktorは,JetBrains社主導で開発されるKotlin向けのサーバサイドフレームワークです.coroutines basedなフレームワークということもあり,最近やっとバージョン1.0.0を迎えました.このフレームワークの特徴をいくつか挙げるとすると,

  • micro web framework (microな機能しかない)
  • coroutines based (coroutinesで並列性を担保する)
  • DSL based (DSLの形式でサーバの環境を書いていく)
  • feature based (featureinstallすることで必要な機能を追加していく)
  • 100%Kotlin Project (Kotlin100%だねわ~い)

という感じです.

Exposed

これまた,JetBrains社主導で開発される.ORMapper.ドキュメントが絶望的に少ないためGitHubIssuesのライブラリ作成者による回答を見るか,自分でExposedのソースコードを読み解くのが一番早い(testコードとかはちゃんと書かれているので).100% Kotlin Project.

雑談

世の中で最近聞く声としてserver sideをKotlinに書き換えました!みたいなのはほとんどSpringBootばっかで,そんなんJavaやっていた人間が圧倒的に有利だし,Javaっぽくなりそう.あくまでもKotlin100%を目指したいよね.Kotlinかわいいもん.Ktorも使えるんだぞっていうのを布教していきたい.1.0.0だし.

アーキテクチャ

まずはじめに,目指すべき対象のRailsがどのような形をとっているかを自分なりにまとめると,以下のようになる.
image.png

これはつまり,routes.rbで各エンドポイントに対応するController/actionにアクセス内容が振り分けられ,それに応じて適切なmodelを参照,必要なデータを加工できたらviewに返すという流れになっています.

全く同じものをKtor+Exposedで実現する.

ディレクトリ構成

これからの話がわかりやすくなるようにこんな構成でやりますというのを書きます.

.
├── routes.kt  
├── config.kt  
├── controllers  
│   ├── BasicController.kt  
│   ├── UserController.kt  
│   └── RoomController.kt  
├── daos  
│   ├── Users.kt  
│   └── Rooms.kt  
├── models  
│   ├── User.kt  
│   └── Room.kt  
├── utils  
│   └── Calculator.kt  
└── views  
    ├── BaseResponse.kt  
    ├── BasicResponse.kt  
    ├── UserResponse.kt  
    └── RoomResponse.kt

routes.kt

これは

  • すべてのアクセスのエントリーポイント
  • 初期環境のセットアップ,各種機能のinstall
  • 各Controllerにエンドポイントを振り分ける役目

として用意します.

1つ目のエントリーポイントとしての運用はconfファイルでこのroutes.ktを指定する形で行うのが一般的だと思います.
2つ目は,DBへの接続をしたり,純粋にKtorの機能としてloggingをしたかったらinstall(CallLogging)を追記するという感じ.
最後に3つ目はfeatureの一つであるRoutingを用いて,controllerを追記する.controller自身はRouteの拡張関数として定義してあげることでこのシンプルな形を実現する.この話は後述のcontrollersで詳しく話します.

具体的にソースコードで示すと以下のような感じです.

fun Application.startApp() {
    install(Locations)
    install(CallLogging)
    install(Routing) {
        basicController()
        userController()
        roomController()
    }
}

これで来たアクセスを各Controllerに対して振り分けることができます.各Controllerの中身は別々のファイルで記述していきます.それらをまとめてcontrollersというディレクトリの中に入れました.

controllers

導入

controllerはRouteの拡張関数として定義します.つまり,以下のようになります.

fun Route.userController() {
    //ここで各リクエストを捌く
}

Locationsの話

さて,各リクエストのエンドポイントはどのようにして定義するのか?という疑問が残ります.それはktorのfeatureの一つであるLocationを用いることで解決します.

@Location("/users")
class Users {
    @Location("/create")
    class Create

    @Location("/search")
    data class Search(val name: String? = null)

    @Location("/{id}")
    data class Id(val id: Int){
        @Location("/edit")
        data class Edit(val id: Id, val name: String? = null, val symbol: String? = null, val abbreviation: String? = null, val teamName: String? = null)
    }
}

このように定義したエンドポイントを管理するクラスによって,実際にcontrollerでリクエストをハンドルします.これが実質的にrailsでのactionに対応する形になると思います

endpointの実装

実装の方針は基本的に,

GETデータの場合
  1. パラメータを適切なmodelに対して振り分ける.modelは各controllerで呼び出しておく.
  2. modelによって操作した後,必要なデータを引き抜く,加工する.
  3. views/ 以下に定義するresponseのフォーマットに合わせて適切なresopnse用のmethodを呼び出す.
POSTデータの場合
  1. 送られてくるbodyのデータをviews/以下で定義するReceiveDataClassにmappingする(gsonの機能などを使う)
  2. bodyデータから必要なデータを取り出し,modelによって操作する.
  3. modelによって操作した後,必要なデータを引き抜く,加工する.
  4. views/以下に定義するresponseのフォーマットに合わせて適切なresonse用のmethodを呼び出す.

といった形で進めます.

一例をあげましょう.例えば/users/createのエンドポイントの実装をすることを考えます.

fun Route.userController() {

    // models/以下に作る.後術.
    val model = UserModel()
    // views/以下に作る.Userに関するResponseを司る.
    val response = UserResponse()

    // これが1actionに相当する. これはpostの操作.
    post<Users.Create> {
        // 1.のリクエストをmappingする部分です.
        val result = try {
            // UserReceiveというdata classをviewsに定義しておくとリクエストをmappingしてくれる.
            call.receive<UserReceive>()
        } catch (e: com.google.gson.JsonParseException) {
            // 今回の例ではリクエストはjsonであることを仮定しているので,例外吐いたらparseErrorとして処理する. responseというインスタンスはviews/に定義されている.
            response.showParseError(this.context)
            null
        }


       result?.apply {
           //  2. bodyデータから必要なデータを取り出し,modelによって操作する.3.加工する(今回は無し).
           val createdUser = model.create(result.name, result.age)

           // 4.responseを返す.
           createdUser?.apply { response.showUserCreated(this@post.context, this.name, this.age) } ?: response.showCreateError(this@post.context)
       }

    }
}

これがcontrollerの行うことです.これはMVC全般に言えることですが,controllerはあくまでもつなぎであるため,どういったjsonフォーマットを返却するか?とか,どういったクエリが走ってDBに保存されるか?といったことは考えません.

さて,次はcontrollerから呼び出していたmodelについて触れます.

models

modelの主な役割はDBにデータを書き込んだり,呼び出したりまあそんな感じです(雑)

daoを定義する

まずはdaoを定義します.daos/以下に記述します.

object Users : IntIdTable("users") {
    val name = varchar("name", 64)
    val age = integer("age")
}

IntIdTableはid付きのtableを作ってくれるのでユースケースと合致するなら楽です.

modelを作成する.

models/以下に ファイルを作成します.このファイルでは,

  • daoを実際に呼び出す
  • insert,update,deleteといった操作を書く.

の2つを行うことが目的です.

前者は以下のようなコードで表現されます.これはExposedのリポジトリにサンプルとして書いてあるので,すんなり受け入れられると思います.

class User(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<User>(Users)

    var name by Users.name
    var age by Users.age
}

後者はModelクラスです.しこしこと必要になりそうな操作を書いていきます.

class CollegeModel {
     // 全部引っ張ってくる
     fun findAll() = transaction {
        val query = Users
            .selectAll()
            .withDistinct()
        User.wrapRows(query).toList()
    }

    // createする.
    fun create(name: String, age: Int) = transaction {
        College.new {
            this.name = name
            this.age = age
        }
    }
}

特にval queryでqueryを組み立てていくのはいろんな操作をやる上で基本となる形なのでこれは覚えておいたほうがいいです.transactionは適宜考えながら張ってください.

views

最後にviewsです.これはrespons全般を担います.今回はAPIサーバなので,jsonの形式に加工するだけです.ほぼ.
ファイルはviews以下に作ります.

用意するもの

  • response用にmappingされるdata class.gsonのお陰でclassと実際のレスポンスがマッピングされます.
  • responseのmethod.これをcontrollerから呼び出す.

コード

data class BaseResponse(
    val data: Any?,
    val error: String?
)

data class UserDetailResponse(
    val id: Int,
    val name: String,
    val age: Int
)

class UserResponse() {
    //例えばcontrollerで出てきたparseErrorは以下のような感じでかける.
    suspend fun showParseError(call: ApplicationCall) {
        call.respond(HttpStatusCode.MethodNotAllowed, BaseResponse(null, "parse error"))
    }

    //userがcreateされたというメソッドもこんな感じでかける.
    suspend fun showUserCreated(call: ApplicationCall, id: Int, name: String, age: Int) {
        call.respond(HttpStatusCode.Created, BaseResponse(UserDetailResponse(id, name, age), null))
    }
}

結果

これで,MVCそれぞれが繋がりました!

実はやりたかったこと

  • KoinなどのDIツールの導入(KoinはKtorをサポートしています.)
  • mockkなどのmockツールを導入したまともなテスト(今回は触れられるほど知見がないのでお預けです)

まとめ

このようにして, railsっぽいMVCの構築ができます.実際にこの構成を取り入れてバックエンドのAPIを書いた実績として,本年度の高専プロコン競技部門のバックエンドが例としてあります.ソースコードを公開できないのはいろいろと問題があるのでごめんなさい.

なにより,少し頭をひねればMVCっぽくKtor+Exposedでも記述できることがわかりました.これがこれからKtorでバックエンドAPIを開発する人の助けに少しでもなれば幸いです.

っておもったけどやっぱりCleanArchitectureみたいにやったほうがきれいだと思います.変更に強いし.あとはもっと層を分けるとハッピーになれそう