Communitygithub.com

kotlin-ktor-patterns

Ktor 伺服器模式,包含路由 DSL、插件、認證、Koin DI、kotlinx.serialization、WebSockets 和 testApplication 測試。在建立 Ktor 伺服器時使用——路由、插件、認證、DI、序列化或測試。

kotlin-ktor-patterns 是什麼?

此技能提供一套全面的 Ktor 伺服器開發模式,專為使用 Kotlin 協程建構穩健且易於維護的 HTTP 伺服器而設計。它涵蓋了從應用程式架構、路由 DSL、內容協商與序列化(使用 kotlinx.serialization)、JWT 認證、CORS 配置、錯誤處理(StatusPages)到 Koin 依賴注入的完整流程。此外,還包含 WebSocket 即時通訊支援以及使用 testApplication 的整合測試方法。這些模式遵循業界最佳實務,幫助開發者快速搭建結構清晰、可測試性強的 REST API,並支援多種 Ktor 版本。此技能適用於所有 Ktor 伺服器專案,無論是小型 API 還是大型微服務架構。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/ECC/tree/main/skills/kotlin-ktor-patterns

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Ktor 伺服器模式

全面的 Ktor 模式,用 Kotlin 協程建構穩健且易於維護的 HTTP 伺服器。

何時啟用

  • 建構 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, "缺少 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, "缺少 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, "缺少 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>("無效或過期的權杖"))
            }
        }
    }
}

// 從 JWT 中提取使用者
fun ApplicationCall.userId(): String =
    principal<JWTPrincipal>()
        ?.payload
        ?.getClaim("userId")
        ?.asString()
        ?: throw AuthenticationException("權杖中沒有 userId")

認證路由

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>("認證憑證無效"),
                )
            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>("無效的要求主體:${cause.message}"),
            )
        }

        exception<IllegalArgumentException> { call, cause ->
            call.respond(
                HttpStatusCode.BadRequest,
                ApiResponse.error<Unit>(cause.message ?: "錯誤的要求"),
            )
        }

        exception<AuthenticationException> { call, _ ->
            call.respond(
                HttpStatusCode.Unauthorized,
                ApiResponse.error<Unit>("需要認證"),
            )
        }

        exception<AuthorizationException> { call, _ ->
            call.respond(
                HttpStatusCode.Forbidden,
                ApiResponse.error<Unit>("拒絕存取"),
            )
        }

        exception<NotFoundException> { call, cause ->
            call.respond(
                HttpStatusCode.NotFound,
                ApiResponse.error<Unit>(cause.message ?: "找不到資源"),
            )
        }

        exception<Throwable> { call, cause ->
            call.application.log.error("未處理的例外", cause)
            call.respond(
                HttpStatusCode.InternalServerError,
                ApiResponse.error<Unit>("內部伺服器錯誤"),
            )
        }

        status(HttpStatusCode.NotFound) { call, status ->
            call.respond(status, ApiResponse.error<Unit>("找不到路由"))
        }
    }
}

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

private val counter = java.util.concurrent.atomic.AtomicInteger()

@Serializable
data class ChatMessage(
    val user: String,
    val text: String,
    val timestamp: Long = Clock.System.now().toEpochMilliseconds(),
)

整合測試

testApplication 設定

class UserRoutesTest {
    @Test
    fun `test GET users returns 200`() = testApplication {
        application {
            module()
        }

        client.get("/users").apply {
            assertEquals(HttpStatusCode.OK, status)
        }
    }

    @Test
    fun `test GET user by id returns 404 for unknown`() = testApplication {
        application {
            module()
        }

        client.get("/users/unknown-id").apply {
            assertEquals(HttpStatusCode.NotFound, status)
        }
    }

    @Test
    fun `test POST user creates user`() = testApplication {
        application {
            configureSerialization()
            configureRouting()
        }

        val request = CreateUserRequest(name = "Test", email = "[email protected]")

        client.post("/users") {
            contentType(ContentType.Application.Json)
            setBody(request)
        }.apply {
            assertEquals(HttpStatusCode.Created, status)
        }
    }
}

測試 Koin 模組

class AuthRoutesTest {
    @Test
    fun `test authentication flow`() = testApplication {
        environment {
            config = MapApplicationConfig(
                "jwt.secret" to "test-secret",
                "jwt.issuer" to "test-issuer",
                "jwt.audience" to "test-audience",
                "jwt.realm" to "test-realm",
            )
        }

        application {
            configureSerialization()
            configureAuthentication()
            configureDI()
            configureRouting()
        }

        // 註冊
        val registerRequest = RegisterRequest(
            name = "Test",
            email = "[email protected]",
            password = "password123",
        )

        client.post("/auth/register") {
            contentType(ContentType.Application.Json)
            setBody(registerRequest)
        }.apply {
            assertEquals(HttpStatusCode.Created, status)
        }

        // 登入並取得權杖
        val loginRequest = LoginRequest(
            email = "[email protected]",
            password = "password123",
        )

        val loginResponse = client.post("/auth/login") {
            contentType(ContentType.Application.Json)
            setBody(loginRequest)
        }.body<ApiResponse<TokenResponse>>()

        val token = loginResponse.data!!.token

        // 使用權杖存取受保護端點
        client.get("/auth/me") {
            bearerAuth(token)
        }.apply {
            assertEquals(HttpStatusCode.OK, status)
        }

        // 無權杖時應被拒絕
        client.get("/auth/me").apply {
            assertEquals(HttpStatusCode.Unauthorized, status)
        }
    }
}

配置參考

application.conf

ktor {
    deployment {
        port = 8080
        host = 0.0.0.0
    }
    application {
        modules = [com.example.ApplicationKt.module]
    }
}

jwt {
    secret = "your-256-bit-secret"
    issuer = "http://localhost:8080"
    audience = "http://localhost:3000"
    realm = "example-realm"
}

build.gradle.kts 依賴

dependencies {
    // 伺服器
    implementation("io.ktor:ktor-server-core:2.3.x")
    implementation("io.ktor:ktor-server-netty:2.3.x")
    implementation("io.ktor:ktor-server-content-negotiation:2.3.x")
    implementation("io.ktor:ktor-server-auth:2.3.x")
    implementation("io.ktor:ktor-server-auth-jwt:2.3.x")
    implementation("io.ktor:ktor-server-cors:2.3.x")
    implementation("io.ktor:ktor-server-status-pages:2.3.x")
    implementation("io.ktor:ktor-server-websockets:2.3.x")

    // 序列化
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.x")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.x")

    // DI
    implementation("io.insert-koin:koin-core:3.5.x")
    implementation("io.insert-koin:koin-ktor:3.5.x")

    // 測試
    testImplementation("io.ktor:ktor-server-test-host:2.3.x")
    testImplementation("io.ktor:ktor-client-content-negotiation:2.3.x")
    testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.x")
    testImplementation("io.insert-koin:koin-test:3.5.x")
    testImplementation("io.mockk:mockk:1.13.x")
}

注意事項

  • 始終使用 testApplication 進行整合測試,以避免真實伺服器需求
  • 在 StatusPages 中處理例外狀況,以確保 API 回應保持一致
  • 使用 Koin 模組進行依賴注入,以保持測試的簡潔性
  • 在需要時分離公開和受保護路由,以明確辨識認證需求
  • 設定 kotlinx.serialization 的 ignoreUnknownKeys = true,以在 JSON 回應中具有彈性
  • WebSocket 連接應該使用同步集合進行管理,以避免並發修改問題

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/content-engine

Create platform-native content systems for X, LinkedIn, TikTok, YouTube, newsletters, and repurposed multi-platform campaigns. Use when the user wants social posts, threads, scripts, content calendars, or one source asset adapted cleanly across platforms.

affaan-m/ECC

Multi-platform content distribution across X, LinkedIn, Threads, and Bluesky. Adapts content per platform using content-engine patterns. Never posts identical content cross-platform. Use when the user wants to distribute content across social platforms.

affaan-m/fal-ai-media

Unified media generation via fal.ai MCP — image, video, and audio. Covers text-to-image (Nano Banana), text/image-to-video (Seedance, Kling, Veo 3), text-to-speech (CSM-1B), and video-to-audio (ThinkSound). Use when the user wants to generate images, videos, or audio with AI.

affaan-m/manim-video

日本語翻訳:このファイルは manim-video 用の日本語翻訳が必要です

affaan-m/remotion-video-creation

Remotion のベストプラクティス - React で動画を作成する。3D、アニメーション、音声、字幕、チャート、トランジションなどをカバーするドメイン固有の29のルール。

affaan-m/video-editing

AI-assisted video editing workflows for cutting, structuring, and augmenting real footage. Covers the full pipeline from raw capture through FFmpeg, Remotion, ElevenLabs, fal.ai, and final polish in Descript or CapCut. Use when the user wants to edit video, cut footage, create vlogs, or build video content.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

agent-self-evaluation

Use after completing any non-trivial task. The agent self-rates its output on 5 axes — accuracy, completeness, clarity, actionability, conciseness — with concrete evidence per criterion. Produces a structured 1-5 scorecard with specific improvement suggestions.

agent-sort

Build an evidence-backed ECC install plan for a specific repo by sorting skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using parallel repo-aware review passes. Use when ECC should be trimmed to what a project actually needs instead of loading the full bundle.

ai-first-engineering

Engineering operating model for teams where AI agents generate a large share of implementation output. Use when setting team process, review gates, or ownership rules for a codebase largely written by agents.

ai-regression-testing

Regression testing strategies for AI-assisted development. Sandbox-mode API testing without database dependencies, automated bug-check workflows, and patterns to catch AI blind spots where the same model writes and reviews code. Use when adding regression coverage to AI-assisted code, or when the same model both wrote and reviewed a change.

android-clean-architecture

Clean Architecture patterns for Android and Kotlin Multiplatform projects — module structure, dependency rules, UseCases, Repositories, and data layer patterns. Use when structuring modules, layers, or data flow in an Android or KMP project.

angular-developer

Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling.

相關技能