Ktor サーバーパターン は何をしますか?
Kotlin コルーチンを使用して堅牢で保守性の高い HTTP サーバーを構築するための包括的な Ktor パターン。
アクティブ化のタイミング
- Ktor HTTP サーバーの構築
- Ktor プラグインの設定(Auth、CORS、ContentNegotiation、StatusPages)
- Ktor を使用した REST API の実装
- Koin を使用した依存性注入の設定
- testApplication を使用した Ktor 統合テストの作成
- Ktor での WebSocket の使用
アプリケーション構造
標準的な Ktor プロジェクトレイアウト
src/main/kotlin/
├── com/example/
│ ├── Application.kt # エントリポイント、モジュール設定
│ ├── plugins/
│ │ ├── Routing.kt # ルート定義
│ │ ├── Serialization.kt # コンテントネゴシエーションの設定
│ │ ├── Authentication.kt # 認証設定
│ │ ├── StatusPages.kt # エラーハンドリング
│ │ └── CORS.kt # CORS 設定
│ ├── routes/
│ │ ├── UserRoutes.kt # /users エンドポイント
│ │ ├── AuthRoutes.kt # /auth エンドポイント
│ │ └── HealthRoutes.kt # /health エンドポイント
│ ├── models/
│ │ ├── User.kt # ドメインモデル
│ │ └── ApiResponse.kt # 応答エンベロープ
│ ├── services/
│ │ ├── UserService.kt # ビジネスロジック
│ │ └── AuthService.kt # 認証ロジック
│ ├── repositories/
│ │ ├── UserRepository.kt # データアクセスインターフェース
│ │ └── ExposedUserRepository.kt
│ └── di/
│ └── AppModule.kt # Koin モジュール
src/test/kotlin/
├── com/example/
│ ├── routes/
│ │ └── UserRoutesTest.kt
│ └── services/
│ └── UserServiceTest.kt
アプリケーションエントリポイント
// Application.kt
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
configureSerialization()
configureAuthentication()
configureStatusPages()
configureCORS()
configureDI()
configureRouting()
}
ルーティング DSL
基本的なルート
// plugins/Routing.kt
fun Application.configureRouting() {
routing {
userRoutes()
authRoutes()
healthRoutes()
}
}
// routes/UserRoutes.kt
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing id")
val user = userService.getById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user)
}
put("/{id}") {
val id = call.parameters["id"]
?: return@put call.respond(HttpStatusCode.BadRequest, "Missing id")
val request = call.receive<UpdateUserRequest>()
val user = userService.update(id, request)
?: return@put call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
delete("/{id}") {
val id = call.parameters["id"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing id")
val deleted = userService.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound)
}
}
}
認証付きルートの整理
fun Route.userRoutes() {
route("/users") {
// 公開ルート
get { /* ユーザー一覧 */ }
get("/{id}") { /* ユーザー取得 */ }
// 保護されたルート
authenticate("jwt") {
post { /* ユーザー作成 - 認証必要 */ }
put("/{id}") { /* ユーザー更新 - 認証必要 */ }
delete("/{id}") { /* ユーザー削除 - 認証必要 */ }
}
}
}
コンテントネゴシエーションとシリアライゼーション
kotlinx.serialization のセットアップ
// plugins/Serialization.kt
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = false
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
})
}
}
シリアライズ可能なモデル
@Serializable
data class UserResponse(
val id: String,
val name: String,
val email: String,
val role: Role,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
)
@Serializable
data class CreateUserRequest(
val name: String,
val email: String,
val role: Role = Role.USER,
)
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: String? = null,
) {
companion object {
fun <T> ok(data: T): ApiResponse<T> = ApiResponse(success = true, data = data)
fun <T> error(message: String): ApiResponse<T> = ApiResponse(success = false, error = message)
}
}
@Serializable
data class PaginatedResponse<T>(
val data: List<T>,
val total: Long,
val page: Int,
val limit: Int,
)
カスタムシリアライザー
object InstantSerializer : KSerializer<Instant> {
override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) =
encoder.encodeString(value.toString())
override fun deserialize(decoder: Decoder): Instant =
Instant.parse(decoder.decodeString())
}
認証
JWT 認証
// plugins/Authentication.kt
fun Application.configureAuthentication() {
val jwtSecret = environment.config.property("jwt.secret").getString()
val jwtIssuer = environment.config.property("jwt.issuer").getString()
val jwtAudience = environment.config.property("jwt.audience").getString()
val jwtRealm = environment.config.property("jwt.realm").getString()
install(Authentication) {
jwt("jwt") {
realm = jwtRealm
verifier(
JWT.require(Algorithm.HMAC256(jwtSecret))
.withAudience(jwtAudience)
.withIssuer(jwtIssuer)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(jwtAudience)) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, ApiResponse.error<Unit>("Invalid or expired token"))
}
}
}
}
// JWT からユーザーを抽出
fun ApplicationCall.userId(): String =
principal<JWTPrincipal>()
?.payload
?.getClaim("userId")
?.asString()
?: throw AuthenticationException("No userId in token")
認証ルート
fun Route.authRoutes() {
val authService by inject<AuthService>()
route("/auth") {
post("/login") {
val request = call.receive<LoginRequest>()
val token = authService.login(request.email, request.password)
?: return@post call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("Invalid credentials"),
)
call.respond(ApiResponse.ok(TokenResponse(token)))
}
post("/register") {
val request = call.receive<RegisterRequest>()
val user = authService.register(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
authenticate("jwt") {
get("/me") {
val userId = call.userId()
val user = authService.getProfile(userId)
call.respond(ApiResponse.ok(user))
}
}
}
}
ステータスページ(エラーハンドリング)
// plugins/StatusPages.kt
fun Application.configureStatusPages() {
install(StatusPages) {
exception<ContentTransformationException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("Invalid request body: ${cause.message}"),
)
}
exception<IllegalArgumentException> { call, cause ->
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>(cause.message ?: "Bad request"),
)
}
exception<AuthenticationException> { call, _ ->
call.respond(
HttpStatusCode.Unauthorized,
ApiResponse.error<Unit>("Authentication required"),
)
}
exception<AuthorizationException> { call, _ ->
call.respond(
HttpStatusCode.Forbidden,
ApiResponse.error<Unit>("Access denied"),
)
}
exception<NotFoundException> { call, cause ->
call.respond(
HttpStatusCode.NotFound,
ApiResponse.error<Unit>(cause.message ?: "Resource not found"),
)
}
exception<Throwable> { call, cause ->
call.application.log.error("Unhandled exception", cause)
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse.error<Unit>("Internal server error"),
)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(status, ApiResponse.error<Unit>("Route not found"))
}
}
}
CORS 設定
// plugins/CORS.kt
fun Application.configureCORS() {
install(CORS) {
allowHost("localhost:3000")
allowHost("example.com", schemes = listOf("https"))
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowCredentials = true
maxAgeInSeconds = 3600
}
}
Koin 依存性注入
モジュール定義
// di/AppModule.kt
val appModule = module {
// データベース
single<Database> { DatabaseFactory.create(get()) }
// リポジトリ
single<UserRepository> { ExposedUserRepository(get()) }
single<OrderRepository> { ExposedOrderRepository(get()) }
// サービス
single { UserService(get()) }
single { OrderService(get(), get()) }
single { AuthService(get(), get()) }
}
// アプリケーションセットアップ
fun Application.configureDI() {
install(Koin) {
modules(appModule)
}
}
ルートでの Koin の使用
fun Route.userRoutes() {
val userService by inject<UserService>()
route("/users") {
get {
val users = userService.getAll()
call.respond(ApiResponse.ok(users))
}
}
}
テストでの Koin
class UserServiceTest : FunSpec(), KoinTest {
override fun extensions() = listOf(KoinExtension(testModule))
private val testModule = module {
single<UserRepository> { mockk() }
single { UserService(get()) }
}
private val repository by inject<UserRepository>()
private val service by inject<UserService>()
init {
test("getUser はユーザーを返す") {
coEvery { repository.findById("1") } returns testUser
service.getById("1") shouldBe testUser
}
}
}
リクエスト検証
// ルートでリクエストデータを検証
fun Route.userRoutes() {
val userService by inject<UserService>()
post("/users") {
val request = call.receive<CreateUserRequest>()
// 検証
require(request.name.isNotBlank()) { "名前は必須です" }
require(request.name.length <= 100) { "名前は100文字以内である必要があります" }
require(request.email.matches(Regex(".+@.+\\..+"))) { "メールアドレスの形式が無効です" }
val user = userService.create(request)
call.respond(HttpStatusCode.Created, ApiResponse.ok(user))
}
}
// または検証拡張関数を使用
fun CreateUserRequest.validate() {
require(name.isNotBlank()) { "名前は必須です" }
require(name.length <= 100) { "名前は100文字以内である必要があります" }
require(email.matches(Regex(".+@.+\\..+"))) { "メールアドレスの形式が無効です" }
}
WebSocket
fun Application.configureWebSockets() {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = 64 * 1024 // 64 KiB — プロトコルでより大きなフレームが必要な場合のみ増やす
masking = false // サーバーからクライアントへのフレームは RFC 6455 に従いマスクされません。クライアントからサーバーへのフレームは常に Ktor によってマスクされます
}
}
fun Route.chatRoutes() {
val connections = Collections.synchronizedSet<Connection>(LinkedHashSet())
webSocket("/chat") {
val thisConnection = Connection(this)
connections += thisConnection
try {
send("接続しました!オンラインユーザー数: ${connections.size}")
for (frame in incoming) {
frame as? Frame.Text ?: continue
val text = frame.readText()
val message = ChatMessage(thisConnection.name, text)
// ロック下でスナップショットを取得して ConcurrentModificationException を回避
val snapshot = synchronized(connections) { connections.toList() }
snapshot.forEach { conn ->
conn.session.send(Json.encodeToString(message))
}
}
} catch (e: Exception) {
logger.error("WebSocket エラー", e)
} finally {
connections -= thisConnection
}
}
}
data class Connection(val session: DefaultWebSocketSession) {
val name: String = "User-${counter.getAndIncrement()}"
companion object {
private val counter = AtomicInteger()
}
}
@Serializable
data class ChatMessage(
val user: String,
val text: String,
val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
)
テスト
testApplication を使用したテスト
// build.gradle.kts の依存関係
testImplementation("io.ktor:ktor-server-test-host:3.0.3")
testImplementation("io.ktor:ktor-client-content-negotiation:3.0.3")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("io.mockk:mockk:1.13.13")
// routes/UserRoutesTest.kt
class UserRoutesTest {
@Test
fun `GET /users should return list of users`() = testApplication {
application {
module()
}
val response = client.get("/users")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("""[{"id":"1","name":"John"}]""", response.bodyAsText())
}
@Test
fun `POST /users should create user`() = testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(name = "Alice", email = "[email protected]"))
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
認証を必要とするエンドポイントのテスト
class AuthenticatedRoutesTest {
@Test
fun `POST /users without token should return 401`() = testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(name = "Bob", email = "[email protected]"))
}
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `POST /users with valid token should succeed`() = testApplication {
application {
module()
}
val token = generateTestToken()
val response = client.post("/users") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(CreateUserRequest(name = "Bob", email = "[email protected]"))
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
モックを使用したサービステスト
class UserServiceTest : FunSpec(), KoinTest {
override fun extensions() = listOf(KoinExtension(testModule))
private val testModule = module {
single<UserRepository> { mockk() }
single { UserService(get()) }
}
private val repository by inject<UserRepository>()
private val service by inject<UserService>()
init {
test("getAll はすべてのユーザーを返す") {
coEvery { repository.findAll() } returns listOf(testUser)
service.getAll() shouldBe listOf(testUser)
}
test("getById は見つかった場合はユーザーを返す") {
coEvery { repository.findById("1") } returns testUser
service.getById("1") shouldBe testUser
}
test("getById は見つからない場合は null を返す") {
coEvery { repository.findById("999") } returns null
service.getById("999") shouldBe null
}
test("create はユーザーを作成して返す") {
val request = CreateUserRequest(name = "Charlie", email = "[email protected]")
coEvery { repository.save(any()) } returns createdUser
service.create(request) shouldBe createdUser
}
}
companion object {
private val testUser = User("1", "Test", "[email protected]", Role.USER, Clock.System.now())
private val createdUser = User("2", "Charlie", "[email protected]", Role.USER, Clock.System.now())
}
}
WebSocket テスト
class WebSocketTest {
@Test
fun `WebSocket chat should broadcast messages`() = testApplication {
application {
module()
}
val client1 = createWebSocketSession("/chat")
val client2 = createWebSocketSession("/chat")
client1.send("Hello!")
val message = client2.incoming.receive() as Frame.Text
assertTrue(message.readText().contains("Hello"))
}
}
設定管理
// application.conf (HOCON 形式)
ktor {
deployment {
port = 8080
}
application {
modules = [ com.example.ApplicationKt.module ]
}
}
jwt {
secret = "your-secret-key"
issuer = "your-app"
audience = "your-audience"
realm = "your-realm"
expiration = 3600000 // 1時間(ミリ秒)
}
database {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/mydb"
user = "dbuser"
password = "dbpass"
}
環境変数
// AppEnvironment.kt
object AppEnvironment {
val jwtSecret: String by lazy {
System.getenv("JWT_SECRET") ?: error("JWT_SECRET 環境変数が設定されていません")
}
val jwtIssuer: String by lazy {
System.getenv("JWT_ISSUER") ?: "https://api.example.com"
}
val databaseUrl: String by lazy {
System.getenv("DATABASE_URL") ?: error("DATABASE_URL 環境変数が設定されていません")
}
val isDevelopment: Boolean by lazy {
System.getenv("APP_ENV") != "production"
}
}
環境変数を使用した設定
fun Application.configureAuthentication() {
install(Authentication) {
jwt("jwt") {
realm = AppEnvironment.jwtRealm
verifier(
JWT.require(Algorithm.HMAC256(AppEnvironment.jwtSecret))
.withAudience(AppEnvironment.jwtAudience)
.withIssuer(AppEnvironment.jwtIssuer)
.build()
)
validate { credential ->
if (credential.payload.audience.contains(AppEnvironment.jwtAudience)) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized)
}
}
}
}
ロギング
// アプリケーションログ用の SLF4J ロガー
private val logger = LoggerFactory.getLogger("Application")
fun Application.configureMonitoring() {
install(CallLogging) {
level = Level.INFO
filter { call -> call.request.path().startsWith("/api") }
// デフォルトは Logback が使用され、logback.xml で設定されます
}
}
スレッドモデル:io.ktor.network.selector とコルーチン
// デフォルトでは、Ktor は各リクエストを個別のコルーチンで処理します
// CPU バウンドなタスクには以下を使用します
fun Application.configureCustomDispatcher() {
val myDispatcher = Dispatchers.IO.limitedParallelism(16)
intercept(ApplicationCallPipeline.Plugins) {
withContext(myDispatcher) {
blockingDatabaseCall()
}
}
}
ベストプラクティス
1. 関心事の分離
- ルートは薄く保ち、ビジネスロジックをサービスクラスに委譲する
- プラグインを個別のファイルに分離する
- データアクセスにリポジトリパターンを使用する
2. 堅牢なエラーハンドリング
- すべてのパブリックエンドポイントに構造化されたエラーハンドリングを実装する
- 一貫したエラー応答形式を返す
- 機密情報を含む内部エラーを漏洩させない
3. API設計
- 一貫した命名規則を使用する
- RESTful なエンドポイント命名規則に従う
- API の変更を管理するためにバージョニングを実装する
4. パフォーマンス
- ブロッキング操作に適切なディスパッチャーを使用する
- 大きなペイロードにはストリーミングを検討する
- 高トラフィックのエンドポイントには接続プールとキャッシュを活用する
5. テスト
- ルートテストとサービステストを対象に書く
- 外部依存関係にはモックを使用する
- 非同期待機を適切に処理するためにコルーチンテストを使用する