Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Ktor サーバーパターン(ルーティング DSL、プラグイン、認証、Koin DI、kotlinx.serialization、WebSocket、testApplication テストを含む)。
.claude/skills/affaan-m-kotlin-ktor-patterns/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-16 | ✗→✓ | ▲ Improved | 142% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 125% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 181% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 143% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 164% | 0% |
使用 Kotlin 协程构建健壮、可维护的 HTTP 服务器的综合 Ktor 模式。
textsrc/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
kotlin// Application.kt fun main() { embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true) } fun Application.module() { configureSerialization() configureAuthentication() configureStatusPages() configureCORS() configureDI() configureRouting() }
kotlin// 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) } } }
kotlinfun Route.userRoutes() { route("/users") { // Public routes get { /* list users */ } get("/{id}") { /* get user */ } // Protected routes authenticate("jwt") { post { /* create user - requires auth */ } put("/{id}") { /* update user - requires auth */ } delete("/{id}") { /* delete user - requires auth */ } } } }
kotlin// plugins/Serialization.kt fun Application.configureSerialization() { install(ContentNegotiation) { json(Json { prettyPrint = true isLenient = false ignoreUnknownKeys = true encodeDefaults = true explicitNulls = false }) } }
kotlin@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, )
kotlinobject 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()) }
kotlin// 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")) } } } } // Extracting user from JWT fun ApplicationCall.userId(): String = principal<JWTPrincipal>() ?.payload ?.getClaim("userId") ?.asString() ?: throw AuthenticationException("No userId in token")
kotlinfun 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)) } } } }
kotlin// 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")) } } }
kotlin// 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 } }
kotlin// di/AppModule.kt val appModule = module { // Database single<Database> { DatabaseFactory.create(get()) } // Repositories single<UserRepository> { ExposedUserRepository(get()) } single<OrderRepository> { ExposedOrderRepository(get()) } // Services single { UserService(get()) } single { OrderService(get(), get()) } single { AuthService(get(), get()) } } // Application setup fun Application.configureDI() { install(Koin) { modules(appModule) } }
kotlinfun Route.userRoutes() { val userService by inject<UserService>() route("/users") { get { val users = userService.getAll() call.respond(ApiResponse.ok(users)) } } }
kotlinclass 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 returns user") { coEvery { repository.findById("1") } returns testUser service.getById("1") shouldBe testUser } } }
kotlin// Validate request data in routes fun Route.userRoutes() { val userService by inject<UserService>() post("/users") { val request = call.receive<CreateUserRequest>() // Validate require(request.name.isNotBlank()) { "Name is required" } require(request.name.length <= 100) { "Name must be 100 characters or less" } require(request.email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" } val user = userService.create(request) call.respond(HttpStatusCode.Created, ApiResponse.ok(user)) } } // Or use a validation extension fun CreateUserRequest.validate() { require(name.isNotBlank()) { "Name is required" } require(name.length <= 100) { "Name must be 100 characters or less" } require(email.matches(Regex(".+@.+\\..+"))) { "Invalid email format" } }
kotlinfun Application.configureWebSockets() { install(WebSockets) { pingPeriod = 15.seconds timeout = 15.seconds maxFrameSize = 64 * 1024 // 64 KiB — increase only if your protocol requires larger frames masking = false // Server-to-client frames are unmasked per RFC 6455; client-to-server are always masked by Ktor } } fun Route.chatRoutes() { val connections = Collections.synchronizedSet<Connection>(LinkedHashSet()) webSocket("/chat") { val thisConnection = Connection(this) connections += thisConnection try { send("Connected! Users online: ${connections.size}") for (frame in incoming) { frame as? Frame.Text ?: continue val text = frame.readText() val message = ChatMessage(thisConnection.name, text) // Snapshot under lock to avoid ConcurrentModificationException val snapshot = synchronized(connections) { connections.toList() } snapshot.forEach { conn -> conn.session.send(Json.encodeToString(message)) } } } catch (e: Exception) { logger.error("WebSocket error", e) } finally { connections -= thisConnection } } } data class Connection(val session: DefaultWebSocketSession) { val name: String = "User-${counter.getAndIncrement()}" companion object { private val counter = AtomicInteger(0) } }
kotlinclass UserRoutesTest : FunSpec({ test("GET /users returns list of users") { testApplication { application { install(Koin) { modules(testModule) } configureSerialization() configureRouting() } val response = client.get("/users") response.status shouldBe HttpStatusCode.OK val body = response.body<ApiResponse<List<UserResponse>>>() body.success shouldBe true body.data.shouldNotBeNull().shouldNotBeEmpty() } } test("POST /users creates a user") { testApplication { application { install(Koin) { modules(testModule) } configureSerialization() configureStatusPages() configureRouting() } val client = createClient { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } } val response = client.post("/users") { contentType(ContentType.Application.Json) setBody(CreateUserRequest("Alice", "alice@example.com")) } response.status shouldBe HttpStatusCode.Created } } test("GET /users/{id} returns 404 for unknown id") { testApplication { application { install(Koin) { modules(testModule) } configureSerialization() configureStatusPages() configureRouting() } val response = client.get("/users/unknown-id") response.status shouldBe HttpStatusCode.NotFound } } })
kotlinclass AuthenticatedRoutesTest : FunSpec({ test("protected route requires JWT") { testApplication { application { install(Koin) { modules(testModule) } configureSerialization() configureAuthentication() configureRouting() } val response = client.post("/users") { contentType(ContentType.Application.Json) setBody(CreateUserRequest("Alice", "alice@example.com")) } response.status shouldBe HttpStatusCode.Unauthorized } } test("protected route succeeds with valid JWT") { testApplication { application { install(Koin) { modules(testModule) } configureSerialization() configureAuthentication() configureRouting() } val token = generateTestJWT(userId = "test-user") val client = createClient { install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { json() } } val response = client.post("/users") { contentType(ContentType.Application.Json) bearerAuth(token) setBody(CreateUserRequest("Alice", "alice@example.com")) } response.status shouldBe HttpStatusCode.Created } } })
yamlktor: application: modules: - com.example.ApplicationKt.module deployment: port: 8080 jwt: secret: ${JWT_SECRET} issuer: "https://example.com" audience: "https://example.com/api" realm: "example" database: url: ${DATABASE_URL} driver: "org.postgresql.Driver" maxPoolSize: 10
kotlinfun Application.configureDI() { val dbUrl = environment.config.property("database.url").getString() val dbDriver = environment.config.property("database.driver").getString() val maxPoolSize = environment.config.property("database.maxPoolSize").getString().toInt() install(Koin) { modules(module { single { DatabaseConfig(dbUrl, dbDriver, maxPoolSize) } single { DatabaseFactory.create(get()) } }) } }
| 模式 | 描述 | |---------|-------------| | route("/path") { get { } } | 使用 DSL 进行路由分组 | | call.receive<T>() | 反序列化请求体 | | call.respond(status, body) | 发送带状态的响应 | | call.parameters["id"] | 读取路径参数 | | call.request.queryParameters["q"] | 读取查询参数 | | install(Plugin) { } | 安装并配置插件 | | authenticate("name") { } | 使用身份验证保护路由 | | by inject<T>() | Koin 依赖注入 | | testApplication { } | 集成测试 |
记住:Ktor 是围绕 Kotlin 协程和 DSL 设计的。保持路由精简,将逻辑推送到服务层,并使用 Koin 进行依赖注入。使用 testApplication 进行测试以获得完整的集成覆盖。
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-15 | pass→pass | 11,719 | 11,453 | -2% | 1 | 1 | 0% | 2,156 | 6,854 | +218% | 0 | 0 | — |
case-16 | fail→pass | 24,276 | 7,949 | -67% | 1 | 1 | 0% | 2,537 | 6,135 | +142% | 0 | 0 | — |
case-01 | fail→fail | 15,437 | 16,110 | +4% | 1 | 1 | 0% | 3,370 | 8,253 | +145% | 0 | 0 | — |
case-02 | fail→pass | 13,753 | 9,194 | -33% | 1 | 1 | 0% | 2,979 | 6,713 | +125% | 0 | 0 | — |
case-03 | fail→fail | 19,459 | 18,719 | -4% | 1 | 1 | 0% | 3,156 | 8,326 | +164% | 0 | 0 | — |
case-04 | pass→pass | 8,094 | 5,075 | -37% | 1 | 1 | 0% | 1,487 | 5,469 | +268% | 0 | 0 | — |
case-05 | pass→pass | 9,764 | 8,171 | -16% | 1 | 1 | 0% | 1,972 | 6,164 | +213% | 0 | 0 | — |
case-17 | pass→pass | 10,868 | 8,271 | -24% | 1 | 1 | 0% | 2,028 | 6,210 | +206% | 0 | 0 | — |
case-06 | fail→fail | 13,119 | 9,278 | -29% | 1 | 1 | 0% | 2,403 | 6,526 | +172% | 0 | 0 | — |
case-07 | fail→pass | 11,769 | 9,553 | -19% | 1 | 1 | 0% | 2,278 | 6,397 | +181% | 0 | 0 | — |
case-08 | fail→pass | 14,205 | 9,735 | -31% | 1 | 1 | 0% | 2,648 | 6,439 | +143% | 0 | 0 | — |
case-09 | pass→pass | 10,386 | 8,650 | -17% | 1 | 1 | 0% | 1,914 | 6,324 | +230% | 0 | 0 | — |
case-10 | pass→pass | 12,472 | 8,763 | -30% | 1 | 1 | 0% | 2,232 | 6,484 | +191% | 0 | 0 | — |
case-11 | pass→pass | 12,738 | 8,230 | -35% | 1 | 1 | 0% | 2,285 | 6,040 | +164% | 0 | 0 | — |
case-12 | fail→fail | 16,440 | 12,730 | -23% | 1 | 1 | 0% | 2,634 | 6,959 | +164% | 0 | 0 | — |
case-13 | fail→pass | 12,605 | 6,635 | -47% | 1 | 1 | 0% | 2,156 | 5,702 | +164% | 0 | 0 | — |
case-14 | fail→fail | 14,816 | 27,931 | +89% | 1 | 1 | 0% | 2,621 | 7,416 | +183% | 0 | 0 | — |
case-18 | pass→pass | 10,610 | 5,771 | -46% | 1 | 1 | 0% | 1,929 | 5,636 | +192% | 0 | 0 | — |
case-19 | pass→pass | 12,846 | 9,966 | -22% | 1 | 1 | 0% | 2,444 | 6,668 | +173% | 0 | 0 | — |
case-20 | pass→pass | 17,459 | 13,019 | -25% | 1 | 1 | 0% | 3,331 | 7,389 | +122% | 0 | 0 | — |
case-21 | pass→pass | 10,031 | 11,291 | +13% | 1 | 1 | 0% | 2,098 | 6,634 | +216% | 0 | 0 | — |
case-22 | pass→pass | 22,502 | 14,382 | -36% | 1 | 1 | 0% | 3,029 | 7,369 | +143% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.