Kotlin Ktor WebSocket 사용하기

|

Ktor WebSocket 사용하기

WebSocket를 이용하면 실시간 양방향 통신을 할 수 있습니다.


build.gradle

먼저 build.gradle에 다음 라이브러리를 추가해줍니다.

dependencies {
    implementation "io.ktor:ktor-websockets:$ktor_version"
}


Main.kt

routing은 다음 코드처럼 할 수 있으며, incoming.receive() 메소드와 outgoing.send(Frame.Text(text) 메소드는 블럭킹(Blocking) 메소드입니다.

package com.snowdeer

import io.ktor.application.Application
import io.ktor.application.install
import io.ktor.http.cio.websocket.Frame
import io.ktor.http.cio.websocket.readText
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.routing.routing
import io.ktor.websocket.WebSockets
import io.ktor.websocket.webSocket
import java.time.Duration

fun Application.main() {

    install(WebSockets) {
        pingPeriod = Duration.ofSeconds(60) // Disabled (null) by default
        timeout = Duration.ofSeconds(15)
        maxFrameSize = Long.MAX_VALUE // Disabled (max value). The connection will be closed if surpassed this length.
        masking = false
    }

    routing {
        static("/static") {
            resources("static")
        }

        webSocket("/chat") {
            println("[snowdeer] chat starts")
            while (true) {
                when (val frame = incoming.receive()) {
                    is Frame.Text -> {
                        val text = frame.readText()
                        println("[snowdeer] text: $text")
                        outgoing.send(Frame.Text("$text from Server"))
                    }
                }
            }

            println("[snowdeer] chat is finished")
        }
    }
}


client.html

다음 코드를 브라우저에서 실행해서 WebSocket 통신이 잘 되는지 확인할 수 있습니다.

<!DOCTYPE HTML>

<html>
   <head>
      
      <script type = "text/javascript">
         function startWebSocket() {
            
            if ("WebSocket" in window) {
               alert("You can use WebSocket.");
               
               var ws = new WebSocket("ws://localhost:8080/chat");
				
               ws.onopen = function() {
                  alert("WebSocket is opened.");
                  ws.send("Hello");
                  alert("Send message to Server(Hello).");
               };
				
               ws.onmessage = function (evt) { 
                  var msg = evt.data;
                  alert("Message is received(" + msg + ")");
               };
				
               ws.onclose = function() { 
                  alert("WebSocket is closed."); 
               };
            } else {
               alert("Your browser does not support WebSocket !!!");
            }
         }
      </script>
		
   </head>
   
   <body>
      <a href = "javascript:startWebSocket()">Start WebSocket</a>
   </body>
</html>

Kotlin Ktor CORS 설정

|

CORS 설정

fun Application.module() {
    install(CORS) {
        method(HttpMethod.Options)
        method(HttpMethod.Get)
        method(HttpMethod.Post)
        method(HttpMethod.Put)
        method(HttpMethod.Delete)
        method(HttpMethod.Patch)
        header(HttpHeaders.Authorization)
        allowCredentials = true
        anyHost()
    }
    // ...
}

Kotlin Ktor에 Logger 연결하기

|

Ktor에 Logger 연결하기

Ktor에 Logger를 연결하지 않으면, 실행할 때 마다 다음 경고 메시지를 볼 수 있습니다.

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.

경고 메시지라 실행하는데 큰 문제는 없지만, 걸리적거리기도 하고 원할한 디버깅을 위해 Logger를 설정하는 것을 추천합니다.

기본적으로 제공하는 Logback provider를 사용해보도록 하겠습니다.


build.gradle

먼저 build.gradle에 다음 항목을 추가합니다.

implementation "ch.qos.logback:logback-classic:1.2.3"


logback.xml

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="trace">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="org.eclipse.jetty" level="INFO"/>
    <logger name="io.netty" level="INFO"/>
</configuration>


그 이후 프로그램을 종료했다가 재실행을 하면 다음과 같은 메시지가 출력될 것입니다.

2020-01-06 18:47:51.160 [main] TRACE Application - {
    # application.conf @ file:/Users/snowdeer/Workspace/HelloKtor/build/resources/main/application.conf: 6
    "application" : {
        # application.conf @ file:/Users/snowdeer/Workspace/HelloKtor/build/resources/main/application.conf: 7
        "modules" : [
            # application.conf @ file:/Users/snowdeer/Workspace/HelloKtor/build/resources/main/application.conf: 7
            "com.snowdeer.BlogAppKt.main"
        ]
    },
    # application.conf @ file:/Users/snowdeer/Workspace/HelloKtor/build/resources/main/application.conf: 2
    "deployment" : {
        # application.conf @ file:/Users/snowdeer/Workspace/HelloKtor/build/resources/main/application.conf: 3
        "port" : 8080
    },
    # Content hidden
    "security" : "***"
}

2020-01-06 18:47:51.279 [main] INFO  Application - No ktor.deployment.watch patterns specified, automatic reload is not active
2020-01-06 18:47:51.598 [main] INFO  Application - Responding at http://0.0.0.0:8080

이 상태에서 GET, POST 등의 메소드를 이용해서 서버에 접속해보면 다양한 로그 메시지들이 출력되는 것을 확인할 수 있습니다.

Kotlin Ktor를 활용한 HTTP API 서버 만들기

|

Ktor를 활용한 HTTP API 서버 구현

간단한 라우팅 구현

fun Application.main() {

    routing {
        static("/static") {
            resources("static")
        }

        routing {
            get("/apis") {
                call.respondText("OK")
            }
        }
    }
}

위 코드로 http://localhost:8080/apis 경로로 접속하면 OK라는 텍스트를 리턴하는 웹서버를 만들 수 있습니다.


Jakson 라이브러리 추가

HTTP API들은 보통 JSON 형태의 데이터를 많이 사용합니다. jakson 라이브러리를 사용하면 보다 쉽게 JSON 데이터를 전송할 수 있습니다.

build.gradle에 다음 종속성을 추가합니다.

implementation "io.ktor:ktor-jackson:$ktor_version"

그리고 위의 Application.main() 코드를 다음과 같이 변경합니다.

package com.snowdeer

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.jackson.jackson
import io.ktor.response.respond
import io.ktor.routing.get
import io.ktor.routing.routing

fun Application.main() {

    install(ContentNegotiation) {
        jackson {
        }
    }

    routing {
        static("/static") {
            resources("static")
        }

        routing {
            get("/apis") {
                call.respond(mapOf("id" to "snowdeer"))
            }
        }
    }
}

만약 복수의 데이터를 Map 형태로 리턴하고 싶으면 다음과 같이 작성할 수 있습니다.

package com.snowdeer

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.jackson.jackson
import io.ktor.response.respond
import io.ktor.routing.get
import io.ktor.routing.routing
import java.util.*

fun Application.main() {

    install(ContentNegotiation) {
        jackson {
        }
    }

    routing {
        static("/static") {
            resources("static")
        }

        routing {
            get("/apis") {

                val map = HashMap<String, Any>()
                map["id"] = "snowdeer"
                map["age"] = 40
                map["email"] = "snowdeer0314@gmail.com"
                map["boolean"] = true
                map["float"] = 3.14F
                map["long"] = 30L

                call.respond(mapOf("data" to map))
            }
        }
    }
}

이 때 http://localhost:8080/apis로 리퀘스트를 날리면 응답은 다음과 같습니다.

{
    "data": {
        "boolean": true,
        "id": "snowdeer",
        "float": 3.14,
        "age": 40,
        "email": "snowdeer0314@gmail.com",
        "long": 30
    }
}


HTTP POST를 이용한 데이터 수신

POST를 이용해서 JSON 데이터를 수신받을 때는 call.receive<Type>() 메소드를 이용해서 전달 받을 수 있습니다.

package com.snowdeer

import com.fasterxml.jackson.databind.SerializationFeature
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.jackson.jackson
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.routing
import java.util.*
import kotlin.collections.ArrayList

data class PostItem(val id: String, val email: String)

fun Application.main() {

    val list = ArrayList<PostItem>()

    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT)
        }
    }

    routing {
        static("/static") {
            resources("static")
        }

        routing {
            get("/apis") {

                val map = HashMap<String, Any>()
                map["id"] = "snowdeer"
                map["age"] = 40
                map["email"] = "snowdeer0314@gmail.com"
                map["boolean"] = true
                map["float"] = 3.14F
                map["long"] = 30L

                call.respond(mapOf("data" to map))
            }

            post("/apis/add") {
                val data = call.receive<PostItem>()
                println("[snowdeer] receive: ${data}")

                list.add(data)

                call.respond(mapOf("result" to true))
            }

            get("/apis/list") {
                call.respond(mapOf("data" to list))
            }
        }
    }
}

POST로 데이터를 전송할 때 주의할 점은, Request의 Key/Value로 값을 전달하는게 아니라 HTTP Body에 데이터를 문자열 형태로 전송해야 한다는 점입니다.

예를 들면 다음과 같은 형태로 HTTP Request를 전송해야 합니다.

POST http://127.0.0.1:8080/apis/add
Content-Type: application/json

{
	"id": "snowdeer",
	"email" : "hello"
}

만약 JSON 포맷이 다르거나 잘못된 내용을 전송할 경우 500Internal Server Error를 리턴합니다.

Kotlin Ktor의 DSL을 이용한 웹페이지 생성

|

DSL을 이용한 웹페이지 만들기

FreeMaker 모듈 대신 HTML DSL(Domain specific language)를 이용해서 웹페이지를 응답하는 코드 예제입니다.

보다 자세한 내용은 여기에서 볼 수 있습니다.

build.gradle

Ktor용 App을 위한 기본 build.gradle의 종속성에 FreeMaker 종속성 대신에 implementation "io.ktor:ktor-html-builder:$ktor_version" 항목이 추가되었습니다.

repositories 항목에 jcenter()를 추가해야 됩니다.

group 'com.snowdeer'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.3.61'
    ext.ktor_version = '1.2.6'

    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'application'

sourceCompatibility = 1.8
application {
    mainClassName = "com.snowdeer.BlogAppKt"
}

repositories {
    jcenter()
    mavenCentral()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"

    implementation "io.ktor:ktor-html-builder:$ktor_version"
    compile "io.ktor:ktor-server-netty:$ktor_version"

    testCompile group: 'junit', name: 'junit', version: '4.12'
}

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

kotlin {
    experimental {
        coroutines "enable"
    }
}


BlogApp.kt

package com.snowdeer

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.html.respondHtml
import io.ktor.http.content.resources
import io.ktor.http.content.static
import io.ktor.routing.get
import io.ktor.routing.routing
import kotlinx.html.*

data class IndexData(val items: List<Int>)

fun Application.main() {
    routing {
        static("/static") {
            resources("static")
        }

        get("/") {
            val data = IndexData(listOf(1, 2, 3))
            call.respondHtml {
                head {
                    link(rel = "stylesheet", href = "/static/styles.css")
                }
                body {
                    ul {
                        for (item in data.items) {
                            li { +"$item" }
                        }
                    }
                }
            }
        }
    }
}