KtorでCRUDアプリを作成する ~STEP5 : エラーハンドリング

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

oboe-note.hatenablog.com

ではKodeinと使って,Clean Architectureに従った設計に修正をしました.

今回の目標

前回まで作成したCRUDアプリはエラーハンドリングについてあまり考えておらずハンドラー部分がデータの存在を確認し404 not foundを返すぐらいのことしか行っていませんでした.今回はKtorの機能を使って,もっとうまくエラーハンドリングを行おうと思います!

エラーハンドリングのイメージ

前回

冒頭でもお話しましたが前回はハンドラーがデータの存在を判定し404 not foundを返すという実装になっていました.

f:id:harada-777:20200505183333p:plain:h100

実際のコードはこんな感じでした.

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"))
}

ここで色々やるとハンドラーが複雑になってしまったり,同じように404を返す部分全てでこういうことをやらなくてはなりません..

今回の実装

そこで今回はそれぞれのクラス(今回はRepository)が例外をスローし,Ktorの機能で例外に応じたレスポンスを返すという実装にしようと思います.

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

こうすることで以下のメリットが生まれます + ハンドラーがシンプルになる + より内側の層(例えばUsecaseなどのロジックのある層)が,nullなどのデータの存在の判定をしなくて済む 2つ目について軽く説明します.例えばRepositoryに対してidを指定してデータを取得したあとにUsecseなどでロジックを適用するときを考えてみます.Repositoryの戻り値が以下のようだったとします.

    fun findById(id: Int): Student?

この場合Usecaseでrepositoryを使用する場合こんな感じ,nullを気にして?を使ったコードになります.

 repository.findById(id)?.let { it ->
        //何らかの処理..
    }

Kotlinは比較的シンプルにnullを扱えますがあまりこういうコードが複雑化する原因になるのでやりたくはありません.Repository自身がデータがないときに例外をスローする実装にするとrepositoryの戻り値としてnullが返らなくなるのでRepositoryを使う側がnullを気にせず済みます!

実装

実際にコードを書いていきます.今回修正がないコードも一応再掲しておきます.

再掲(build.gradle, Dao, Entity, Port, Injector)

// build.gradle
buildscript {
    repositories {
        jcenter()
    }
    
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'kotlin'
apply plugin: 'application'
apply plugin: 'kotlin-kapt'

group 'com.example'
version '0.0.1'
mainClassName = "io.ktor.server.netty.EngineMain"

sourceSets {
    main.kotlin.srcDirs = main.java.srcDirs = ['src']
    test.kotlin.srcDirs = test.java.srcDirs = ['test']
    main.resources.srcDirs = ['resources']
    test.resources.srcDirs = ['testresources']
}

repositories {
    mavenLocal()
    jcenter()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "io.ktor:ktor-server-netty:$ktor_version"
    implementation "ch.qos.logback:logback-classic:$logback_version"
    implementation "io.ktor:ktor-gson:$ktor_version"
    implementation 'org.kodein.di:kodein-di-generic-jvm:6.5.0'
    compile 'io.requery:requery:1.6.1'
    compile 'io.requery:requery-kotlin:1.6.1'
    kapt 'io.requery:requery-processor:1.6.1'
    compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.19'
    testImplementation "io.ktor:ktor-server-tests:$ktor_version"
}

Kodeinを使った依存性注入の定義です.詳しくはこちらをご覧ください.

// Injector.kt
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() }
    }
    // ExposedのDatabase接続定義
    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))
        }
    }

    public val kodein = Kodein {
        importAll(usecaseModule, portModule, daoModule, dataSourceModule)
    }
}

requeryを使ったエンティティの定義です.詳しくはこちらをご覧ください.

// StudentEntity.kt
import io.requery.Entity
import io.requery.Key
import io.requery.Persistable
import io.requery.Table

@Entity
@Table(name = "students")
interface StudentEntity : Persistable {
    @get:Key
    var id: Int
    var name: String
}

requeryを使ったDAOです.詳しくはこちらをご覧ください.前回にはなかったですがIdを指定してStudentを得るfindByIdを追加しています.

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

class StudentDao {

    fun findAll(dataStore: Store): List<Student> =
        dataStore.select(Student::class).get().toList()

    fun findById(id: Int, dataStore: Store): Student? {
        val cond = Student::id eq id
        return dataStore.select(Student::class).where(cond).get().firstOrNull()
    }

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

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

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

findByIdは指定したIdのStudentがいなかったらnullを返します.

Usecaseが要求するInterfaceであるPort.

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

例外をスローするRepository

今回の目的を達成するためのRepositoryに修正を加えます.主に以下の2つの例外を投げるようにしました. + 指定したIdの生徒が存在しないとき -> NotFoundStudentException + 既に存在するIdの生徒をもう一度作ろうとしたとき -> PersistenceStudentException

NotFoundStudentExceptionについて説明します.以下のようなクラスを作成しました.

class NotFoundStudentException(id: Int) : Throwable("student id: $id is not found")

そして指定したidのStudentがいない場合以下のようにNotFoundStudentExceptionをスローします.dao.findByIdStudent?を返します.

override fun findById(id: Int): Student = dao.findById(id, dataStore) ?: throw NotFoundStudentException(id)

次にupdate部分においても指定したidのStudentが存在しないときNotFoundStudentExceptionをスローするようにします.

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

requeryはupdateは指定したidのデータがないとき何もしないだけなのでこちらで一度findByIdを呼ぶことでデータの存在を確認することにしました.

次にdeleteでもNotFoundStudentExceptionをスローさせます.

override fun delete(id: Int) {
    val count = dataStore.withTransaction {
        dao.delete(id, this)
     }
        if (count == 0) throw NotFoundStudentException(id)
    }
}

requeryは削除したカウントを返すのでcountが0のときそのデータは存在しなかったということなので上記のように実装しました.

次にPersistenceStudentExceptionについて説明します.次のようなクラスを用意します.

class PersistenceStudentException(id: Int) : Throwable("student id: $id is already persist")

requeryは既に存在するPRIMARY KEY (今回はid)のデータをもう一度作ろうとするとPersistenceExceptionをスローします.なのでrunCatchingでその例外をResult型でラップしてPersistenceExceptionのときはPersistenceStudentExceptionをスローしなおす実装を行います.

override fun create(student: Student) {
        kotlin.runCatching {
            dataStore.withTransaction {
                dao.create(student, this)
            }
        }.onFailure {
            when (it) {
                // requeryのcreateは既にPRIMARY KEYが存在するものを作成するとPersistenceExceptionを投げる
                is PersistenceException -> throw PersistenceStudentException(student.id)
                else -> {
                    throw it
                }
            }
        }
    }

全体は以下のようになります.

// StudentRepository.kt
typealias Store = EntityStore<Persistable, Any>

class NotFoundStudentException(id: Int) : Throwable("student id: $id is not found")
class PersistenceStudentException(id: Int) : Throwable("student id: $id is already persist")

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

    override fun findById(id: Int): Student = dao.findById(id, dataStore) ?: throw NotFoundStudentException(id)

    override fun create(student: Student) {
        kotlin.runCatching {
            dataStore.withTransaction {
                dao.create(student, this)
            }
        }.onFailure {
            when (it) {
                // requeryのcreateは既にPRIMARY KEYが存在するものを作成するとPersistenceExceptionを投げる
                is PersistenceException -> throw PersistenceStudentException(student.id)
                else -> {
                    throw it
                }
            }
        }
    }

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

    override fun delete(id: Int) {
        val count = dataStore.withTransaction {
            dao.delete(id, this)
        }
        if (count == 0) throw NotFoundStudentException(id)
    }
}

Repositoryを呼ぶUsecase

// StudentUsecase.kt
interface StudentUsecase {
    fun getStudents(): List<Student>
    fun getStudentById(id: Int): Student
    fun createStudent(student: Student)
    fun updateStudent(student: Student)
    fun deleteStudent(id: Int)
}

今回は特にロジックがないのでただPort (Repository)を呼ぶだけとなっていますが実際にロジックを書く際にデータの有無を気にせず書くことができます.

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

Handler (Kodeinによる例外とResponseのマッピング)

Kodeinは様々なフィーチャを追加することができます.今回使うのはStatusPages フィーチャです.以下のように使用することができます.

install(StatusPages) {
    //例外とレスポンスのマッピング        
}

Status Pagesはexception<補足したい例外>{返したいレスポンス} の形で定義をしていきます.上から優先的に例外のマッチングが行われます.NotFoundStudentExceptionが投げられると404 not foundが返るように定義すると以下のようになります.

    install(StatusPages) {
        exception<NotFoundStudentException> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.NotFound, JsonErrorResponse(errorMessage))
        }     
    }
data class JsonErrorResponse(val error: String)

このマッピングは複数登録することができ上から順にマッチングしていきます.PersistenceStudentExceptionの定義とその他の例外にも対応させます.

install(StatusPages) {
    exception<NotFoundStudentException> { cause ->
        val errorMessage: String = cause.message ?: "Unknown error"
        call.respond(HttpStatusCode.NotFound, JsonErrorResponse(errorMessage))
        }
    exception<PersistenceStudentException> { cause ->
        val errorMessage: String = cause.message ?: "Unknown error"
        call.respond(HttpStatusCode.Conflict, JsonErrorResponse(errorMessage))
        }
    exception<Throwable> { cause ->
        val errorMessage: String = cause.message ?: "Unknown error"
        call.respond(HttpStatusCode.InternalServerError, JsonErrorResponse(errorMessage))
        }
    }

次にハンドラーでパスパラメータやクエリパラメータのバリデーションも追加します.その例外を表すクラスが以下のものです.

class ValidationError(override val message: String) : Throwable(message)

まずidに関するバリデーションを行います.KtorのParamatersを受け取ってInt型のidを返す関数を作成します.この関数はidがnullのときとパースできない場合に例外を投げます.

private fun getId(parameters: Parameters): Int = runCatching {
    // toInt()ができないときとidがnullのときに例外がスローされる
    parameters["id"]?.toInt() ?: throw ValidationError("id is't must be null")
}.getOrElse {
    throw ValidationError(it.message ?: "Unkown error")
}

idが欲しい部分でこれを呼ぶとidを取得できます.

delete("students/{id}") {
    val id = getId(call.parameters)
    usecase.deleteStudent(id)
    call.respond(JsonResponse("id $id is deleted"))
}

Putはidをパスパラメータ,nameをqueryパラメータで受け取ってStudentを更新する仕様なのでgetIdを使うとともに名前がnullのとき例外をスローします.

put("students/{id}") {
    val id = getId(call.parameters)
    val name = call.request.queryParameters["name"]?:throw ValidationError("name is't must be null")
    usecase.updateStudent(Student(id, name))
    call.respond(JsonResponse("id $id is updated"))
}

StatusPagesにValidationErrorを追加します.

exception<ValidationError> { cause ->
    val errorMessage: String = cause.message ?: "Unknown error"
}

ハンドラー全体のコードは以下のようになります.

// Application.kt
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.gson.gson
import io.ktor.http.HttpStatusCode
import io.ktor.http.Parameters
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)
data class JsonErrorResponse(val error: String)

class ValidationError(override val message: String) : Throwable(message)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    val usecase by Injector.kodein.instance<StudentUsecase>()
    install(ContentNegotiation) {
        gson {
            setPrettyPrinting()
        }
    }
    install(StatusPages) {
        exception<NotFoundStudentException> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.NotFound, JsonErrorResponse(errorMessage))
        }
        exception<PersistenceStudentException> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.Conflict, JsonErrorResponse(errorMessage))
        }
        exception<ValidationError> { cause ->
            val errorMessage: String = cause.message
            call.respond(HttpStatusCode.BadRequest, JsonErrorResponse(errorMessage))
        }
        exception<Throwable> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.InternalServerError, JsonErrorResponse(errorMessage))
        }
    }
    routing {
        get("/students") {
            call.respond(usecase.getStudents())
        }

        get("/students/{id}") {
            val id = getId(call.parameters)
            call.respond(usecase.getStudentById(id))
        }

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

        put("students/{id}") {
            val id = getId(call.parameters)
            val name = call.request.queryParameters["name"]?:throw ValidationError("name is't must be null")
            usecase.updateStudent(Student(id, name))
            call.respond(JsonResponse("id $id is updated"))
        }

        delete("students/{id}") {
            val id = getId(call.parameters)
            usecase.deleteStudent(id)
            call.respond(JsonResponse("id $id is deleted"))
        }
    }
}

private fun getId(parameters: Parameters): Int = runCatching {
    parameters["id"]?.toInt() ?: throw ValidationError("id is't must be null")
}.getOrElse {
    throw ValidationError(it.message ?: "Unkown error")
}

実際にrequestを投げて見ましょう. まずはデータを作成します.

f:id:harada-777:20200505183323p:plain:h400

存在しないid=2でリクエストしてみます.

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

もう一度id=1でリクエストしてみます.

f:id:harada-777:20200505183317p:plain:h400

int型ではないidでリクエストしてみます.

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

想定通りのレスポンスが返ってきました!

まとめ

今回はKtorを使ったアプリケーションでどうやってエラーハンドリングを行うかについて解説しました.とりあえずこのCRUDアプリ作成シリーズはこの辺で終了かなと思っています.