มาทำ RESTful API ด้วย Kotlin กันดีกว่า

มาทำ RESTful API ด้วย Kotlin กันดีกว่า Cover Image

หลังจากได้ไปเป็น Speaker ที่งาน Dev Night - Meetup #1 (Kotlin) by CNX Community of Practice ที่จัดขึ้นครั้งแรก รู้สึกว่าเราต้องกลับมาเขียนบล็อคเพิ่มเติมซักหน่อย เพราะว่าสไลด์และการบรรยายในวันนั้น น่าจะไม่พอ จริงๆบทความนี้ก็เป็นการสรุปที่วันนั้นพูดไปแหละครับ มีเพิ่มๆมาบ้างตามที่คิดออกละกัน :)

Kotlin for Server

บางคนอาจจะสงสัยว่าเคยได้ยินแต่ Kotlin เอามาเขียนแอพ Android มันเอามาทำเว็บหรือเขียนฝั่ง Server ได้ด้วยหรอ? จริงๆต้องบอกว่าได้ครับ ไม่ใช่เฉพาะแอพ Android เท่านั้น ซึ่งดูๆแล้ว Kotlin อนาคตน่าจะค่อนข้างสดใสเลย เพราะว่า Google เค้าประกาศ support kotlin เต็มตัวแล้วนี่นา แต่นั้นมันฝั่งแอพมือถือ

และถ้าหากหันมามองฝั่ง Server ละ จริงๆก็มีหลายๆ framework ให้ใช้ หรือว่ารองรับภาษา Kotlin แล้ว ไม่ว่าจะเป็นการใช้ร่วมกับ Spring Boot, Spark, Javelin, Wasabi หรือ ktor ซึ่งบทความนี้จะพูดถึงเจ้าตัวสุดท้ายนี่แหละครับ

สำหรับคนไม่รู้ว่า Kotlin คืออะไร อ่านเพิ่มที่นี่ครับ รู้จักภาษา Kotlin ภาษาที่สองของโลก Android ใช้ทดแทน-ควบคู่กับ Java ได้ 100% และ ทำไมต้องหันมาใช้ Kotlin พร้อม「code lab 」ลด learning curve

ktor คืออะไร?

ขอข้าม Kotlin ไป แล้วมาพูดถึง ktor กันเลยดีกว่า :)

ktor เป็น Web Framework ภาษา Kotlin ที่พัฒนาโดยทีม JetBrains ทีมเดียวกับที่สร้าง Kotlin และ Intelij นั่นแหละ ซึ่งแน่นอนที่ผมเลือกใช้เจ้าตัวนี้ก็เพราะมองว่า ทีมเดียวกัน น่าจะทำให้เป็น Standard หรือมาตรฐานสำหรับเขียนเว็บด้วย Kotlin ไม่ยาก ว่าแล้วเพื่อไม่ให้เสียเวลา เข้าเนื้อหาเถอะนะ :)

Step 1 : Getting Started

สิ่งที่ต้องใช้สำหรับโปรเจ็คนี้คือ Intelij IDEA CE ตัวเวอร์ชันฟรี นั่นเอง

Intelij IDEA

ดาวน์โหลดและทำการติดตั้งเลยครับ เมื่อติดตั้งเรียบร้อยแล้ว ให้เปิดโปรแกรมขึ้นมา

ทำการสร้างโปรเจ็ค โดยเลือกเป็น Kotlin ดังรูป

Create Project

Create Project

Create Project

จากนั้นเราจะได้โปรเจ็คที่เป็น Kotlin base แบบนี้

Create Project

ใครมาถึงตรงนี้แล้ว ไปต่อได้

Step 2 : Add ktor

ต่อมาทำการแก้ไขไฟล์ build.gradle จาก default ที่หน้าตาแบบด้านล่าง

// build.gradle

group 'com.devahoy.kotlinserver'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.1.4-3'

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

apply plugin: 'kotlin'

repositories {
    mavenCentral()
}

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
}

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

ก็ทำการเพิ่ม Repository URL เข้าไป

repositories {
    mavenCentral()
    maven { url "https://dl.bintray.com/kotlin/kotlinx" }
    maven { url "https://dl.bintray.com/kotlin/ktor" }
}

จากนั้นกำหนด ktor version ตรงส่วนบล็อค buildscript

buildscript {
    ext.kotlin_version = '1.1.4-3'
    ext.ktor_version = '0.4.0'

สุดท้ายเพิ่ม Dependencies ตามนี้

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
    compile "org.jetbrains.ktor:ktor-netty:$ktor_version"
    compile "org.jetbrains.ktor:ktor-gson:$ktor_version"
    compile "ch.qos.logback:logback-classic:1.2.1"
}
  • ktor-netty : เป็นตัว Web Server ที่เอาไว้ใช้รัน
  • ktor-gson : เป็นตัว build-in gson ให้เรา ไม่ต้องไปแมพ POJO กันเองแล้ว
  • logback : เป็นตัว log

สุดท้ายไฟล์ build.gradle เราก็จะได้แบบนี้

// build.gradle

group 'com.devahoy.kotlinserver'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.1.4-3'
    ext.ktor_version = '0.4.0'

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

apply plugin: 'kotlin'

kotlin {
    experimental {
        coroutines "enable"
    }
}

repositories {
    mavenCentral()
    maven { url "https://dl.bintray.com/kotlin/kotlinx" }
    maven { url "https://dl.bintray.com/kotlin/ktor" }
}

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
    compile "org.jetbrains.ktor:ktor-netty:$ktor_version"
    compile "org.jetbrains.ktor:ktor-gson:$ktor_version"
    compile "ch.qos.logback:logback-classic:1.2.1"
}

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

Step 3 : Create Main.kt

ต่อมาทำการสร้างไฟล์ขึ้นมาไฟล์นึง Main.kt ใน src/main/kotlin แล้วเพิ่มโค๊ดด้านล่างนี้ลงไป

import org.jetbrains.ktor.host.embeddedServer
import org.jetbrains.ktor.netty.Netty
import org.jetbrains.ktor.response.respondText
import org.jetbrains.ktor.routing.get
import org.jetbrains.ktor.routing.routing

fun main(args: Array<String>) {
    val server = embeddedServer(Netty, 9000) {
        routing {
            get("/") {
                call.respondText("Hello World!")
            }
        }
    }
    server.start(wait = true)
}
  • embeddedServer() : จะเป็น Object ที่รับ Web Server ในที่นี่คือ Netty และ port ซึ่งกำหนดไว้คือ port 9000
  • routing {} : ทำการกำหนด routing ให้กับ web server ของเรานั่นเอง
  • get("/") : กำหนดให้เว็บมีแค่ path เดียวคือ http://localhost:9000/
  • call.respondText("Hello World!") : แสดงคำว่า Hello World! หากเข้าเว็บด้วย path /
  • server.start(wait = true) : สั่ง start server.

แค่นี้เราก็ได้เว็บ Server ที่เขียนด้วย Kotlin + Ktor แล้ว ไม่เชื่อ คลิกขวาที่โปรแกรม เลือก => Run ‘MainKt’ แล้วเข้าเว็บ http://localhost:9000 จะเห็นว่าเราได้เว็บ server ง่ายๆแล้ว

Web Server

Step 4 : Application Module

ต่อมา เราต้องมานั่งกดคลิกขวา แล้วเลือก Run ‘MainKt’ ทุกครั้งก็ไม่น่าจะดี หรือถ้าต้องการ Deploy ลง server ละ จะทำไง คำตอบคือ เราสามารถใช้ Application Object เข้ามาช่วยครับ

โดยการเปลี่ยนจาก fun main() เป็น Application Object ซะ

import org.jetbrains.ktor.application.Application
import org.jetbrains.ktor.application.install
import org.jetbrains.ktor.features.CallLogging
import org.jetbrains.ktor.features.DefaultHeaders
import org.jetbrains.ktor.host.embeddedServer
import org.jetbrains.ktor.netty.Netty
import org.jetbrains.ktor.response.respondText
import org.jetbrains.ktor.routing.Routing
import org.jetbrains.ktor.routing.get

fun Application.module() {
    install(DefaultHeaders)
    install(CallLogging)
    install(Routing) {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

fun main(args: Array<String>) {
    val server = embeddedServer(Netty, 9000, watchPaths = listOf("MainKt"), module = Application::module)
    server.start(wait = true)
}

จะเห็นได้ว่าโค๊ดอันใหม่ของเรา ถูก Improve ขึ้นทุกอย่างแยกไปอยู่อีกฟังค์ชันคือ Application.module() และตัว main() ก็เพียงแค่เรียก embededServer ส่วนการคอนฟิคต่างๆ ก็ไปทำใน Application.module() แทน

ซึ่งมีส่วนที่แตกต่างจากครั้งแรกคือ

  • embededServer() รับ args เป็น watchPatsh และ module ซึ่งอ้างถึง Application.module() นั่นเอง
  • install(...) ทำหน้าที่คล้ายๆกับ Middleware คือทำการกำหนด Module ที่จะใช้เช่น DefaultHeaders, CallLogging, Routing เป็นต้น

ลองรัน Server ใหม่อีกครั้ง ผลลัพธ์ยังคงเหมือนเดิม

ต่อมาสร้างไฟล์ application.conf ในโฟลเดอร์ resources ดังนี้

ktor {
    deployment {
        port = 9000
    }

    application {
        modules = [ com.devahoy.kotlinserver.MainKt.main ]
    }
}
  • อย่าลืมเปลี่ยน com.devahoy.kotlinserver เป็น package ของตัวเอง แล้วก็ไฟล์ MainKt.main คือชื่อไฟล์ + Kt แล้วตามด้วย .main ซึ่งก็คือไฟล์​ Main.kt นั่นเอง

จากนั้นสั่งรัน Server ผ่าน Terminal ด้วยคำสั่ง

./gradlew run

เราก็จะได้ server ของเราที่รันผ่าน command line ด้วยการคอนฟิคค่า application.conf แล้ว

หรืออีกวิธีสำหรับคนขี้เกียจใช้คำสั่ง ก็สามารถรันผ่าน Intelij IDEA ได้เหมือนเดิม เพียงแต่ต้องไป Edit Configuration ให้มันใหม่

Edit Configuration

โดยปรับ

  • Main class เป็น org.jetbrains.ktor.netty.DevelopmentHost
  • Working Directory : ตำแหน่งของโปรเจ็คเรา
  • Use classpath of module : ชื่อโฟลเดอร์ ตามด้วย _main

จะได้ดังรูป

Edit Configuration

สุดท้าย ปรับ Application.module() เป็น Application.main() และทำการลบ fun main() ทิ้ง โดยเราจะไม่ใช้ embededServer แล้ว จะใช้การ config จากไฟล์ application.conf และตัว Host เป็น Netty/Jetty แทน

โดยการเพิ่มตรงนี้ลงไปที่ไฟล์ build.gradle

apply plugin: 'application'

mainClassName = 'org.jetbrains.ktor.netty.DevelopmentHost'

ทดลองรัน

./gradlew run

ก็ควรจะได้ผลลัพธ์เช่นเดิม

Step 5 : Create RESTful API

ต่อมา มาถึงการทำ API กันแล้ว ซึ่งในตัวอย่าง ผมจะทำการทำแค่ 5 routes นะครับ คือ

  • GET /api/movies : ดึงรายชื่อหนังทั้งหมด
  • GET /api/movies/{id} : ดึงข้อมูลหนังจาก id
  • POST /api/movies : ทำการข้อมูลเพิ่มหนัง
  • PUT /api/movies/{id} : ทำการอัพเดทข้อมูลหนังจาก id
  • DELETE /api/movies/{id} : ทำการลบข้อมูลหนังโดยระบุ id

ซึ่งใน ktor เราสามารถกำหนด routing ได้เป็นแบบนี้

application.install(Routing) {
    get("/api/movies") {
        call.respond(movies)
    }

    get("/api/movies/{id}") {
        val id = call.parameters["id"]
        call.respond(movies[id])
    }

    post("/api/movies") {
        call.respond(data)
    }

    put("/api/movies/{id}") {
        call.respond(message)
    }

    delete("/api/movies/{id}") {
        call.respond(message)
    }
}

ซึ่งด้านบนเป็นแค่ตัวอย่างคร่าวๆ ยังไม่สามารถใช้งานได้จริงนะครับ เช่น ไม่มีข้อมูล data movies message เป็นต้น

จะเห็นได้ว่าตัว ktor มีฟังค์ชันสำหรับทำ GET, POST, PUT, DELETE ตามชื่อของ HTTP Method เลย ซึ่งหากใครเคยเขียน Express, Sinatra ก็จะให้ความรู้สึกคล้ายๆกัน

และก็การ mapping path parameter ตัว ktor จะใช้ {name} ในการกำหนด dynamic path เช่น กำหนด /api/users/{name} ทั้ง /api/users/john และ /api/users/jane สามารถเรียกผ่าน call.parameter["name"] จะได้ John และ Jane ตามลำดับ

  • call.respond() : สามารถส่งข้อมูลเป็น JSON ได้ หากเราทำการส่งค่า Map, Set ซึ่งตัว ktor จะมี built-in gson จัดการตรงนี้ให้แล้ว (ตัว dependencies ที่ลงตอนแรกเลย)

ที่ไฟล์ Main.kt ทำการเพิ่ม routing สำหรับ api/movies ลงไป

data class Movie(val id: String, val name: String, val imageUrl: String, val overview: String)

fun Application.main() {

    val data = initData()

    install(DefaultHeaders)
    install(CallLogging)
    install(Routing) {
        get("/api/movies") {
            call.respond(data)
        }
    }
}

fun initData(): List<Movie> {
    return listOf(
            Movie("1", "Minions", "http://image.tmdb.org/t/p/w780/uX7LXnsC7bZJZjn048UCOwkPXWJ.jpg", "Minions Stuart, Kevin and Bob are recruited by Scarlet Overkill, a super-villain who, alongside her inventor husband Herb, hatches a plot to take over the world."),
            Movie("2", "Beauty and the Beast", "http://image.tmdb.org/t/p/w780/6aUWe0GSl69wMTSWWexsorMIvwU.jpg", "A live-action adaptation of Disney's version of the classic 'Beauty and the Beast' tale of a cursed prince and a beautiful young woman who helps him break the spell."),
            Movie("3", "Spider-Man: Homecoming", "http://image.tmdb.org/t/p/w780/vc8bCGjdVp0UbMNLzHnHSLRbBWQ.jpg", "Following the events of Captain America: Civil War, Peter Parker, with the help of his mentor Tony Stark, tries to balance his life as an ordinary high school student in Queens, New York City, with fighting crime as his superhero alter ego Spider-Man as a new threat, the Vulture, emerges."),
            Movie("4", "Guardians of the Galaxy Vol. 2", "http://image.tmdb.org/t/p/w780/vc8bCGjdVp0UbMNLzHnHSLRbBWQ.jpg", "The Guardians must fight to keep their newfound family together as they unravel the mysteries of Peter Quill's true parentage."),
            Movie("5", "Despicable Me 3", "http://image.tmdb.org/t/p/w780/puV2PFq42VQPItaygizgag8jrXa.jpg", "Gru and his wife Lucy must stop former '80s child star Balthazar Bratt from achieving world domination."),
            Movie("6", "War for the Planet of the Apes", "http://image.tmdb.org/t/p/w780/ulMscezy9YX0bhknvJbZoUgQxO5.jpg", "Caesar and his apes are forced into a deadly conflict with an army of humans led by a ruthless Colonel. After the apes suffer unimaginable losses, Caesar wrestles with his darker instincts and begins his own mythic quest to avenge his kind. As the journey finally brings them face to face, Caesar and the Colonel are pitted against each other in an epic battle that will determine the fate of both their species and the future of the planet."),
            Movie("7", "Annabelle: Creation", "http://image.tmdb.org/t/p/w780/o8u0NyEigCEaZHBdCYTRfXR8U4i.jpg", "Several years after the tragic death of their little girl, a dollmaker and his wife welcome a nun and several girls from a shuttered orphanage into their home, soon becoming the target of the dollmaker's possessed creation, Annabelle."),
            Movie("8", "Dunkirk", "http://image.tmdb.org/t/p/w780/fudEG1VUWuOqleXv6NwCExK0VLy.jpg", "Miraculous evacuation of Allied soldiers from Belgium, Britain, Canada, and France, who were cut off and surrounded by the German army from the beaches and harbor of Dunkirk, France, between May 26 and June 04, 1940, during Battle of France in World War II"),
            Movie("9", "Deadpool", "http://image.tmdb.org/t/p/w780/n1y094tVDFATSzkTnFxoGZ1qNsG.jpg", "Deadpool tells the origin story of former Special Forces operative turned mercenary Wade Wilson, who after being subjected to a rogue experiment that leaves him with accelerated healing powers, adopts the alter ego Deadpool. Armed with his new abilities and a dark, twisted sense of humor, Deadpool hunts down the man who nearly destroyed his life."),
            Movie("10", "Guardians of the Galaxy", "http://image.tmdb.org/t/p/w780/bHarw8xrmQeqf3t8HpuMY7zoK4x.jpg", "Light years from Earth, 26 years after being abducted, Peter Quill finds himself the prime target of a manhunt after discovering an orb wanted by Ronan the Accuser.")
    )
}

ซึ่งด่านบนจะเห็นได้ว่า ผมมี

  • Movie : data class สำหรับเอาไว้เก็บข้อมูล Movie
  • initData() : สำหรับ mock ข้อมูลขึ้นมาจาก model Movie เป็น List
  • call.respond(data) : Response ก้อนข้อมูล Movie กลับไป

ทีนี้ลองสั่งรัน แล้วดูผลลัพท์

java.lang.IllegalArgumentException: Response pipeline couldn't transform 'class java.util.Arrays$ArrayList' to the FinalContent

เนื่องจากว่าเราไม่สามารถแปลง ArrayList ไปเป็น FinalContent ได้ วิธีแก้ไขก็คือ ใช้ Gson ที่เราได้ install มาก่อนหน้านี้

เพิ่มเข้ามาใน Application ผ่าน install() แบบนี้

import org.jetbrains.ktor.gson.GsonSupport


fun Application.main() {

    val data = initData()

    install(GsonSupport)
    ...
    ...
}

แล้วลองรันใหม่ ผลลัพธ์จะได้แบบนี้

Movies JSON

ภาพด้านบนผมใช้ Chrome Extensions : JSON Formatter นะครับ

ต่อมาเพิ่ม routing สำหรับ /api/movies/{id} ดังนี้

get("/api/movies/{id}") {
    val id = call.parameters["id"]
    call.respond(data.filter { it.id == id }[0])
}
  • call.parameters["id"] : Get ค่าจาก path เพื่อเอามาหาข้อมูลใน data
  • data.filter {} : เป็นการ filter เพื่อ return id ที่ตรงกับ path

ส่วนของ PUT, POST, DELETE ผมทำแค่ส่ง response กลับไปเท่านั้น แบบนี้

data class ResponseMessage(val message: String)


post("/api/movies") {
    val movie = call.receive<Movie>()
    call.respond(ResponseMessage("$movie has been created!"))
}

put("/api/movies/{id}") {
    val id = call.parameters["id"]
    call.respond(ResponseMessage("$id has been updated!"))
}

delete("/api/movies/{id}") {
    val id = call.parameters["id"]
    call.respond(ResponseMessage("$id has been deleted!"))
}
  • โดยเพิ่ม data class ResponseMessage ขึ้นมาเอาไว้ response กลับเฉยๆ
  • call.recieve<ClassName> : เอาไว้สำหรับรับค่า data payload จาก POST method

หมดแล้ว ตอนนี้เราก็ได้ API Server แบบง่ายๆกันแล้ว ต่อไปก็ลองมาทดสอบกันดูครับ

Step 6 : Run & test web server

หลังจากได้ Server แล้วตอนนี้ก็ถึงเวลาทดสอบ ซึ่งผมจะใช้ POSTMAN ในการทดสอบ เริ่มจาก

GET /api/movies

GET

POST /api/movies

POST

GET /api/movies/{id}

Find by ID

PUT /api/movies/{id}

PUT

DELETE /api/movies/{id}

DELETE

ได้ผลลัพธ์ทั้งหมดตามที่ต้องการ \m/

Step 7 : Deployment

มาถึงขั้นตอนสุด การ Deploy เราอาจจะไม่สามารถใช้ Hosting ทั่วๆไปได้ เนื่องจากมันต้องมีการรันพวก Web Server, Tomcat, Jetty หรืออะไรก็แล้วแต่ ซึ่งในตัวอย่างนี้ผมแนะนำ Heroku ละกัน ซึ่งมันน่าจะง่ายสุดแล้ว

ใครไม่รู้จัก Heroku ลองไปดูพวก Getting Started แล้วลองโหลด CLI ของ Heroku มาลองเล่นดูครับ ส่วนนี้ขอข้ามละกันเนอะ ไม่เกี่ยวกับบทความนี้ :)

การ Deploy แน่นอน เราจะใช้คำสั่ง

./gradlew run

เริ่มต้น ล็อคอินเข้า heroku ผ่าน command line

heroku login

ใส่ email & password ถ้าล็อคอินเสร็จ จะขึ้นว่า Logged in as xxx

ต่อมาสั่งให้มันสร้างโปรเจ็คของ Heroku ซึ่งโปรเจ็คเราจะต้องเป็น git ซะก่อน

git init

สร้างโปรเจ็คด้วยคำสั่ง

heroku create

แต่ถ้าใครสร้าง App บนหน้า Dashboard ของ Heroku ก็สามารถชี้ไปที่ App นั้นได้ด้วยคำสั่ง heroku git:remote -a NAME_OF_YOUR_APP

ต่อมาเปลี่ยน gradle task ที่จะให้มันรันตอน push โค๊ดขึ้น heroku เป็น build ซะ รวมถึง Port ของ Heroku

heroku config:set GRADLE_TASK="build"
heroku config:set PORT=9000

ต่อมาที่ไฟล์​ application.conf แก้ไขให้ใช้ Port จาก variables ของ Heroku

ktor {
    deployment {
        port = ${PORT}
    }

    application {
        modules = [ com.devahoy.kotlinserver.MainKt.main ]
    }
}

สุดท้ายสร้างไฟล์ Procfile ไว้ที่ root folder ตัวนี้จะเป็นคล้ายๆ configuration file ของ Heroku ซึ่งเราก็จะสั่งให้มันรัน gradle นั่นเอง

web: ./gradlew run

ปิดท้ายด้วย push git ไปที่ heroku

git add .
git commit -m "update your task blah blah"

git push heroku master

ถ้าเกิดว่า Deploy ผ่าน จะได้ผลลัพธ์ดังนี้

remote: -----> Compressing...
remote:        Done: 64.2M
remote: -----> Launching...
remote:        Released v4
remote:        https://your-app-name-12345.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.

To https://git.heroku.com/your-app-name-12345.git

ทดสอบเข้าเว็บด้วย URL ด้านบน https://your-app-name-12345.herokuapp.com/ ก็จะเห็น API ของเรา เป็นอันเรียบร้อย (ตรง your-app-name มันคือชื่อ heroku app นะ) สุดท้าย Source Code บน Github สามารถไปดูเพิ่มเติมได้ครับ

Source Code

Reference

Chai Chai Phonbopit : Web Developer @Nimbl3 • ผู้ชายธรรมดาๆ ที่ชื่นชอบ Node.js, JavaScript และ Open Source มีงานอดิเรกเป็น Acoustic Guitar และ Football

บทความล่าสุด