KtorでCRUDアプリを作成する ~STEP5 : エラーハンドリング
この記事はKtorでCRUDアプリを作成することを目標としています.前回のSTEP4
ではKodeinと使って,Clean Architectureに従った設計に修正をしました.
今回の目標
前回まで作成したCRUDアプリはエラーハンドリングについてあまり考えておらずハンドラー部分がデータの存在を確認し404 not foundを返すぐらいのことしか行っていませんでした.今回はKtorの機能を使って,もっとうまくエラーハンドリングを行おうと思います!
エラーハンドリングのイメージ
前回
冒頭でもお話しましたが前回はハンドラーがデータの存在を判定し404 not foundを返すという実装になっていました.
実際のコードはこんな感じでした.
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の機能で例外に応じたレスポンスを返すという実装にしようと思います.
こうすることで以下のメリットが生まれます + ハンドラーがシンプルになる + より内側の層(例えば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.findById
はStudent?
を返します.
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を投げて見ましょう. まずはデータを作成します.
存在しないid=2でリクエストしてみます.
もう一度id=1でリクエストしてみます.
int型ではないidでリクエストしてみます.
想定通りのレスポンスが返ってきました!
まとめ
今回はKtorを使ったアプリケーションでどうやってエラーハンドリングを行うかについて解説しました.とりあえずこのCRUDアプリ作成シリーズはこの辺で終了かなと思っています.