§ 導入
数年前までは、フルスタック Web アプリケーションフレームワークの Rails が流行していました。しかし、最近では React.js や Vue.js などのフロントエンドのフレームワークが進化したため、バックエンドとフロントエンドを分離し、別の言語で開発するケースが増えています。
ただし、フルスタックが悪かというとそうではなく、メリットもたくさんあります。フロントエンドとバックエンドで同一言語を使うことにより生産性が上がりますし、ビジネスロジックを共有することもできます。
では、最新のフロントエンドフレームワークを使える状態でWeb アプリケーションをフルスタック化するためにはどうすれば良いでしょうか?
第一の選択肢は、バックエンドで Node.js を使う方法です。JavaScript/TypeScript が好きならこれでもいいかもしれません。
第二の選択肢は、フロントエンドに Kotlin/JS、バックエンドに Kotlin/JVM を使う方法です。今回はその方法を説明していきます。
§ 実装編
これから作るもの
- TODOリストを作ります。
- npmの react-js-pagination を使用して、ページネーションも実装します。
環境
- 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
- 最終的に AWS Fargate にデプロイするため corretto を選択。
- Java11 からコンテナ実行時に便利なオプションが追加されているため、11 以降にした方がよいです。
- この状態でプロジェクトを実行すると「LOG4J」のエラーが発生するので、それを防ぐために以下のコードを追加します。
val jvmMain by getting {
dependencies {
...
implementation("org.slf4j:slf4j-log4j12:1.7.21")
}
}
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 をクリックして実行します。
- http://localhost:8080 を開くと以下の画面が表示されるはずです。
ホットリロード対応
続いて、フロントエンドのソースコードを変更した後に、自動的にリロード(ホットリロード)されるように設定します。
まず、フロントエンドを単独で起動した場合に使用する index.html ファイルを作成します。
(bootstrap5 を使用する設定を追加しています)
<!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 を使うように設定します。
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)
フロントエンドは、ターミナルから以下のコマンドを実行することで、ホットリロード状態で起動します。(Port:8081)
$ ./gradlew jsRun -h
client.kt のコードを一部変更してみてください。自動でビルドが実行され、ブラウザがリロードされることが確認できます。
TODO アプリの実装
ソースコードは全てgithubにアップしています。
ここでは重要な部分だけをピックアップして紹介します。
バックエンド
exposedライブラリを使用したテーブルとDAOの定義
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エンドポイントの実装
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ライブラリの型情報を定義
@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クライアント
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で実装
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タスクを追加します。
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を作ります。
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 がお勧めです。デプロイ方法はこの記事では説明しませんが、以下の開発者ガイドの通り進めればデプロイできると思います。
一つだけ注意することは、fargateのタスク定義でメモリ制限の値を必ず指定するようにしてください。(参考)
これを忘れるとガベージコレクションが実行されないので、最終的にOutOfMemoryエラーが発生してサーバが落ちてしまいます。
§ 参考
- Building a Full Stack Web App with Kotlin Multiplatform
- Building Web Applications with React and Kotlin/JS
- Kotlin/JS よくある質問
§ Tips
複数のリクエストを同時に捌く方法
newSuspendedTransaction(Dispatchers.IO) {} のブロック内でDBアクセスなどの時間がかかる処理を書くと、複数のリクエストを同時に捌くことができます。
サンプルコードではdbQueryメソッドでラップしています。