Kotlin Fest 2025の登壇資料です











































以下補足
はんずおんExposed
ExposedはKotlin向けに作られたJetBrains社製のORM。Ktor×Exposedでのバックエンドアプリケーションの導入事例はほとんど見られず、Exposedに関するノウハウがあまり出回っていない印象がある。
事実、プロシージャやらファンクションやらcaseやら複雑なサブクエリやらを使ったSQLをExposedに移植しようとした際に非常に困った。なぜなら、この辺のノウハウは当時のExposed Wikiにはしっかり書かれておらず、ソースコードを解析するしかなかったので。
とまあそんな悲しい歴史を繰り返さないために執筆した本がこちら。
iolite
日付、メールアドレス、IPアドレスなど仕様が必ず決まっていて、かつ世界のいろんな場所で繰り返し使われることになる値オブジェクトを集めたライブラリになっている。入力をparseすることでエラーを吐いたり、エラーという結果だけを受け取って呼び出し元で後処理をコントロールできるようになっている。
parse
try { val email: String = Email("youremail@example.com").parse() }catch (e: IllegalArgumentException) { // Handle the exception if needed }
safeParse
val email = Email("youremail@example.com").safeParse() if(email.isFailure){ // Handle the exception if needed } println(email.getOrNull()) // print "youremail@example.com"
Extending Ktor for Server Side Development | Ido Flax
2025年(今年)のKotlin Conf発表。Pluginの作成などについても言及していて、めちゃくちゃいい発表。
Auto Reload
application.yml
ktor: application: modules: - com.example.ApplicationKt.module + development: true deployment: port: 8080 + watch: + - classes
変更時に再コンパイルされるように設定する
./gradlew -t build -x test -i
検証


Routing.ktを修正
package com.example
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
import org.koin.dsl.module
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureRouting() {
routing {
get("/") {
- call.respondText("Hello Ktor!!")
+ call.respondText("Hello Ktor!! Reloaded!!")
}
}
}


Call ID
Application.kt
package example.koin import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.callid.* import io.ktor.server.plugins.callloging.* fun main(args: Array<String>) { io.ktor.server.tomcat.EngineMain.main(args) } fun generateRandomString(length: Int): String { val charPool = ('a'..'z') + ('A'..'Z') + ('0'..'9') // 小文字、大文字、数字 return (1..length) .map { Random.nextInt(charPool.size) } .map(charPool::get) .joinToString("") } fun Application.module() { settingKoin() configureRouting() install(CallId) { header(HttpHeaders.XRequestId) generate { generateRandomString(10) } verify { callId: String -> callId.isNotEmpty() } } install(CallLogging) { callIdMdc("call-id") } }
logback.xml
<configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %X{call-id} %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="debug"> <appender-ref ref="STDOUT"/> </root> </configuration>
Type-Safe Routing
散らかり気味のRouting.kt
めちゃくちゃ巨大
package example.koin import example.koin.controller.ExposedController import io.ktor.server.application.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.koin.ktor.ext.inject fun Application.configureRouting() { val exposedController by inject<ExposedController>() routing { get("/") { call.respondText("Hello! hands on exposed!!") } get("/inorin"){ exposedController.getInorin(call) } get("/allEmployees"){ exposedController.getAllEmployees(call) } get("/allEmployeesNames"){ exposedController.getAllEmployeesNames(call) } get("/employeeNameOfGeneralOrAccounting"){ exposedController.getEmployeeNameOfGeneralOrAccounting(call) } get("/employeeBySorted"){ exposedController.getEmployeeBySorted(call) } get("/howManyApplyExpenseByEmployee"){ exposedController.getHowManyApplyExpenseByEmployee(call) } get("/howMuchExpenseByEmployee"){ exposedController.getHowMuchExpenseByEmployee(call) } get("/employeeLimitOffset"){ exposedController.getEmployeeLimitOffset(call) } get("/employeeNameAndDepartment"){ exposedController.getEmployeeNameAndDepartment(call) } get("/hasExpenseEmployeeNames"){ exposedController.hasExpenseEmployeeNames(call) } get("/hasExpenseEmployeeNamesWithBetween"){ exposedController.hasExpenseEmployeeNamesWithBetween(call) } get("/allEmployeeTypeAndNames"){ exposedController.getAllEmployeeTypeAndNames(call) } get("/allEmployeeTypeAndNamesDistinct"){ exposedController.getAllEmployeeTypeAndNamesDistinct(call) } get("/hasExpenseEmployeeIdAndNames"){ exposedController.getHasExpenseEmployeeIdAndNames(call) } get("/overExpenseEmployeeIdAndNames"){ exposedController.getOverExpenseEmployeeIdAndNames(call) } get("/existsOverExpenseEmployeeIdAndNames"){ exposedController.getExistsOverExpenseEmployeeIdAndNames(call) } get("/latestEmployeeIdByDepartmentId"){ exposedController.getLatestEmployeeIdByDepartmentId(call) } get("/employeeNamesAndEnrollmentStatus"){ exposedController.getEmployeeNamesAndEnrollmentStatus(call) } get("/concatEmployeeNames"){ exposedController.getConcatEmployeeNames(call) } get("/concatPartnerNames"){ exposedController.getConcatPartnerNames(call) } get("/employeeFirstNameStrByte"){ exposedController.getEmployeeFirstNameStrByte(call) } get("/employeeFirstNameCharLength"){ exposedController.getEmployeeFirstNameCharLength(call) } get("/employeeFirstNameCharLengthOver3"){ exposedController.getEmployeeFirstNameCharLengthOver3(call) } post("/insertUpdateDeleteEmployee"){ exposedController.insertUpdateDeleteEmployee(call) } put("/updateApplyExpenseEmployee"){ exposedController.updateApplyExpenseEmployee(call) } get("/allPartners"){ exposedController.getAllPartners(call) } get("/allPartnersNames"){ exposedController.getAllPartnersNames(call) } get("/partnerNameById"){ exposedController.getPartnerNameById(call) } get("/partnerNameByLikeKeyword"){ exposedController.getAllPartnersNamesByLikeKeyword(call) } get("/partnerBySorted"){ exposedController.getPartnerBySorted(call) } get("/partnerLimitOffset"){ exposedController.getPartnerLimitOffset(call) } get("/partnerNameAndDepartment"){ exposedController.getPartnerNameAndDepartment(call) } get("/partnerNamesAndEnrollmentStatus"){ exposedController.getPartnerNamesAndEnrollmentStatus(call) } post("/insertUpdateDeletePartner"){ exposedController.insertUpdateDeletePartner(call) } } }
リソースクラスを作成
Partner関係はここに全部入れていくPathは少し修正が必要かもしれない
package example.koin.resources import io.ktor.resources.* @Resource("/partners") class Partner { @Resource("all") class all(val parent: Partner) @Resource("names") class names(val parent: Partner){ @Resource("all") class all(val parent: names) @Resource("{id}") class byId(val parent: names, val id: Int) } }
Routing.kt 修正前後の比較
get("/allPartners"){ // before exposedController.getAllPartners(call) } get<Partner.all>{ // after exposedController.getAllPartners(call) } get("/allPartnersNames"){ // before exposedController.getAllPartnersNames(call) } get<Partner.names.all>{ // after exposedController.getAllPartnersNames(call) } get("/partnerNameById"){ // before val id = call.parameters["partnerId"]?.toIntOrNull() ?: -1 exposedController.getPartnerNameById(call, id) } get<Partner.names.byId>{ // after partner -> exposedController.getPartnerNameById(call, partner.id) }
Status Pages
package com.example import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* import io.ktor.server.routing.* fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) fun Application.module() { install(StatusPages) { exception<Throwable> { call, cause -> if(cause is AuthorizationException) { call.respondText(text = "403: $cause" , status = HttpStatusCode.Forbidden) } else { call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError) } } status(HttpStatusCode.NotFound) { call, status -> call.respondText(text = "404: Page Not Found", status = status) } statusFile(HttpStatusCode.Unauthorized, HttpStatusCode.PaymentRequired, filePattern = "error#.html") } routing { get("/") { call.respondText("Hello, world!") } get("/internal-error") { throw Exception("Internal Server Error") } get("/authorization-error") { throw AuthorizationException("Forbidden Error") } get("/authentication-error") { call.respond(HttpStatusCode.Unauthorized) } get("/payment-error") { call.respond(HttpStatusCode.PaymentRequired) } } } class AuthorizationException(override val message: String?) : Throwable()
Request Validation
package com.example import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.requestvalidation.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.serialization.* @Serializable data class Customer(val id: Int, val firstName: String, val lastName: String) fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args) fun Application.module() { install(RequestValidation) { validate<String> { bodyText -> if (!bodyText.startsWith("Hello")) ValidationResult.Invalid("Body text should start with 'Hello'") else ValidationResult.Valid } validate<Customer> { customer -> if (customer.id <= 0) ValidationResult.Invalid("A customer ID should be greater than 0") else ValidationResult.Valid } validate { filter { body -> body is ByteArray } validation { body -> check(body is ByteArray) val intValue = String(body).toInt() if (intValue <= 0) ValidationResult.Invalid("A value should be greater than 0") else ValidationResult.Valid } } } install(StatusPages) { exception<RequestValidationException> { call, cause -> call.respond(HttpStatusCode.BadRequest, cause.reasons.joinToString()) } } install(ContentNegotiation) { json() } routing { post("/text") { val body = call.receive<String>() call.respond(body) } post("/json") { val customer = call.receive<Customer>() call.respond(customer) } post("/array") { val body = call.receive<ByteArray>() call.respond(String(body)) } } }
さらっと触れた、他入れときたいPlugin
竹端さんの資料
Klibs.io
意外と知らない人が多い?DIとかTestとかカテゴリなどを絞ってGitHubでStarが多くついているライブラリを検索できるサービス。JetBrainsが公式提供している。
2025年(今年)のKotlin ConfでKlibs開発秘話が話されていて、これもめちゃくちゃ面白いので興味ある人は是非。