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

# § 導入

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

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

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

# § 実装編

## これから作るもの

- TODOリストを作ります。
- npmの [react-js-pagination](https://www.npmjs.com/package/react-js-pagination) を使用して、ページネーションも実装します。
![todolist.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9875/c9ea6078-1dcc-8320-d906-ecc7db67784a.png)

## 環境

- macOS Big Sur

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

- [IntelliJ IDEA CE(コミュニティ版)](https://www.jetbrains.com/ja-jp/idea/download/#section=mac)
- [Docker Desktop for Mac](https://www.docker.com/get-started)
- mysql

## 使用するライブラリ

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

## プロジェクト作成

- IntelliJ IDEA を起動し、「New Project」ボタンをクリック。
- 以下の設定でプロジェクトを作成します。
 - Project Template: Full-Stack Web Application
 - Project JDK: corretto-11
     - 最終的に AWS Fargate にデプロイするため corretto を選択。
     - Java11 からコンテナ実行時に便利なオプションが追加されているため、11 以降にした方がよいです。
         - 参考: [JVM のヒープサイズとコンテナ時代のチューニング](https://i-beam.org/2019/08/15/jvm-heap-sizing/)

![1-newproject.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9875/33aff408-28a6-ba9a-ef93-24da6e73d1ef.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9875/0025671b-29ca-c99e-8444-d72fe9b60f7a.png)

- http://localhost:8080 を開くと以下の画面が表示されるはずです。

![スクリーンショット 2020-12-21 0.07.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9875/c71be12d-9711-56e5-5da8-c21ea5434273.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9875/64f3d4ad-3bb0-2586-2fc8-5cd4fdde127e.png)

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

```
$ ./gradlew jsRun -h
```

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

## TODO アプリの実装

ソースコードは全て[github](https://github.com/k-yamada/kotlin-fullstack-web-app-sample)にアップしています。
ここでは重要な部分だけをピックアップして紹介します。

### バックエンド

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](https://jp.ktor.work/servers/deploy/packing/fatjar.html)を作成します。
fat Jarはすべての依存ライブラリを１つにまとめた単一の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
```

- ブラウザで http://localhost:8080/ を開き、アプリが起動すれば成功です。

## Docker対応

- Docker for Desktopアプリを起動します。
- 以下のようにDockerfileを作ります。

```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
```

- ブラウザで http://localhost:8080/ を開き、アプリが起動すれば成功です。

## デプロイ先について

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

- [Amazon Elastic Container Service 開発者ガイド](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/Welcome.html)

一つだけ注意することは、fargateのタスク定義でメモリ制限の値を必ず指定するようにしてください。([参考](https://qiita.com/k-yamada-github/items/9e874cc2895644c0a6da#fargate%E3%81%AE%E3%82%BF%E3%82%B9%E3%82%AF%E5%AE%9A%E7%BE%A9))
これを忘れるとガベージコレクションが実行されないので、最終的にOutOfMemoryエラーが発生してサーバが落ちてしまいます。
![task2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9875/f6682bee-12d0-9116-f223-7c78303ff5c8.png)


# § 参考

- [Building a Full Stack Web App with Kotlin Multiplatform](https://play.kotlinlang.org/hands-on/Full%20Stack%20Web%20App%20with%20Kotlin%20Multiplatform/01_Introduction)
- [Building Web Applications with React and Kotlin/JS](https://play.kotlinlang.org/hands-on/Building%20Web%20Applications%20with%20React%20and%20Kotlin%20JS/01_Introduction)
- [Kotlin/JS よくある質問](https://qiita.com/k-yamada-github/items/5a33cc08768ba2a9e417)


# § Tips

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

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

https://github.com/k-yamada/kotlin-fullstack-web-app-sample/blob/master/src/jvmMain/kotlin/com/github/kyamada/sample/database/DatabaseFactory.kt#L46-L54