LoginSignup
34

More than 5 years have passed since last update.

Ktor+CleanArchitectureでAPIサーバを構築する

Last updated at Posted at 2018-12-22

この記事はKotlin Advent Calendar 2018の23日目です。

今、とあるスタートアップのお手伝いをさせてもらっています。
そこでAPIサーバを新しく構築するにあたって技術選定から任せてもらえたため、色々検討した末フレームワークとしてKtorを採用することにしました。

2018年12月23日現在、Ktorの最新バージョンは1.0.1になっていますが、開発スタート時はまだ0.9.5でした。
1.0に到達していない状況でなぜKtorを選択したのか、理由はいくつかあるんですが、大きなところで言うとこんな感じです。

  • null安全な世界で生きていくため、できる限り利用するライブラリをKotlin製のもので固めたかった
  • 軽量、かつpluggableで、必要な機能だけ選んで足していけるという思想がよかった
  • 個人的にSpringのアノテーションだらけな感じがあまり好きじゃなかった
  • githubのissueを見た感じ、HttpClient周り以外ではあまり大きなバグは残ってなさそう
  • 開発元がJetBrainsで、開発が継続されそうな安心感があった。
  • まあバージョンも0.9.xだし、きっと近いうちに1.0が出るだろうという楽観的な勘(一応11月に1.0の正式版が出たので、間違ってはなかった)
  • Kotlin界隈では注目度の高いフレームワークのため、人柱覚悟でも知見を蓄積することで、今後の採用などにもプラスの影響があると思った

また、今回開発にあたってクリーンアーキテクチャ(DDD的なモデリングができていなかったり、Presenterを作っていなかったりと、あくまでもどきですが)を採用しているため、そのあたりも含めて実際どういうコードになっているかご紹介しようと思います。

クリーンアーキテクチャに関してはこの記事では解説しないため、詳しい記事をご参照ください。
実装クリーンアーキテクチャ

サンプルコード

サンプルコードに関しては実際に開発しているコードからアーキテクチャだけ模したものをこちらにアップしていますので、興味のある方はぜひご覧ください。

技術スタック

実際に採用しているライブラリはこんな感じです(Exposedとかは現状サンプルコードには入っていませんが、そのうち追加するかもしれません)

  • Ktor
  • Exposed(OR Mapper)
  • HikariCP(Connection Pooling)
  • Koin(DI Container)
  • Jackson(Json Mapper)
  • JUnit 5, MockK, AssertJ(Test Libraries)

パッケージ構成

今回クリーンアーキテクチャを採用するということで、レイヤー間の依存関係に関してはある程度強制力をもった制約を持たせたかったため、レイヤーごとに別のモジュールに分けることにしました。

これによって、誰かが「今作っているレイヤーよりも外側のレイヤーのクラスを参照したい」と思っても、強制的にできなくなっています。

レイヤーの構成を図に示します。
layer.png
内側のレイヤーからは外側のレイヤーは参照できないようになっています。

パッケージ構成としてはこんな感じになっています。

ktor-clean-architecture-sample
├── build.gradle
├── common-lib
│   ├── build.gradle
│   └── src/main/kotlin
│       └── mappter
│           └── ObjectMapperBuilder.kt
├── database
│   ├── build.gradle
│   └── src/main/kotlin
│       └── repository
│           └── UserRepository.kt
├── infrastructure
│   ├── build.gradle
│   ├── src/main/kotlin
│   │   ├── module
│   │   │   └── KoinModuleBuilder.kt
│   │   ├── routes
│   │   │   └── Route.kt
│   │   └── Application.kt
│   └── src/resources
│       └── application.conf
├── interfaces
│   ├── build.gradle
│   └── src/main/kotlin
│       └── controllers
│           └── UserController.kt
└── use-cases
    ├── build.gradle
    └── src/main/kotlin
        ├── dto
        │   └── UserDto.kt
        ├── repository
        │   └── IUserRepository.kt
        └── service
            └── UserService.kt

ソースコード

モジュールごとに主要なファイルをいくつか解説します。(build.gradleの書き方などはgithub見てもらったほうが早いので今回は割愛)

infrastructure

実際にApplicationサーバーを立ち上げたりといった処理を担当しているレイヤーです。(本来、databaseはinfrastructureレイヤーに内包されるものですが、database関連は専用のファイルが結構増えることを見越して別モジュールに分割しています)

Application.ktでKtorサーバーを立ち上げています。
ポイントはDIコンテナの設定をしている場所と、routingの設定をしている場所です。
KoinModuleBuilderでKoinを使ったDIの設定をしており、これによって、Route.ktroot()内でby inject()を呼び出せばインスタンスを取得できるようになっています。

なお、Ktorに依存しているのはこのレイヤーだけで、他のレイヤーは一切Ktorに依存しない形で作っているため、いざフレームワークを別のものに切り替えようと思った場合は、このレイヤーだけ変更すれば実現できるようになっています。(今の所そういった予定はありませんが)

Application.kt
fun main() {
    val server = embeddedServer(Netty, port = 8082) {
        // DIコンテナの設定
        installKoin(KoinModuleBuilder.modules())

        install(ContentNegotiation) {
            jackson {
                ObjectMapperBuilder.build(this)
                configure(SerializationFeature.INDENT_OUTPUT, true)
                configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            }
        }
        install(StatusPages) {
            exception<Throwable> { cause ->
                log.error(cause.message, cause)
                call.respond(HttpStatusCode.InternalServerError)
            }
        }
        install(Locations)
        install(CORS) {
            method(HttpMethod.Options)
            method(HttpMethod.Put)
            method(HttpMethod.Delete)
            anyHost()
        }

        // routingの設定
        routing {
            root()
        }
    }
    server.start(wait = true)
}
KoinModuleBuilder.kt
object KoinModuleBuilder {
    fun modules(): List<Module> = listOf(module {
        // Controllers
        single { UserController(get()) }

        // Services
        single<UserService> { UserServiceImpl(get()) }

        // Repositories
        single<IUserRepository> { UserRepository() }
    })
}
Route.kt
fun Routing.root() {
    val userController: UserController by inject()

    get<UserParam> { param ->
        call.respond(userController.getUser(param.userId))
    }

    route("v1") {
        route("/users") {
            get<UserParam> { param ->
                call.respond(userController.getUser(param.userId))
            }
        }
    }
}

@Location("/{userId}")
data class UserParam(val userId: Long)

ちなみに、Routingの設定ではLocationという機能を使っており、その解説もしようと思いましたが、アドベントカレンダー内の別の記事と完全にかぶったのでやめておきます。
興味ある方はそちらをご参照ください。
Ktor+Exposedで考えるMVCライクなサーバサイドアーキテクチャ
サーバサイドKotlinをはじめよう。Ktor Tips集をまとめた。

ただ、実際に使っていて、Locationにはまだ改善の余地があると思っています。
なにせ、ルートの数が増えてくると、ぱっと見てどのpathがどの処理を呼び出すのか、非常に分かりづらくなってしまうためです。
Locationはまだexperimentalの機能ですので、今後このあたりの改善にも期待したいところ。

interfaces

いわゆる、Controllerを格納しています。Ktorの機能でRoutingをやってしまっているため今はServiceを呼び出しているだけですが、実際にはリクエストデータのバリデーションなどもここで行います。
また、今回はPresenterを用意していないので、レスポンスの成形もここで行っています。
なお、interfacesレイヤーはinfrastractureレイヤーよりも内側のレイヤーのため、ktorに関する機能は一切使っていません(Ktorに依存する部分を分離するのに最初結構苦労した)

UserController.kt
class UserController(private val userService: UserService) {
    fun getUser(userId: Long): UserResponse {
        return userService.findById(userId).toResponse()
    }
}

data class UserResponse(var userId: Long, var familyName: String, var givenName: String)

private fun UserDto.toResponse() = UserResponse(id, familyName, givenName)

use-cases

ビジネスロジックを担当している箇所です。ポイントは、DIを使うことによって外側のレイヤーであるdatabase層に依存していないこと。
これがクリーンアーキテクチャの肝の部分で、databaseを別のものに切り替えたり、外部サービスから取得するようにしたりといった変更について、use-cases層は一切関知しないでできることになります。
なお、当然IUserRepositoryはServiceから参照できる必要があるので、そのinterfaceはこのレイヤーに定義してあります。

UserService.kt
interface UserService {
    fun findById(userId: Long): UserDto
}

class UserServiceImpl(
    private val userRepository: IUserRepository
) :
    UserService {
    override fun findById(userId: Long): UserDto {
        return userRepository.findById(userId) ?: throw IllegalStateException("No User Found for Given Id")
    }
}
IUserRepository.kt
interface IUserRepository {
    fun findById(userId: Long): UserDto?
}

database

IUserRepositoryの実装クラスとして、UserRepositoryを定義しています。
今はモックとして固定の値を返しているだけなので中身は割愛。
あと、DBのマイグレーションファイルとかも一緒にここに置いたりします。

common-lib

これは一種の「逃げ」かもしれませんが、どうしても各レイヤーで共通の処理を作りたいことがあります。(Util系の処理とか)
そういったものはこのレイヤーに格納します。

実行してみる

Application.ktmain()メソッドを実行することでサーバーが立ち上がるので、http://localhost:8082/v1/users/1にGETリクエストを送ってみると、無事データが取得できました!

$ curl -PGET http://localhost:8082/v1/users/1
{
  "userId" : 1,
  "familyName" : "Test",
  "givenName" : "Taro"
}

感想

実際にKtorを使ってみての感想ですが、まず必要な機能だけを取り込んでいる分、起動が非常に早くて快適です。
上記のサンプルアプリ、自分の環境だとビルドの時間をのぞけば2秒とかで立ち上がってくれるため、起動時に毎回ついTwitter休憩をはさんじゃって集中力がブッツリ切れる、みたいなことがなくなりました。

またクリーンアーキテクチャ(っぽい)構成にしたことによって依存関係がごちゃごちゃしなくなり、特にuse-casesがビジネスロジックに集中できていて嬉しい。
それと、クリーンアーキテクチャのネックとしてどうしてもクラスの変換処理は多くなってしまうんですが、(邪道かもしれないけれど)Kotlinの拡張関数を使うと変換用のクラスを用意しなくても変換が可能になるため、その分の面倒臭さがだいぶ半減されたように感じます。
そういう意味でも一度サーバーサイドKotlinやってしまうと、なかなかJavaには戻り難い・・・!というくらい快適です。

そんなわけで、気が向いたらTestの書き方とかExposedでのDB接続のやり方とか、そのあたりも書いてみたいと思います。
Let's enjoy Kotlin!!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34