KtorでCRUDアプリを作成する ~STEP4 : Clean Architecture (Kodeinを使用)

この記事はKtorでCRUDアプリを作成することを目標としています.前回のSTEP3

oboe-note.hatenablog.com

では主にRequeryを使ってDBを操作する方法について紹介しました.

今回の目標

今回はClean Achitectureの考え方に従って依存性の方向をより下位の概念(DBなどの技術的詳細)が上位の概念(解決すべき対象に関する知識やロジック)に依存するように依存関係を制御しようと思います.(依存とは? 例.あるAがBを知っている = AはBに依存する といいます) これを実現するために今回はKotlin性のDIツールであるKodeinを使用します. まずClean ArchitectureやDIPについて説明します.そして実際にKodeinを使って依存性を注入していきます.なのでKodeinの使い方を早く知りたい!って方はいきなりKodeinを使ったDI (Dependency Injection)へ飛んで頂きたいです.

Clean Architecture

まずはClean Architectureについて軽くですが説明をさせて頂きます.詳しい説明はクリーンアーキテクチャ(The Clean Architecture翻訳)Clean Architectureなどを見て頂くのがいいと思います!. クリーンアーキテクチャはRobert C. Martin(Uncle Bob)が提唱したDBやフレークワークからの独立性を確保するためのアーキテクチャです。クリーンアーキテクチャの概念図としてよく登場するのが以下の図です. f:id:harada-777:20200504103650p:plain:h300

  • Enterprise Business Rules
    • このアプリケーションで解決したい対象におけるビジネスルールが集まる層です.これが上位でありこのアプリケーションにとって最も重要な概念です.
  • Application Business Rules
    • このアプリケーションの使い方に関するルールが集まる層です.
  • Interface Adapters
    • より上位の層と下位の層を繋ぐ層です.ビジネスルールにとって使いやすいデータ構造とDatabaseが使いやすいデータ構造の変換などを担います.
  • Frameworks & Drivers
    • ウェブフレームワークやORマッパーなどDBの接続を担うツールが集まる層である.これはこのアプリケーションにとって一番下位な概念であり技術的な詳細です.

この図はClean Architectureが必ずこの4層でないといけないということを表してはいません.重要なの赤で囲った部分です.このように依存の向きがより上位概念に向かっているということが重要なのです!(僕はそう理解しています..).ビジネスルールの層の実装が接続するDatabaseの仕様に合わせた実装をしてはいけないという指針です. こうすることで技術的な部分を隠蔽し,いつでも実装を差し替えることが拡張性の高い設計となります. このように依存の向きをコントロールするのに必要な方法が依存性逆転の原則(Dependency Inversion Principle; DIP)です.次の節で簡単にですが紹介します.

DIPの為の準備 (Interfaceを作成)

この節ではまずDIPについて説明し,それを実現する為にInterfaceを以前までに作成したアプリケーションに対して定義します.

DIP

DIPとは素直にプログラムを実装してしまうと,DBやライブラリに合わせて自分のプログラムを設計してしまうところを,何とかして逆にDBやライブラリの方の実装を自分のプログラムに合わせるように実装できるようにしようという方針です.

f:id:harada-777:20200504103659p:plain:h200

このときに使う手段としてInterfaceが上がられます.自分達が要求するInterfaceを用意してそれに合わせて,DBやライブラリに関する実装を記述するようにします.そうすることで矢印の向きを逆転させることができます!.こうしてより下位の概念がより上位の概念に依存するというClean Architectureの原則を守ことができます.

Interfaceを定義する

ここからは実際にプログラムを修正していきます.以前はHandlerが直接Daoを呼んでいましたが今回はUsecaseを通してデータを取得することにします.Usecaseが求めるInterfaceであるPortをDaoに依存するRepositoryが実装することで依存の向きを逆転させます.

f:id:harada-777:20200504103645p:plain:h250

以下が以前に作成したDaoです.ORマッパーにRequeryを使用しています.

//StudentDao.kt
import io.requery.Persistable
import io.requery.kotlin.EntityStore
import io.requery.kotlin.eq

class StudentDao {

    fun findAll(dataStore: EntityStore<Persistable, Any>): List<Student> =
        dataStore.select(Student::class).get().toList()

    fun create(student: Student, dataStore: Store) {
        dataStore.insert(student)
    }

    fun update(student: Student, dataStore: Store) {
        dataStore.update(student)
    }

    fun delete(id: Int, dataStore: Store) {
        dataStore.delete(Student::class).where(Student::id eq id).get().value()
    }
}

まずPortを定義します.

//StudentPort.kt
interface StudentPort {
    fun findAll(): List<Student>
    fun create(student: Student)
    fun update(student: Student)
    fun delete(id: Int)
}

それを実装するRepositoryを作成します. ここでトランザクション境界を貼っています.

// StudentRepositoy.kt
import io.requery.Persistable
import io.requery.kotlin.EntityStore
import io.requery.sql.KotlinEntityDataStore
typealias Store = EntityStore<Persistable, Any>

class StudentRepository(private val dataStore: KotlinEntityDataStore<Persistable>, private val dao: StudentDao) :
    StudentPort {
    override fun findAll(): List<Student> = dao.findAll(this.dataStore)

    override fun create(student: Student) {
        dataStore.withTransaction {
            dao.create(student, this)
        }
    }

    override fun update(student: Student) {
        dataStore.withTransaction {
            dao.update(student, this)
        }
    }

    override fun delete(id: Int) {
        dataStore.withTransaction {
             dao.delete(id, this)
        }
    }
}

UsecaseのInterfaceと実装を記述します.

//StudentUsecase.kt
interface StudentUsecase {
    fun getStudents(): List<Student>
    fun createStudent(student: Student)
    fun updateStudent(student: Student)
    fun deleteStudent(id: Int)
}
//StudentUsecaseImpl.kt
class StudentUsecaseImpl(val port: StudentPort) :StudentUsecase{
    override fun getStudents(): List<Student> = port.findAll()

    override fun createStudent(student: Student) = port.create(student)

    override fun updateStudent(student: Student) = port.update(student)

    override fun deleteStudent(id: Int) = port.delete(id)
}

StudentUsecaseImplを見て頂くとDaoの情報の情報が全く含まれていないことがわかります.このようにしInterfaceをうまく使うことで技術的な詳細を切り離すことができます.ここまでくるとHandlerはこのUsecaseを呼べばいいことになります.しかしStudentUsecaseImplはStudentPortの実装 (StudentRepository)をコンストラクタで渡すなど依存性を注入(DI)しなくてはなりません.これをサポートしてくれるのがKodeinです!

Kodeinを使ったDI (Dependency Injection)

KodeinとはKotlin製のDI Frameworkです.SpringのAnnotationを使ったInjectionではなくKotoinコードで依存関係を表します.(僕はこっちの方が好みです..) この節ではKodeinの使い方を主に説明しようと思います.

gradleにdependencyを追加

build.gradledependencyを追加します.

//build.gradle
dependencies {
        implementation 'org.kodein.di:kodein-di-generic-jvm:6.5.0'
}

さらに僕の場合は以下の記述をbuild.gradleに追加しないと動きませんでした..

sourceCompatibility = 1.8
compileKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}
compileTestKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}

Kodeinは古いJavaだと動かない可能性があるので注意してください.Java13.0は動きます.

InjectionとRetrive

Kodeinでは2種類の依存性注入方法をサポートしています.まずはInjectionについて説明します. KodeinのInjectionと呼ばれる方法はコンストラクタインジェクションのことを指します.例を用いて説明します. 計算用のCalc Interfaceを定義します

interface Calc {
    // d1とd2を使って計算
    fun calc(d1: Double, d2: Double): Double
    // default値とdを使って計算
    fun calcDefault(d: Double): Double
}

足し算をするクラスを作成しClacを実装します.

class AddInjection(private val default: Double) : Calc {
    override fun calc(d1: Double, d2: Double): Double {
        return d1 + d2
    }

    override fun calcDefault(d: Double): Double {
        return default + d
    }
}

今回はAddInjectionにdefaultを注入します.これをKodeinを使って定義すると以下のようになります.

val kodeinInjection = Kodein {
    bind<Calc>() with provider { AddInjection(instance()) }
    bind<Double>() with provider { 5.0 }
}

Kodeinにブロックで定義を渡します.こうすることで様々なインスタンスがKodeinの管理下になります. bind<依存注入対象の型> with provider { 依存を注入対象のコンストラクタ(値) } とすることで依存を注入することができます.さらにコンストラクタ引数に渡しているinstanse()はKodeinの管理下にあるインスタンスからコンストラクタ引数の型にあるインスタンスを自動で渡してくれます! 今回2行目のでDouble型に5.0をbindすると定義したのでdefaultには5.0が渡ります. 依存性が注入された実態を取り出したいときはKotlinの委譲を使います.kodein.instanse<型>()で指定した方の実体を取り出すことができます.

fun main() {
    val add by kodeinInjection.instance<Calc>()
    println("result ${add.calc(1.0, 2.0)}")
    println("result ${add.calcDefault(1.0)}")
}

kodein.instanse<型>()で指定した方の実体を取り出すことができます.実行すると以下の結果がとなり5.0が注入されていることがわかります.

result 3.0
result 6.0

次にRetriveと呼ばれる方法について説明します.こちらはKotlinに委譲プロパティを用いる方法です.この方法はクラスにはkodeinインスタンスを渡し,プロパティをKodeinを使って初期化します.

class AddRetrieve(private val kodein: Kodein) : Calc {
    private val default by kodein.instance<Double>() //ここが違う!
    override fun calc(d1: Double, d2: Double): Double {
        return d1 + d2
    }

    override fun calcDefault(d: Double): Double {
        return default + d
    }
}

Kodeinの依存の定義の仕方は変わりません.

val kodeinRetrieve = Kodein {
    bind<Calc>() with provider { AddRetrieve(kodein) }
    bind<Double>() with provider { 10.0 }
}
fun main() {
    val add: Calc by kodeinRetrieve.instance<Calc>()
    print("result ${add.calc(1.0, 2.0)}")
    print("result ${add.calcDefault(1.0)}")
}

実行すると2つ目の出力が11.0となり10.0でdefaultが初期化されていることがわかります.

result 3.0
result 11.0
比較

2つの方法を比較するとRetriveは依存を注入したい対象がKodeinに依存してしまう欠点があります..公式サイトによるとそのかわりに便利な機能が提供されるそうですが,僕は対象がKodeinに依存するのが嫌なので今回はInjectionを使って説明をしていきます.

Tag

Kodeinが型で自動的にコンストラクタに値を渡してくれることをお話しました.では型は同じであるが別々の値を渡したいときはどうすればいいでしょうか? Kodeinはそれを可能とするTagという機能を用意しています. 先ほどのClac Interfaceを用いて説明します.片方の実体にはdefault=5.0を注入し,もう片方の実体にはdefault=1.0を注入します.

val kodeinInjection = Kodein {
    bind<Calc>(tag="add1") with provider { AddInjection(instance(tag="add1")) }
    bind<Double>(tag="add1") with provider { 5.0 }
    bind<Calc>() with provider { AddInjection(instance(tag="add2")) }
    bind<Calc>(tag="add2") with provider { AddInjection(instance(tag="add2")) }
    bind<Double>(tag="add2") with provider { 1.0 }
}

どれを注入するかをtagを使って定義しています.注入すべき依存性(Double型)はbindの引数でtagを指定し,注入の対象(AddInjection)にはinstanseの引数でtagを指定する.そして注入された実体にもtagを指定して,使用したい箇所で実態をtagをつけて取り出すこともできます.

fun main() {
    val add1 by kodeinInjection.instance<Calc>(tag = "add1")
    val add2 by kodeinInjection.instance<Calc>(tag = "add2")
    println("result ${add1.calcDefault(1.0)}")
    println("result ${add2.calcDefault(1.0)}")
}

以上が基本的なKodeinの説明です.

KodeinのCRUDアプリへの適用

ここから実際にCRUDアプリに対してKodeinを使って依存性を注入していきます.

Module

ここで今回依存関係を定義するときにKodeinのModule機能を使って責務ごとにKodeinModuleを作成し,それらを全てimportしたkodeinを作成する形をとります.今回のアプリケーションの大きさではここまでする必要はないですが,大きなアプリケーションを作成する際に依存関係をわかりやすく記述することができます.

import com.mysql.cj.jdbc.MysqlDataSource
import io.requery.Persistable
import io.requery.sql.KotlinConfiguration
import io.requery.sql.KotlinEntityDataStore
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.singleton

object Injector {
    val usecaseModule = Kodein.Module("usecase") {
        bind<StudentUsecase>() with singleton { StudentUsecaseImpl(instance()) }
    }

    val portModule = Kodein.Module("port") {
        bind<StudentPort>() with singleton {StudentRepository(instance(), instance())}
    }

    val daoModule = Kodein.Module("dao") {
        bind<StudentDao>() with singleton { StudentDao() }
    }
    // RequeryのDatabase接続定義 (以前はAppkication.ktに記述)
    val dataSourceModule = Kodein.Module("dataSoure") {
        bind<KotlinEntityDataStore<Persistable>>() with singleton {
            val dataSource = MysqlDataSource().apply {
                serverName = "127.0.0.1"
                port = 3306
                user = "root"
                password = "mysql"
                databaseName = "school"
            }
            KotlinEntityDataStore<Persistable>(KotlinConfiguration(dataSource = dataSource, model = Models.DEFAULT))
        }
    }
    // 全moduleをimportしたkodein
    public val kodein = Kodein {
        importAll(usecaseModule, portModule, daoModule, dataSourceModule)
    }
}

最後の方のkodeinには全てのmoduleのインスタンスが管理下にあるので,ここを通して実態を取り出すことで,依存性が全て注入された実態を取り出すことができます.

Singleton

また今回bindする際に providerではなくsingletonを採用しました.こうすることでsingletonオブジェクトとして実態を生成しプログラム全体でこのインスタンスを共有することができます.

Handlerの修正

これを使ってHandler部分を書き直したのが以下です.

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.gson.gson
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.*
import org.kodein.di.generic.instance

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

data class JsonResponse(val message: String)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    // kodeinを通してusecaseを取り出す.
    val usecase by Injector.kodein.instance<StudentUsecase>()
    install(ContentNegotiation) {
        gson {
            setPrettyPrinting()
        }
    }
    routing {
        get("/students") {
            call.respond(usecase.getStudents())
        }

        post("/students") {
            val inputJson = call.receive<Student>()
            usecase.createStudent(inputJson)
            call.respond(JsonResponse("id ${inputJson.id} is created"))
        }

        put("students/{id}") {
            val id = call.parameters["id"]!!.toInt()
            val name = call.request.queryParameters["name"]!!
            val students = usecase.getStudents()

            if (students.indexOfFirst { it.id == id } < 0) {
                call.respond(HttpStatusCode.NotFound, JsonResponse("id $id is not found"))
                return@put
            }
            usecase.updateStudent(Student(id, name))
            call.respond(JsonResponse("id $id is updated"))
        }

        delete("students/{id}") {
            val id = call.parameters["id"]!!.toInt()
            val students = usecase.getStudents()

            if (students.indexOfFirst { it.id == id } < 0) {
                call.respond(HttpStatusCode.NotFound, JsonResponse("id $id is not found"))
                return@delete
            }

            usecase.deleteStudent(id)
            call.respond(JsonResponse("id $id is deleted"))
        }
    }
}

val usecase by Injector.kodein.instance<StudentUsecase>()ここが実際にusecaseを取り出している部分です.

まとめ

今回はClean Architectureを実現する為にKodeinを使用した実装を解説しました.次回はKodeinでのエラーハンドリングについて紹介しようと思います.