LoginSignup
19
16

More than 1 year has passed since last update.

KotlinでフルスタックWebアプリケーションを作ってデプロイするまで

Last updated at Posted at 2020-12-20

§ 導入

数年前までは、フルスタック Web アプリケーションフレームワークの Rails が流行していました。しかし、最近では React.js や Vue.js などのフロントエンドのフレームワークが進化したため、バックエンドとフロントエンドを分離し、別の言語で開発するケースが増えています。

ただし、フルスタックが悪かというとそうではなく、メリットもたくさんあります。フロントエンドとバックエンドで同一言語を使うことにより生産性が上がりますし、ビジネスロジックを共有することもできます。

では、最新のフロントエンドフレームワークを使える状態でWeb アプリケーションをフルスタック化するためにはどうすれば良いでしょうか?
第一の選択肢は、バックエンドで Node.js を使う方法です。JavaScript/TypeScript が好きならこれでもいいかもしれません。
第二の選択肢は、フロントエンドに Kotlin/JS、バックエンドに Kotlin/JVM を使う方法です。今回はその方法を説明していきます。

§ 実装編

これから作るもの

  • TODOリストを作ります。
  • npmの react-js-pagination を使用して、ページネーションも実装します。
    todolist.png

環境

  • macOS Big Sur

使用するアプリケーション

使用するライブラリ

  • Ktor - Webアプリケーションフレームワーク
  • exposed - ORMフレームワーク
  • kotlin-react - react.js の Kotlin wrapper

プロジェクト作成

  • IntelliJ IDEA を起動し、「New Project」ボタンをクリック。
  • 以下の設定でプロジェクトを作成します。
  • Project Template: Full-Stack Web Application
  • Project JDK: corretto-11

1-newproject.png

  • この状態でプロジェクトを実行すると「LOG4J」のエラーが発生するので、それを防ぐために以下のコードを追加します。
build.gradle.kts
        val jvmMain by getting {
            dependencies {
                ...
                implementation("org.slf4j:slf4j-log4j12:1.7.21")
            }
        }
src/jvmMain/resources/log4j.properties
log4j.rootLogger=DEBUG, console
log4j.logger.xxx=DEBUG, console

log4j.appender.console=org.apache.log4j.ConsoleAppender
log4j.appender.console.layout=org.apache.log4j.PatternLayout
log4j.appender.console.layout.ConversionPattern=%d [%-5p-%c] %m%n
  • gradle メニューから Tasks > application > run をクリックして実行します。

2-run.png

スクリーンショット 2020-12-21 0.07.22.png

ホットリロード対応

続いて、フロントエンドのソースコードを変更した後に、自動的にリロード(ホットリロード)されるように設定します。

まず、フロントエンドを単独で起動した場合に使用する index.html ファイルを作成します。
(bootstrap5 を使用する設定を追加しています)

src/commonMain/resources/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta content="width=device-width, initial-scale=1" name="viewport">

    <link href="https://v5.getbootstrap.jp/docs/5.0/examples/dashboard/" rel="canonical">

    <!-- Bootstrap core CSS -->
    <link crossorigin="anonymous" href="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css"
          integrity="sha384-r4NyP46KrjDleawBgD5tp8Y7UzmLA05oM1iAEQ17CSuDqnUK2+k9luXQOfXJCJ4I" rel="stylesheet">

    <meta content="#7952b3" name="theme-color">
    <title>TODO</title>
</head>
<body>
<div id="root"></div>
<script crossorigin="anonymous"
        integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
        src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"></script>
<script crossorigin="anonymous"
        integrity="sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/"
        src="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js"></script>
<script src="kotlin-fullstack-web-app-sample.js"></script>
</body>
</html>

注: kotlin-fullstack-web-app-sample.js の部分にはプロジェクト名を指定する必要があります。

続いて、バックエンドで使用する HTML も bootstrap5 を使うように設定します。

src/jvmMain/kotlin/server.kt
fun HTML.index() {
    head {
        title("TODO")
        meta {
            charset = "UTF-8"
        }
        meta {
            name = "viewport"
            content = "width=device-width, initial-scale=1"
        }
        link(href = "https://v5.getbootstrap.jp/docs/5.0/examples/dashboard/", rel = "canonical")

        // Bootstrap core CSS
        link(
            href = "https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css",
            rel = "stylesheet"
        ) {
            attributes["crossorigin"] = "anonymous"
            integrity = "sha384-r4NyP46KrjDleawBgD5tp8Y7UzmLA05oM1iAEQ17CSuDqnUK2+k9luXQOfXJCJ4I"
        }
    }
    body {
        div {
            id = "root"
        }
        script(src = "https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js") {
            attributes["crossorigin"] = "anonymous"
            integrity = "sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
        }
        script(src = "https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/js/bootstrap.min.js") {
            attributes["crossorigin"] = "anonymous"
            integrity = "sha384-oesi62hOLfzrys4LxRF63OJCXdXDipiYWBnvTl9Y9/TRlw5xlKIEHpNyvvDShgf/"
        }
        script(src = "/static/output.js") {}
    }
}

(フロントエンドとバックエンドでそれぞれindex.htmlの設定をするのはイマイチなのですが、回避方法がわかっていません。)

サーバの起動

バックエンドは server.kt ファイルの mainメソッドの▷をクリックし、「Run 'ServerKt'」を実行することで起動します。(Port:8080)
スクリーンショット 2020-12-21 4.01.03.png

フロントエンドは、ターミナルから以下のコマンドを実行することで、ホットリロード状態で起動します。(Port:8081)

$ ./gradlew jsRun -h

client.kt のコードを一部変更してみてください。自動でビルドが実行され、ブラウザがリロードされることが確認できます。

TODO アプリの実装

ソースコードは全てgithubにアップしています。
ここでは重要な部分だけをピックアップして紹介します。

バックエンド

exposedライブラリを使用したテーブルとDAOの定義

src/jvmMain/kotlin/com/github/kyamada/sample/database/Tasks.kt

object Tasks : IntIdTable("tasks") {
    val title = varchar("title", 255)
    val createdAt = datetime("created_at").defaultExpression(CurrentDateTime())
    val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime())
}

class Task(id: EntityID<Int>) : IntEntity(id) {
    var title by Tasks.title
    var createdAt by Tasks.createdAt
    var updatedAt by Tasks.updatedAt

    fun toData(): TaskData {
        return TaskData(
            id = id.value,
            title = title,
            createdAt = createdAt.millis,
            updatedAt = updatedAt.millis
        )
    }

    companion object : IntEntityClass<Task>(Tasks)
}

APIエンドポイントの実装

src/jvmMain/kotlin/com/github/kyamada/sample/route/TasksRoute.kt
fun Route.tasks() {
    route("/tasks") {
        get("/") {
            val pageInfo = PageInfo.fromQueryParameters(call.request.queryParameters)
            var totalCount: Long = 0
            val tasks = dbQuery {
                val query = Tasks.selectAll()
                totalCount = query.count()
                query.orderBy(Tasks.id to SortOrder.DESC)
                    .limit(pageInfo.perPage, offset = pageInfo.offset)
                    .toList().map {
                        Task.wrapRow(it).toData()
                    }
            }
            val response = TasksResponse(tasks, totalCount)
            call.respond(response)
        }

        post("/") {
            val newTask = call.receive<TaskData>()
            val task = dbQuery {
                Task.new {
                    title = newTask.title
                }.toData()
            }
            call.respond(task)
        }

        delete("/{id}") {
            val id = call.parameters["id"]?.toInt() ?: throw BadRequestException()
            dbQuery {
                val task = Task.findById(id) ?: throw NotFoundException()
                task.delete()
            }
            call.respond(HttpStatusCode.OK)
        }
    }
}

フロントエンド

ページネーションに使用するnpmライブラリの型情報を定義

src/jsMain/kotlin/lib/ReactJsPagination.kt
@file:JsModule("react-js-pagination")
@file:JsNonModule

import react.RClass
import react.RProps

@JsName("default")
external val reactJSPagination: RClass<ReactJSPaginationProps>

external interface ReactJSPaginationProps : RProps {
    var activePage: Int
    var itemsCountPerPage: Int
    var totalItemsCount: Int
    var pageRangeDisplayed: Int
    var itemClass: String
    var linkClass: String
    var onChange: (Int) -> Unit
}

APIクライアント

src/jsMain/kotlin/model/ApiClient.kt
object ApiClient {
    private val baseUrl: String
        get() {
            val origin = window.location.origin
            return if (origin.contains("localhost")) {
                "http://localhost:8080"
            } else {
                origin
            }
        }

    private val jsonClient = HttpClient {
        install(JsonFeature) { serializer = KotlinxSerializer() }
    }

    suspend fun getTasks(page: Int, perPage: Int, userId: Int? = null): TasksResponse {
        val query = "page=$page&per_page=$perPage"
        return jsonClient.get("$baseUrl/v1/tasks?$query")
    }

    suspend fun addTask(taskData: TaskData): TaskData {
        return jsonClient.post("$baseUrl/v1/tasks") {
            contentType(ContentType.Application.Json)
            body = taskData
        }
    }

    suspend fun deleteTask(id: Int) {
        jsonClient.delete<Unit>("$baseUrl/v1/tasks/$id")
    }
}

TODOリストのフロントエンドをReactで実装

src/jsMain/kotlin/component/Tasks.kt

interface TasksState : RState {
    var items: List<TaskData>
    var page: Int
    var totalCount: Long
    var text: String
}

class Tasks : RComponent<RProps, TasksState>() {
    override fun TasksState.init() {
        items = listOf()
        text = ""
        totalCount = 0
        MainScope().launch {
            setState {
                text = ""
            }
            fetchTasks(1)
        }
    }

    private suspend fun fetchTasks(page: Int) {
        val resp = ApiClient.getTasks(page, PER_PAGE)
        setState {
            this.page = page
            items = resp.tasks
            totalCount = resp.totalCount
        }
    }

    override fun RBuilder.render() {
        styledDiv {
            css {
                margin(10.pt)
                width = 400.pt
            }

            styledDiv {
                css {
                    classes = mutableListOf("input-group", "mb-3")
                    marginBottom = 10.pt
                }
                styledInput(type = InputType.text, name = "itemText") {
                    css {
                        classes = mutableListOf("form-control")
                    }
                    key = "itemText"
                    attrs {
                        value = state.text
                        placeholder = "Add a to-do item"
                        onChangeFunction = {
                            val target = it.target as HTMLInputElement
                            setState {
                                text = target.value
                            }
                        }
                    }
                }

                button(classes = "btn btn-primary") {
                    +"Add"
                    attrs {
                        onClickFunction = {
                            if (state.text.isNotEmpty()) {
                                val newTask = TaskData(-1, state.text, 0, 0)
                                MainScope().launch {
                                    val createdTask = ApiClient.addTask(newTask)
                                    setState {
                                        items += createdTask
                                        text = ""
                                    }
                                }
                            }
                        }
                    }
                }
            }

            styledUl {
                css {
                    classes = mutableListOf("list-group")
                    marginBottom = 10.pt
                }
                for (item in state.items) {
                    li("list-group-item") {
                        key = item.id.toString()
                        +item.title
                        styledButton(type = ButtonType.button) {
                            css {
                                classes = mutableListOf("btn", "btn-outline-danger", "btn-sm")
                                marginLeft = 10.pt
                            }
                            +"×"
                            attrs {
                                onClickFunction = {
                                    MainScope().launch {
                                        ApiClient.deleteTask(item.id)
                                        setState {
                                            items = items.filterNot { it.id == item.id }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            nav {
                reactJSPagination {
                    attrs.activePage = state.page
                    attrs.itemsCountPerPage = PER_PAGE
                    attrs.totalItemsCount = state.totalCount.toInt()
                    attrs.pageRangeDisplayed = 5
                    attrs.itemClass = "page-item"
                    attrs.linkClass = "page-link"
                    attrs.onChange = ::handlePageChange
                }
            }
        }
    }

    private fun handlePageChange(page: Int) {
        MainScope().launch {
            fetchTasks(page)
        }
    }

    companion object {
        private const val PER_PAGE = 5
    }
}

§ デプロイ編

fat JAR作成

Docker対応の前準備として fat JARを作成します。
fat Jarはすべての依存ライブラリを1つにまとめた単一のJARファイルで、 Javaを使ってスタンドアロンのアプリケーションとして起動することができます。

  • build.gradle.kts に shadowJarタスクを追加します。
build.gradle.kts
plugins {
    ...
    id("com.github.johnrengelman.shadow") version "4.0.4"
}

...

// This task will generate your fat JAR and put it in the ./build/libs/ directory
tasks.getByName<Jar>("shadowJar") {
    dependsOn(tasks.getByName("jsBrowserProductionWebpack"))
    val jsBrowserProductionWebpack = tasks.getByName<KotlinWebpack>("jsBrowserProductionWebpack")
    from(File(jsBrowserProductionWebpack.destinationDirectory, jsBrowserProductionWebpack.outputFileName))

    manifest {
        attributes(mapOf("Main-Class" to application.mainClassName))
    }
}
  • shadowJarタスクを実行し、fat Jarを作成します。
$ ./gradlew shadowJar
  • fat Jarを実行します。
$ java -jar ./build/libs/kotlin-fullstack-web-app-sample-1.0-SNAPSHOT.jar

Docker対応

  • Docker for Desktopアプリを起動します。
  • 以下のようにDockerfileを作ります。
FROM amazoncorretto:11-alpine

ENV APPLICATION_USER ktor
RUN adduser -D -g '' $APPLICATION_USER

RUN mkdir /app
RUN chown -R $APPLICATION_USER /app

USER $APPLICATION_USER

COPY ./build/libs/kotlin-fullstack-web-app-sample-1.0-SNAPSHOT.jar /app/fullstack.jar
WORKDIR /app

CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:MaxRAMPercentage=90", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-XshowSettings:vm", "-jar", "fullstack.jar"]
  • Dockerイメージを作成します。
$ docker build -t fullstack .
  • Dockerイメージを実行します。
$ docker run --env-file ./.env --env DB_HOSTNAME=host.docker.internal -m512M --cpus 2 -it -p 8080:8080 --rm fullstack

デプロイ先について

デプロイ先はAWS fargate がお勧めです。デプロイ方法はこの記事では説明しませんが、以下の開発者ガイドの通り進めればデプロイできると思います。

一つだけ注意することは、fargateのタスク定義でメモリ制限の値を必ず指定するようにしてください。(参考)
これを忘れるとガベージコレクションが実行されないので、最終的にOutOfMemoryエラーが発生してサーバが落ちてしまいます。
task2.png

§ 参考

§ Tips

複数のリクエストを同時に捌く方法

newSuspendedTransaction(Dispatchers.IO) {} のブロック内でDBアクセスなどの時間がかかる処理を書くと、複数のリクエストを同時に捌くことができます。
サンプルコードではdbQueryメソッドでラップしています。

19
16
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
16