先月から Tagbangers にジョインした生粋のハマっ子こと JK です(元インターン生)
私の好きなプログラミング言語の1つは Kotlin です
Kotlin は JetBrains 社が開発した言語で主に Android アプリ開発で使用できる言語として有名です
JK ≒ JetBrains Kotlin
今回はそんな Kotlin を用いて簡単な Web アプリを1から作成していこうと思います!
GitHub
今回作成するプロジェクトの完成形は下記のリポジトリにプッシュしています
koyama-tagbangers/kotlin-react-spring-sample
What's the advantage of Kotlin
Kotlin の入門動画は下記がおすすめです
Java に触った経験のある方への説明となっていますが、非常にわかりやすいです
Kotlin は Java をより使いやすくした言語という印象で、非常に多くの便利な機能を持っています
特筆すべき点は Java の低いバージョン(最低で Java 6)でも最新の Kotlin の機能が使用でき、既存の Java プロジェクトに非常に組み込みやすいことです
これにより、古くから Java で開発が続いているプロジェクトでも Kotlin を用いて拡張ができたり、あるいは完全に Kotlin に移行することが可能です
そして最近では JVM の垣根を超え、JavaScript や マルチプラットフォームをターゲットとしたオールラウンダーな開発が行える言語としてより注目を浴びています
Project Overview
今回は簡単な TODO アプリを作成します
- [機能] タスクの追加、削除、完了チェック更新
- サーバはデータベースは持たず、インメモリにデータを保存
下記のフレームワークを使用します
フレームワーク | 通常主に使用する言語 | Kotlin Target |
Spring Boot | Java | Kotlin/JVM |
React | JavaScript (TypeScript) | Kotlin/JS |
いずれも Kotlin を使用した開発が可能です
今回は Gradle を使用してアプリの開発を1から行います
(Maven を用いても開発が可能ですが、後述の Kotlin Gradle DSL を使用したいので今回は Gradle を使用します!)
Step0: Install development tools
最初に JDK, Gradle のインストールを行います
JDK のバージョンは幾つでも問題ありません
今回は下記のバージョンで開発を行っています
$ java -version openjdk version "11.0.9" 2020-10-20 OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.9+11) OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.9+11, mixed mode) $ gradle --version Welcome to Gradle 6.7! Here are the highlights of this release: - File system watching is ready for production use - Declare the version of Java your build requires - Java 15 support For more details see https://docs.gradle.org/6.7/release-notes.html ------------------------------------------------------------ Gradle 6.7 ------------------------------------------------------------ Build time: 2020-10-14 16:13:12 UTC Revision: 312ba9e0f4f8a02d01854d1ed743b79ed996dfd3 Kotlin: 1.3.72 Groovy: 2.5.12 Ant: Apache Ant(TM) version 1.10.8 compiled on May 10 2020 JVM: 11.0.9 (AdoptOpenJDK 11.0.9+11) OS: Mac OS X 10.15.7 x86_64
Step1: Setup Gradle project
それでは開発の下地を作ります
開発用の空ディレクトリを作成し、gradle init コマンドで初期化を行います
$ gradle init Starting a Gradle Daemon (subsequent builds will be faster) Select type of project to generate: 1: basic 2: application 3: library 4: Gradle plugin Enter selection (default: basic) [1..4] 1 Select build script DSL: 1: Groovy 2: Kotlin Enter selection (default: Groovy) [1..2] 2 Project name (default: kotlin-todo-app): kotlin-todo-app > Task :init Get more help with your project: Learn more about Gradle by exploring our samples at https://docs.gradle.org/6.7/samples BUILD SUCCESSFUL in 33s 2 actionable tasks: 2 executed
Gradle プロジェクトでは通常 Groovy を用いて設定を行いますが、ここでも代わりに Kotlin を使用することが可能です(正確には Gradle Kotlin DSL と呼ばれます)
成功すると下記の様な Gradle プロジェクトが展開されます
拡張子が gradle.kts のファイルは Gradle Kotlin DSL 用のファイルです
今回はさらにこの中にフロントエンド用及びバックエンド用のサブプロジェクトを用意します
プロジェクト直下に frontend と backend という名前のフォルダを作成して build.gradle.kts ファイルを作成します
そして settings.gradle.kts にサブプロジェクトの情報を追加します
rootProject.name = "kotlin-todo-app" include("backend", "frontend")
これで準備は完了です
Step2: Implement backend application
今回は Spring Data REST を用いて簡潔なコードで API を実装します
データベースはインメモリの H2 Database を使用します
backend/build.gradle.kts に下記を入力します
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("org.springframework.boot") version "2.3.5.RELEASE" id("io.spring.dependency-management") version "1.0.10.RELEASE" kotlin("jvm") version "1.4.10" kotlin("plugin.spring") version "1.4.10" } repositories { mavenCentral() } dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-rest") runtimeOnly("com.h2database:h2") implementation("org.springframework.boot:spring-boot-devtools") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") } tasks.withType<KotlinCompile> { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") jvmTarget = "11" } }
plugins の kotlin("jvm") で JVM ターゲットの Kotlin を選択します
下部の kotlinOptions の jvmTarget はインストールした JDK のバージョンに合わせます(JDK 8 を使用する場合は 1.8)
次に backend/src/main/kotlin/application ディレクトリを作成して直下に Main.kt ファイルを作成します
Main.kt に下記を入力します
package application import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication class TodoApplication fun main(args: Array<String>) { runApplication<TodoApplication>(*args) }
これは Spring Boot の最小の起動コードですが Java と違ってエントリーポイントの関数の書き方が異なっています (psvm less!)
アプリケーションの起動を行います
プロジェクト直下で下記のコマンドを入力します
./gradlew :backend:bootRun
開発用のローカルサーバーが 8080 ポートで起動します
curl コマンドでリクエストを送り、下記の様に帰ってくれば成功です
$ curl localhost:8080 { "_links" : { "profile" : { "href" : "http://localhost:8080/profile" } } }
次に API の実装を行います API の設計は次の通りです
- GET /api/todo -> Todo リストを返す
- POST /api/todo -> Todo を追加
- PUT /api/todo/{id} -> {id} に該当する Todo を上書き更新
- DELETE /api/todo/{id} -> {id} に該当する Todo を削除
Todo が持つ情報は次の通りです
情報 | 型 |
ID | 数値 |
タスク名 | 文字列 |
完了済みかどうか | 真偽値 |
これらを実装するために Main.kt の層に Api.kt ファイルを追加して、下記を入力します
package application import org.springframework.data.repository.CrudRepository import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* import javax.persistence.Entity import javax.persistence.GeneratedValue import javax.persistence.GenerationType import javax.persistence.Id @Entity data class Todo( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, val content: String = "", val done: Boolean = false ) interface TodoRepository : CrudRepository<Todo, Long> @RestController @RequestMapping("/api/todo") class TodoController(private val repository: TodoRepository) { @GetMapping fun findAll() = repository.findAll() @PostMapping @ResponseStatus(HttpStatus.CREATED) fun save(@RequestBody todo: Todo) = repository.save(todo) @PutMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) fun update(@RequestBody todo: Todo, @PathVariable id: Long) = repository.findById(id).ifPresent { repository.save(todo.copy(id = id)) } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) fun deleteById(@PathVariable id: Long) = repository.deleteById(id) }
Spring Data REST と Kotlin の強力な機能の組み合わせで、たった 42 行で実装できました(細かいバリデーション等は今回省略します)
Kotlin では1ファイルに複数クラス・インターフェースを定義できるので、ファイルの大量生成をある程度防げます
data class Todo の箇所は Kotlin の Data Class を使用することで Java Beans のメソッドやの比較メソッドなどを自動生成をしています
後述の TodoController では repository.save(todo.copy(id = id)) の部分で、自動生成された copy メソッドを用いています
class TodoController では Kotlin のプライマリコンストラクタを使用してメンバ変数を定義しています
また、各 API 関数の実装は代入演算子を用いて簡潔に記述することができます(ラムダ記法に似ています)
最後に確認用のモックデータを記述します
backend/src/main/resources ディレクトリを作成して、data.sql ファイルを作成し下記を入力します
INSERT INTO todo (content, done) VALUES ('Task1', false), ('Task2', true);
再度アプリケーションを起動して curl コマンドなどで /api/todo に対する操作ができることを確認します
$ curl localhost:8080/api/todo [{"id":1,"content":"Task1","done":false},{"id":2,"content":"Task2","done":true}] $curl -XPOST -H 'Content-Type: application/json' localhost:8080/api/todo -d '{"content": "Task3"}' {"id":3,"content":"Task3","done":false} $ curl -XPUT -H 'Content-Type: application/json' localhost:8080/api/todo/1 -d '{"done": true}' $ curl -XDELETE -H 'Content-Type: application/json' localhost:8080/api/todo/2 $ curl localhost:8080/api/todo [{"id":1,"content":"","done":true},{"id":3,"content":"Task3","done":false}]⏎
Step3: Implement frontend application
次にフロントエンド側の実装を行います
Kotlin では React のラッパーパッケージが用意されており、スムーズに開発することが可能です
また、公式の React を使用したチュートリアルも用意されています
Building Web Applications with React and Kotlin/JS
frontend/build.gradle.kts を作成して下記を入力します
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig plugins { kotlin("js") version "1.4.10" kotlin("plugin.serialization") version "1.4.10" } repositories { mavenCentral() jcenter() } dependencies { implementation(kotlin("stdlib-js")) implementation("org.jetbrains:kotlin-react:17.0.0-pre.129-kotlin-1.4.10") implementation("org.jetbrains:kotlin-react-dom:17.0.0-pre.129-kotlin-1.4.10") implementation(npm("react", "17.0.1")) implementation(npm("react-dom", "17.0.1")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:1.0-M1-1.4.0-rc") } kotlin { js { browser { runTask { devServer = KotlinWebpackConfig.DevServer( port = 3000, proxy = mapOf("/api" to mapOf("target" to "http://localhost:8080")), contentBase = listOf("$buildDir/processedResources/js/main") ) } } } }
バックエンドと異なり、kotlin("js") と記述することで、JS をターゲットとして Kotlin を使用できます
dependencies スコープに npm の記述が見られますが、JS をターゲットにした際は npm パッケージを用いることが可能です
Kotlin/JS では Webpack をラップしており、開発時は Webpack Dev Server を使用します
デフォルトのポート番号が 8080 ポートなのでバックエンド側と競合しない様にポート番号を 3000 に変更します
また、フロントエンド側の /api 以降のパスをバックエンドサーバーにフォワーディングするための設定も行っています
次に、エントリーポイントとなる index.html ファイルを frontend/src/main/resources に作成します
<!doctype html> <html> <head> <meta charset="UTF-8"> <title>React + Spring Todo App in React</title> </head> <body> <div id="root"></div> <script src="frontend.js"></script> </body> </html>
スクリプト名はデフォルトではプロジェクト名になるため、今回は frontend.js と指定しています
まずは API 通信を行わない、フロントエンドオンリーなコードを書きます
frontend/src/main/kotlin/Main.kt を作成して下記を入力します
import kotlinx.browser.document import kotlinx.html.InputType import kotlinx.html.js.onChangeFunction import kotlinx.html.js.onClickFunction import kotlinx.html.js.onSubmitFunction import org.w3c.dom.HTMLInputElement import react.RProps import react.child import react.dom.* import react.functionalComponent import react.useState data class Todo( var id: Long = 0, var content: String = "", var done: Boolean = false ) val mockTodoList = listOf(Todo(content = "Task1"), Todo(content = "Task2")) val app = functionalComponent<RProps> { val (todoList, setTodoList) = useState(mockTodoList) val (inputText, setInputText) = useState("") div { form { attrs { onSubmitFunction = { setTodoList(todoList.toMutableList().apply { add(Todo(content = inputText)) }) setInputText("") it.preventDefault() } } input { attrs { type = InputType.text value = inputText onChangeFunction = { setInputText((it.target as HTMLInputElement).value) } } } input { attrs { type = InputType.submit value = "Add" disabled = inputText.isBlank() } } } todoList.forEachIndexed { index, todo -> div { button { attrs { onClickFunction = { setTodoList(todoList.toMutableList().apply { this[index].done = !todo.done }) } } +"${if (todo.done) '✅' else '⬜'}" } +todo.content button { attrs { onClickFunction = { setTodoList(todoList.toMutableList().apply { removeAt(index) }) } } +"DELETE" } } } } } fun main() { render(document.getElementById("root")) { h1 { +"React + Spring Todo App in React" } child(app) } }
React の独特の JSX 記法を Kotlin の独特の DSL 記法で実現しています
div / button などの実態はラムダを引数とした関数です、そして "+" は演算子オーバーロードという機能を利用しています
// 冗長な書き方 div({ it -> it.childList.add("Hoge") }) // 上記の省略記法 div { +"Hoge" }
アトリビュートは attrs ブロックに記述します、ここは JSX 記法に比べて少しわかりづらいかもしれません
React をラップしたパッケージの恩恵のおかげで、関数コンポーネントや React Hook などの最新の機能も使用することができます!
それでは開発サーバを起動して動作を確認します
下記のコマンドで起動します(--continuous オプションをつけることでコードの変更時に自動的にリビルド&リロードが走ります)
./gradlew :frontend:run
タスクの追加・更新・削除が行えました!
それでは API 通信を含んだ実装を行います、ファイル内の規模が大きくなるのでの3つのファイルにわけて開発を行います
- [Main.kt] エントリーポイント、バックエンドとの API 通信を行う
- [Form.kt] テキストインプットを用いてタスクの追加が行えるコンポーネント
- [Card.kt] Todo リストの1タスクに相当するコンポーネント
それぞれ下記の様に実装を行います
Main.kt
import kotlinx.browser.document import kotlinx.browser.window import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.await import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import org.w3c.fetch.RequestInit import react.* import react.dom.div import react.dom.h1 import react.dom.render import kotlin.js.json @Serializable data class Todo( var id: Long = 0, var content: String = "", var done: Boolean = false ) val app = functionalComponent<RProps> { val (loading, setLoading) = useState(true) val (todoList, setTodoList) = useState(emptyList<Todo>()) val toggleSummit: (String, () -> Unit) -> Unit = { content, onSucceed -> GlobalScope.launch { try { setLoading(true) val data = window.fetch("/api/todo", object : RequestInit { override var method: String? = "POST" override var headers = json().apply { this["Content-Type"] = "application/json" } override var body = Json.encodeToString(Todo.serializer(), Todo(content = content)) }) .await() .text() .await() .let { Json.decodeFromString(Todo.serializer(), it) } setTodoList(todoList.toMutableList().apply { add(data) }) onSucceed() } catch (err: Throwable) { throw err } finally { setLoading(false) } } } val toggleHandle: (Int) -> () -> Unit = { index -> { GlobalScope.launch { try { val data = todoList[index].copy().apply { this.done = !this.done } setLoading(true) window.fetch("/api/todo/${data.id}", object : RequestInit { override var method: String? = "PUT" override var headers = json().apply { this["Content-Type"] = "application/json" } override var body = Json.encodeToString(Todo.serializer(), data) }) .await() .text() .await() setTodoList(todoList.toMutableList().apply { this[index] = data }) } catch (err: Throwable) { throw err } finally { setLoading(false) } } } } val toggleDelete: (Int) -> () -> Unit = { index -> { GlobalScope.launch { try { val data = todoList[index] setLoading(true) window.fetch("/api/todo/${data.id}", object : RequestInit { override var method: String? = "DELETE" }) .await() .text() .await() setTodoList(todoList.toMutableList().apply { remove(data) }) } catch (err: Throwable) { throw err } finally { setLoading(false) } } } } useEffect(emptyList()) { GlobalScope.launch { try { setLoading(true) val data = window.fetch("/api/todo") .await() .text() .await() .let { Json.decodeFromString(ListSerializer(Todo.serializer()), it) } setTodoList(data) } catch (err: Throwable) { throw err } finally { setLoading(false) } } } div { inputForm { this.loading = loading onSubmit = toggleSummit } todoList.forEachIndexed { index, todo -> todoCard { key = todo.id.toString() this.todo = todo this.loading = loading onToggle = toggleHandle(index) onDelete = toggleDelete(index) } } } } fun main() { render(document.getElementById("root")) { h1 { +"React + Spring Todo App in React" } child(app) } }
Form.kt
import kotlinx.html.InputType import kotlinx.html.js.onChangeFunction import kotlinx.html.js.onSubmitFunction import org.w3c.dom.HTMLInputElement import react.* import react.dom.form import react.dom.input interface FormProps : RProps { var loading: Boolean var onSubmit: (String, () -> Unit) -> Unit } val inputForm = functionalComponent<FormProps> { props -> val (inputText, setInputText) = useState("") form { attrs { onSubmitFunction = { props.onSubmit(inputText) { setInputText("") } it.preventDefault() } } input { attrs { type = InputType.text value = inputText disabled = props.loading onChangeFunction = { setInputText((it.target as HTMLInputElement).value) } } } input { attrs { type = InputType.submit value = "Add" disabled = inputText.isBlank() or props.loading } } } } fun RBuilder.inputForm(handler: FormProps.() -> Unit) = child(inputForm) { attrs { handler() } }
Card.kt
import kotlinx.html.js.onClickFunction import react.RBuilder import react.RProps import react.child import react.dom.button import react.dom.div import react.functionalComponent interface TodoProps : RProps { var todo: Todo var onToggle: () -> Unit var onDelete: () -> Unit var loading: Boolean } val todoCard = functionalComponent<TodoProps> { props -> div { button { attrs { disabled = props.loading onClickFunction = { props.onToggle() } } +"${if (props.todo.done) '✅' else '⬜'}" } +"${props.todo.content} (id: ${props.todo.id})" button { attrs { disabled = props.loading onClickFunction = { props.onDelete() } } +"DELETE" } } } fun RBuilder.todoCard(handler: TodoProps.() -> Unit) = child(todoCard) { attrs { handler() } }
fetch による API 通信処理は Kotlin Coroutine を使用して GlobalScope.launch 内で行うことで非同期に行わせます
Coroutine は Java のスレッドに似ていますが、より軽量に動作するため Kotlin ではスレッドの代わりによく用いられます
GlobalScope.launch { try { setLoading(true) val data = window.fetch("/api/todo") .await() .text() // Json text console.log(data) } catch (err: Throwable) { throw err } }
JSON のパース、生成は kotlinx.serialization を使用します(JSON.parse はうまく動作しませんでした)
データクラスに対して @Serializable アノテーションをつけるだけなので非常に簡単です
また、このパッケージはマルチプラットフォームに使用できるため Kotlin のアプリ開発において非常に重宝します
@Serializable data class Hoge(val value: String) val hoge: Hoge = Json.decodeFromString(Hoge.serializer(), """{"value": "Value"}""")
リストを返す様なレスポンスを変換する場合は以下の様な書き方になります
val hogeList: List<Hoge> = Json.decodeFromString(ListSerializer(Todo.serializer()), """[{"value": "Value"}]""")
それでは動作を見ていきます
事前にバックエンドサーバの起動(./graldew :backend:bootRun)を裏で行っておいてください
上記では Chrome Dev Tools の Network 機能を利用して通信速度を意図的に遅くしています
API 通信を介して動作が行えていることが確認できます
バックエンドサーバがデータを持っているので、ブラウザを更新してもデータが保持されます(バックエンドサーバを終了するとデータも破棄されます)
バックエンドのアプリの起動は時間がかかるので何も表示されない場合はしばらく経ってからブラウザを更新してください
Step4: Build applications and run in Docker
最後にプロジェクトのビルドを行い、Docker 上でアプリを動かします
プロジェクトルート上で下記のコマンドを打つとサブプロジェクトを含めたビルドを行います
./gradlew clean build
成果物はそれぞれ下記に出力されます
- backend/build/libs
- backend.jar
- frontend/build/distributions
- frontend.js
- index.html
これらの動作確認を Docker 上で行います
事前に Docker をインストールしてください
プロジェクト直下に docker-compose.yml を作成して下記を入力します
version: "3.8" services: frontend: image: nginx:1.19 ports: - 80:80 volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf - ./frontend/build/distributions:/etc/nginx/html backend: image: openjdk:11-jdk ports: - 8080:8080 volumes: - ./backend/build/libs:/etc command: java -jar /etc/backend.jar
フロントエンドサーバで使用する Nginx にリバースプロキシの設定を行うためプロジェクト直下に nginx.conf ファイルを作成して下記を入力します
server { location /api { proxy_pass http://host.docker.internal:8080/api; proxy_redirect off; } }
Docker を立ち上げます、下記のコマンドを入力します
docker-compose up
バックエンドサーバの起動に少し時間がかかるので、ある程度待ってから http://localhost にアクセスしてアプリケーションが動作していれば成功です!
Conclusion
Kotlin のみでモダンな Web 開発が行えました(思っていたより記事のボリュームが増えてしまいました)
Spring の開発を Kotlin 行うことでより快適で強力な開発を行うことが可能です、お馴染みの雛壇プロジェクト生成サイトの Spring Initializr も Kotlin に対応しています
一方で Kotlin の JS 開発を初めて実践しましたが、React ラッパーがあるのは衝撃的でした
TypeScript 開発に比べると少し難解な印象を受けましたが、ポテンシャルを感じました
Styled Components なども使える様でかなり面白いです
Kotlin に興味を持っていただけたら幸いです
ぜひ、今日から Kotlin を用いた開発にチャレンジしてみましょう!