본문 바로가기

Develop/Kotlin

Ktor 찍먹하기 - 간단 HTTP API 작성

ktor 공식문서 따라해 보는 중 : https://ktor.io/docs/gradle.html#create-entry-point

 

Adding Ktor to an existing Gradle project | Ktor

 

ktor.io

나의 소스코드 : https://github.com/jyami-kim/ktor-sample

 

1. 서버 실행을 위한 기본 골격

- ktor embeddedServer로 실행 : https://ktor.io/docs/gradle.html#create-embedded-server
- ktor engineMain으로 실행 : https://ktor.io/docs/gradle.html#create-engine-main

a. embeddedServer 

package com.example

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main() {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/") {
                call.respondText("Hello, world!")
            }
        }
    }.start(wait = true)
}

기본 서버 실행을 위한 골격. routing에 해당하는 부분을 주로 아래와 같이 configureXXX 와 같은 형태의 플러그인 형식으로 빼서, 분리하는 패턴을 갖고있음. (코틀린의 확장함수 사용)

// com.jyami.Application

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        configureRouting()
    }.start(wait = true)
}

// com.jyami.plugins.Routing

fun Application.configureRouting() {

    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

 

위와같이 간단한 코드만 짜서 서버 RUN을 시키면? 
서버 실행 속도가 이렇게까지 빨라도 되는건가.. spring안쓰고 간단하게 서버 만들고 싶을 때 쓰면 좋을 것 같다.

 

b. engineMain (HOCON 포맷)

위 예제는 embeddedServer로 짜는 방법을 적었는데, 만약 EngineMain 방식으로 ktor 서버를 짜고 싶다면 조금 다른 방식이다. 

// main.kotlin.com.jyami

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
    configureRouting()
}


// main.kotlin.com.jyami.plugins

fun Application.configureRouting() {
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}


// resource

ktor {
    deployment {
        port = 8080
    }
    application {
        modules = [ com.jyami.ApplicationKt.module ]
    }
}

HOCON 포맷이라고한다. resources 폴더안에 application.conf 파일을 이용해서 실행시킬 포트나, entry point에대한 애플리케이션 설정을 작성한다. application.conf 관련 여러 설정들 : https://ktor.io/docs/configurations.html

 

2. gradle 설정 확인

gradle application 플러그인 : https://docs.gradle.org/current/userguide/application_plugin.html

 

The Application Plugin

This plugin also adds some convention properties to the project, which you can use to configure its behavior. These are deprecated and superseded by the extension described above. See the Project DSL documentation for information on them. Unlike the extens

docs.gradle.org

 

실행가능한 JVM 애플리케이션을 만들 수 있는 기능을 제공함. 
개발도중에 쉽게 애플리케이션을 시작할 수 있도록 해주며, TAR, AIP과 같이 애플리케이션 패키지도 지원해줌

application 플러그인 안에 java / distribution 플러그인이 모두 내장되어있다. 각각 main에 대한 소스 셋, 배포를 위한 패키징 기능을 가진 플러그인 인듯 하다.

application 플러그인을 사용할 때  configuration은 main class (i.e. entry point)를 지정하는 건 꼭 필요하다.

plugins {
    application
    kotlin("jvm") version "1.6.0"
}
application {
    mainClass.set("com.kakao.ApplicationKt")
}

실행시 intellij runner사용도 되고, ./gradlew run을 사용해도 된다.

 

3. 튜토리얼 1: HTTP API 만들기

build.gradle 분석

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    implementation("io.ktor:ktor-serialization:$ktor_version")

    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
  • ktor-server-core : ktor가 제공하는 핵심 컴포넌트 제공
  • ktor-server-netty : 네티엔진을 사용하여 서버 기능을 사용할 수 있게한다. 외부 애플리케이션 컨테이너에 의존하지 않고도 가능.
  • logback-classic : SLF4J 구현
  • ktor-serialization : 코틀린 객체를 JSON과 같은 직렬화 형태로 변환해준다. 
  • ktor-server-test-host : http 스택을 사용하지 않고도 서버 테스트가 가능함. unit test로 사용가능함

 

ConentNegotiation

fun Application.module() {
    install(ContentNegotiation){
        json()
    }
}
  • 클라이언트와 서버간의 미디어타입을 중계해준다. : Accept, Content-Type Header
  • kotlinx.serialization 라이브러리 혹은 그외의 것 (Gson, Jackson)을 사용하여 컨텐츠를 특정 포맷으로 serializing/deserializing한다. (json으로 하겠지)

ContentNegotiation 플러그인을 사용하기 위하여 애플리케이션 초기화 코드에 install 함수를 사용하여 해당하는 ConetentNegotiation 플러그인을 넘겨준다.

ktor에서는 Content-Type으로 다양한 내장 함수를 제공하고 있는데 

  • kotlinx.serializtion : JSON, Protobuf, CBOR
  • Gson : JSON
  • Jackson : JSON

그이외에 커스텀한 Content-Type을 제공하고 싶다면 아래와 같이 ContentNegotiation의 register 메서드를 사용하여 등록하면 된다

install(ContentNegotiation) {
    register(ContentType.Application.Json, CustomJsonConverter())
    register(ContentType.Application.Xml, CustomXmlConverter())
}

 

request, response 파라미터 제어

1. path parameter

get("{id}") {
    val id = call.parameters["id"] ?: return@get call.respondText(
        "Missing or malformed id",
        status = HttpStatusCode.BadRequest
    )
    val customer = customerStorage.find {it.id == id} ?: return@get call.respondText (
        "No customer with id $id",
        status = HttpStatusCode.NotFound
    )
    call.respond(customer)
}

인덱스 접근자 함수 : call.parameters["myParamName"]
요청 Path를 기본적으로 string으로 가져오게 된다.

 

2. request body

post {
    val customer = call.receive<Customer>()
    customerStorage.add(customer)
    call.respondText ("Customer stored correctly", status = HttpStatusCode.Created)
}

call.recevie<T> : 제네릭 변수를 사용하여 호출한 request body를 자동으로 코틀린 객체로 역직렬화 한다. 

동시에 여러 요청이 접근될 때 문제는 이 실습의 범위를 벗어난다 : 동시에 요청/스레드에서 액세스 할 수 있는 데이터 구조 혹은 코드를 작성하면 될 것

 

3. response body

get {
    if (customerStorage.isNotEmpty()) {
        call.respond(customerStorage)
    } else {
        call.respondText("No customers found", status = HttpStatusCode.NotFound)
    }
}
  • call.respond() : kotlin 객체를 가져와 해당 객체가 지정된 형식으로 직렬화하여 http 응답을 반환한다.
  • call.respondText() : Text를 응답으로 보낼 때의 respond를 간단하게 구현해둔 함수이다. 문자열 응답을 반환한다.

 

Routing

Route 확장함수를 이용하여 경로를 정의하였다. 이렇게 할 경우 아무래도 라우팅만 역할을 빼서 코드를 관리할 수 있어서 좋아보인다. 
Application.module에 있는 라우팅 블록 내부에 각 경로를 직접 추가할 수 있긴하지만, 파일 경로들을 그룹화하여 관리하는 것이 유지보수에 더 좋다. 

Route 확장함수만을 사용하여 경로를 정의할 경우 실제 Ktor 서버에는 적용되지 않는데, 맨 처음 ktor 시작하기에 했던 것처럼, route를 Application에 등록해주는 과정이 필요하다. 즉, Route.module에 등록된 경로들을 Application.module에 등록하는 방식으로 관리하자.

Application의 확장함수를 사용함으로써 application에 등록할 path를 route dsl을 이용하여 등록할 수 있으며, 이 dsl에는 사실상 Route 모듈에 등록된 경로를 Application에 경로를 등록하도록 도와주는 것으로 보인다.

fun Route.customerRouting() {
    route("/customer") {
    	get{}
    }
}

fun Application.registerCustomerRoutes() {
    routing {
        customerRouting()
    }
}

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        registerCustomerRoutes()
        install(ContentNegotiation){
            json()
        }
    }.start(wait = true)
}

 

4. Ktor 테스트

ktor-server-test-host : netty를 시작하지 않고도 endpoint 테스트가 가능하다.
테스트 request를 실행하기 위한 몇가지 헬퍼 메서드가 프레임워크에서 제공되며, 그중 하나가 TestApplication 이다.

HOCON방식 : 한번에 모든 configuration이 등록가능한 구조

internal class OrderRoutingKtTest {

    @Test
    fun testGetOrder() {
        withTestApplication({ module() }) {
            handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
                assertEquals(
                    """{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
                    response.content
                )
                assertEquals(HttpStatusCode.OK, response.status())
            }
        }

    }
    
}

HOCON 포맷인 경우에 사용이 가능한 것으로 보인다. (embeddedServer 방식으로 짜는 경우에는 httpClient를 사용한 직접 호출 방식을 이용하는 것으로 보인다 : https://ktor.io/docs/testing.html#end-to-end)

핵심은 withTestApplication인데, 테스트로 실행하려는 애플리케이션을 주입한다. (Application.module() 형태로된 메서드면 무엇이든 주입이 가능한 것으로 보인다.)

HOCON 포맷의 경우에는, main에 넣기전 main밖에 있는 Application 확장함수에 정의되어있어, 해당 애플리케이션에 대한 모든 설정이 등록된채 테스트가 가능하고, 따라서 위와 같이 module() 을 넣고 테스트를 해도 무관하다.

 

embeddedServer 방식 : 필요모듈만 configuration에 등록하여 테스트할 수 있는 구조

fun Application.testModule(){
    registerOrderRoute()
    install(ContentNegotiation){
        json()
    }
}

@Test
fun testGetOrder() {
    withTestApplication({ testModule() }) {
        handleRequest(HttpMethod.Get, "/order/2020-04-06-01").apply {
            assertEquals(
                """{"number":"2020-04-06-01","contents":[{"item":"Ham Sandwich","amount":2,"price":5.5},{"item":"Water","amount":1,"price":1.5},{"item":"Beer","amount":3,"price":2.3},{"item":"Cheesecake","amount":1,"price":3.75}]}""",
                response.content
            )
            assertEquals(HttpStatusCode.OK, response.status())
        }
    }

}

다만 embeddedServer 방식으로 withTestApplication을 작성하는 경우에는, main() 함수안에 직접 선언이 되어있어 애플리케이션에 대한 모든 테스트는 불가능하고 위에서 내가 정의한 configureRouting(), registerCustomRouting(), registerOrderRouting()과 같이 Application 확장함수 단위로 테스트가 가능하다. 

다만 여기서 주의해야할 점은 configureRouting은 단일하게 넣어도 테스트가 가능하나, registerOrderRouting, registerCustomRouting의 경우에는 불가능하다 왜냐하면 이것들을 직접 넣고 돌리게되면, Application에 등록해둔 ContentNegotiation이 빠지게 될것이고, serialize과정에 문제가 생겨서 테스트가 실패하게 될 것이다. 따라서 테스트시 두개의 configuration을 함께 등록하여 테스트해야한다.

위와같이 테스트가 가능하게 되면서, ktor을 사용할 경우에는 모듈별로, 설정별로 테스트가 손쉽게 될 것으로 보인다.
사실, embeddedServer나 HOCON이나 하나의 Application 확장함수를 만들어서 테스트하는건 같아서 입맛대로 사용하도록하자.

 

'Develop > Kotlin' 카테고리의 다른 글

backingField와 recursive call  (1) 2022.05.04
Kotlin Void vs Unit vs Nothing  (0) 2022.05.02