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 連接應該使用同步集合進行管理,以避免並發修改問題