
記事の概要
この記事では、JetBrains社が開発したオープンソースのプログラミング言語 Kotlin 2.0 と Ktor 2.3.12 を使って、macOS Sonoma と Docker Desktop 4.32 を使った開発環境の構築からシンプルなWeb API開発、実行までの流れを紹介します。
この記事を参考に、Kotlin や Web API開発 のスタートの一助となれば幸いです。
今回の記事の対象者
この記事は次のような方に向けています:
- Kotlin を使ってWeb API開発を始めたい方
- macOSとDockerコンテナを使って Kotlin の開発環境を構築したい方
- シンプルなWeb APIで Kotlin + Ktor の実装イメージを理解したい方
はじめに
今回は、構築する環境と、開発するWebアプリの概要は以下の通りです。
開発・実行環境の構成
- サーバー環境
- OS:macOS Sonoma
- ツール:Docker Desktop
- 環境:Kotlin コンテナ開発・実行環境
開発・実行環境の効率化ポイント
- Dockerコンテナで環境を統一
- Dockerコンテナを使えば、どのPCやMacでも同じ開発環境を簡単に作れます。友達と一緒に同じプロジェクトを開発するときに、とても便利です。
- Kotlinの環境を他の開発者に同一の環境を展開出来る様に設定ファイルで構成をコード化します。
- Dockerコンテナを使ったシンプルな開発環境
- Dockerを使うことで、MacBook等の端末に直接Kotlinの環境をインストールせずに、コンテナ内で動かすことができます。これにより、環境設定のトラブルが少なく、スムーズに開発が進められます。
Web API
シンプルなToDoリストWebAPIの開発を通じて、KotlinとKtorの基本操作を学びながら、基本的なルーティングとデータベース操作といった重要な要素を実践的に理解します。
また、ToDoリストのAPIの仕様を維持しながら、データベースを利用出来るように段階的にリファクタリングしながらプログラムの開発を行っていき、拡張のノウハウも身につけることが出来ます。
ToDoリストAPI
概要:
ユーザーがやるべきタスクを入力し、そのタスクをリストに追加できるシンプルなToDoリスト を管理するAPIです。
学習ポイント:
これらのポイントを学ぶことで、Kotlinを使って効率的にアプリケーションを開発できるようになります。
- RESTful APIの基本設計
- 各HTTPメソッド(GET, POST, PUT, DELETE)に対応するルーティングと処理を実装。
- KtorでのルーティングとCRUD操作
- Ktorのルーティング機能を使用し、CRUD操作を実装。
- データベース接続と基本的なデータ操作
- Exposedを使用してデータベース操作を行い、データの作成、読み取り、更新、削除を実現。
もくじ
- ソフトウェア・ハードウェア
- Kotlin、Ktorの概要
- 開発ツールのインストール
- Ktor開発環境の構築と確認
- ToDoリストAPIの開発(ステップ1:モデル、API、SPA)
- ToDoリストAPIの開発(ステップ2:メモリ上でデータ管理)
- ToDoリストAPIの開発(ステップ3:DB対応リファクタリング)
- ToDoリストAPIの開発(ステップ4:PostgreSQL対応)
ソフトウェア・ハードウェア
必要なツール、ライブラリ、端末は以下の通りです。
開発ツール
以下、開発ツールとその公式サイトの一覧です。本記事ではJetbrains社のIntelliJ IDEAを使います。
ツール名 | 用途 |
---|---|
IntelliJ IDEA | Java、Kotlin 開発者向けのIDEです。Ultimate版は個人非商用利用や学生は無料となっています。Community版は、開発ツールの基本機能を提供するOSSベースの無料IDEです。 |
pgAdmin4 | PostgreSQLデータベースの管理ツールです。 |
Postman | API(アプリケーションプログラミングインターフェース)を開発、テスト、およびデバッグするためのツールです。 |
端末
以下、今回の環境を構築する対象の端末スペックです。
項目 | 詳細 |
---|---|
ハードウェア | Apple Silicon M3, RAM 24GB |
OS | macOS Sonoma 14.6 |
本記事で紹介するソフトウェアおよびツールは、筆者の個人的な使用経験に基づくものであり、公式のサポート外の設定や使用方法を含む場合があります。利用に際しては、公式サイトの指示およびガイドラインを参照し、自己責任で行ってください。
Kotlin、Ktor概要
Kotlinとは
2011年にJetBrainsによって開発されたプログラミング言語で、主にAndroidアプリ開発で広く使われています。Javaと高い互換性を持ちつつ、よりシンプルで安全なコードを書けることが特徴です。初心者にとってありがちな「NullPointerException」を未然に防ぐ仕組みがあり、エラーに悩まされることが減ります。これは学習時に大きな助けとなります。
GoogleがAndroid公式開発言語として採用していることもあり、急速に普及しています。
Kotlinの主な特徴
- Javaとの互換性
- KotlinはJava Virtual Machine(JVM)上で動作し、Javaの既存のライブラリやフレームワークと簡単に統合できます。JavaのプロジェクトにKotlinを部分的に導入することも可能です。
- 簡潔な文法
- Kotlinはコードが非常にシンプルに書けるように設計されています。たとえば、Javaでは多くの行が必要な処理も、Kotlinではわずかな行数で表現できるため、開発効率が向上します。
- マルチプラットフォーム
- KotlinはAndroidだけでなく、iOSやWeb、サーバーサイドの開発にも対応しています。Kotlin Multiplatformを使うことで、複数のプラットフォーム向けの共通コードを共有することができます。
- オブジェクト指向と関数型プログラミング
- Kotlinはオブジェクト指向と関数型プログラミングの両方をサポートしており、状況に応じて使い分けることができます。関数型プログラミングをサポートすることで、より簡潔で柔軟なコードを書くことが可能です。
Ktorとは
JetBrainsが開発したKotlin用のWebフレームワークで、Kotlinのために設計された軽量なフレームワークです。シンプルかつ柔軟で、最初からWebAPIの開発に特化し小規模から大規模まで、柔軟に拡張可能です。JetBrainsが公式にサポートしているため、豊富なドキュメントとコミュニティサポートがあります。
Ktorの主な特徴
- 完全な非同期サポート
- 軽量で柔軟な設計
- 自由に設定できる構成(必要な機能を選んで使える)
- WebSocketやHTTP/2などの最新技術に対応
- DSL (Domain-Specific Language) を使ったシンプルなコード
開発ツールのインストール
KotlinでのToDoリストアプリ開発と実行に必要な開発ツールのインストールを行います。
- Docker Desktopのインストール
- IntelliJ IDEA Community版のインストール
Docker Desktopのインストール
Kotlinの環境は、Dockerコンテナで構築するので、Docker Desktopを事前にインストールしておきます。
Docker Desktopのインストールについては、以下の記事で詳細に解説していますので、こちらをご覧下さい。
IntelliJ IDEA Community版のインストール
Kotlinの開発は、IntelliJ IDEAを使いますので、事前にインストールしておきます。
• インストール手順: IntelliJ IDEA をインストールする
Ktor開発環境の構築と確認
KtorでのToDoリスト WebAPIの開発と実行を、Dockerコンテナを使って行う手順を説明します。
- 新しいKtorプロジェクトの作成
- IntelliJ IDEAでのプロジェクト読込と動作確認
- Dockerコンテナ環境の設定と動作確認
新しいKtorプロジェクトの作成
Ktorプロジェクトのスケルトンを公式サイトで作成することが出来ます。Configureをクリックすると、作成するプロジェクトの詳細設定を変更することが出来ます。

今回のプロジェクトの設定を以下に示します。
項目 | 定義 |
---|---|
ハードウェアproject artifact | com.example.ktor-todo-app |
Build System | Gradle Kotlin |
User version catalog | no |
Ktor Version | 2.3.12 |
Engine | Netty |
Configutration | Code |
Include samples | On |
次に、プロジェクトで利用する各種プラグインを選択します。Pluginsタブをクリックし、検索エリアに必要となるプラグイン名を入力して検索し、見つかったら「Add」ボタンでプロジェクトへ追加を行います。

今回のプロジェクトで利用するプラグインを以下に示します。それぞれプロジェクトに追加してください。
- Routing
- Content Negotiation
- Kotlinx.serialization
- Static Content
- Status Pages
- Exposed
- Postgres
プラグインの追加が終わったら、「Download」ボタンをクリックして、Ktorプロジェクトを作成&ダウンロードします。
IntelliJ IDEAでのプロジェクト読込と動作確認
IntelliJ IDEAで新しいKotlin/Gradleプロジェクトを作成します。ダウンロードしたプロジェクトを任意のディレクトリに解凍します。今回は、test-pjディレクトリの直下に解凍します。
解凍したプロジェクトディレクトリの構成を以下に示します。
(base) xxxxxx@xxxxxx ktor-todo-app % tree .
.
├── build.gradle.kts
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
├── main
│ ├── kotlin
│ │ └── com
│ │ └── example
│ │ ├── Application.kt
│ │ └── plugins
│ │ ├── CitySchema.kt
│ │ ├── Databases.kt
│ │ ├── Routing.kt
│ │ ├── Serialization.kt
│ │ └── UsersSchema.kt
│ └── resources
│ ├── logback.xml
│ └── static
│ └── index.html
└── test
└── kotlin
└── com
└── example
└── ApplicationTest.kt
15 directories, 16 files
解凍したKtorプロジェクトディレクトリを、IntelliJ IDEAで開きます。
IntelliJ IDEAを起動し、「オープン」を選択します。先ほど回答したプロジェクトディレクトリ「ktor-todo-app」を指定して、プロジェクトを作成します。起動するとGradle等の設定が自動で行われます。
Ktorプロジェクトのライブラリのバージョン設定ファイル
Ktorプロジェクトのライブラリを構成する「build.gradle.kts」、「gradle.properties」を以下に示します。新しくプラグインを追加する場合は、このファイルを編集します。今回は、編集しませんが、構成要素として頭の片隅に覚えておいてください。
build.gradle.kts (Gradle設定ファイル)
val kotlin_version: String by project
val logback_version: String by project
val exposed_version: String by project
val h2_version: String by project
val postgres_version: String by project
plugins {
kotlin("jvm") version "2.0.21"
id("io.ktor.plugin") version "2.3.12"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
}
group = "com.example"
version = "0.0.1"
application {
mainClass.set("com.example.ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
repositories {
mavenCentral()
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("com.h2database:h2:$h2_version")
implementation("io.ktor:ktor-server-host-common-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
implementation("org.postgresql:postgresql:$postgres_version")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("ch.qos.logback:logback-classic:$logback_version")
testImplementation("io.ktor:ktor-server-test-host-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
gradle.properties(ライブラリのバージョン設定ファイル)
kotlin.code.style=official
ktor_version=2.3.12
kotlin_version=2.0.21
logback_version=1.4.14
exposed_version=0.53.0
h2_version=2.2.224
postgres_version=42.5.1
次に、アプリの開発を始める前に、不要なサンプルコードを削除します。
ジェネレーターで生成した、Ktorプロジェクトには都市に関するデータをHSQLDBまたはPostgreSQLに保存するコードが追加されています。このコードは、必要ないので削除します。
- src /main /kotlin /pluginsに移動し、CitySchema.ktファイルとUsersSchema.ktファイルを削除します。
- Databases.ktファイルを開き、関数の内容を削除します
configureDatabases()
。fun Application.configureDatabases() { }
これで、アプリ開発の準備が整いました。
それでは、ローカル環境でサンプルコードが動作するか確認しましょう。
Application.ktを実行するか、Gradleのrunタスクを実行します。
次に、http://localhost:8080にアクセスすると、hello, world!が表示されます。

Dockerコンテナ環境の設定と動作確認
それでは、Dockerコンテナで先ほどのKtorプロジェクトを動作確認してみます。作業手順は、以下の通りです。
- Dockerfileの作成
- docker-compose.ymlの作成
Dockerfile の作成
Kotlinアプリを動かすためのコンテナの設定ファイルです。プロジェクトディレクトリに Dockerfile を作成し、以下の内容を記述します。
FROM gradle:8.10.0-jdk21 as builder
WORKDIR /app
COPY . .
RUN gradle build
FROM eclipse-temurin:21
WORKDIR /app
COPY --from=builder /app/build/libs/todo-list-api-all.jar /app/todo-list-api.jar
EXPOSE 8080
CMD ["java", "-jar", "/app/todo-list-api.jar"]
docker-compose.yml の作成
Docker Compose でKotlinアプリを管理するための設定ファイルを作成します。
services:
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: tododb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build: .
command: ./gradlew run
volumes:
- .:/app
ports:
- "8080:8080"
depends_on:
- db
volumes:
postgres_data:
Dockerコンテナでの動作確認
引き続き、Dockerコンテナの環境でサンプルコードが動作するか確認しましょう。
以下のコマンドを実行して、Dockerコンテナを起動します。
Dockerイメージのビルド
プロジェクトディレクトリで以下のコマンドを実行し、Dockerイメージをビルドします。
docker compose build
Dockerイメージをビルドしたログを以下に示します。
(base) xxxxxx@xxxxxx ktor-todo-app % docker compose build
[+] Building 58.1s (13/13) FINISHED docker:desktop-linux
=> [app internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 330B 0.0s
=> WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 1) 0.0s
=> [app internal] load metadata for docker.io/library/eclipse-temurin:21 1.8s
=> [app internal] load metadata for docker.io/library/gradle:8.10.0-jdk21 1.7s
=> [app internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [app internal] load build context 0.0s
=> => transferring context: 815.15kB 0.0s
=> [app stage-1 1/3] FROM docker.io/library/eclipse-temurin:21@sha256:5ad4efff3364b06c61578b267138359bcba92acc20dfd533f35b75c709a6f10b 0.0s
=> [app builder 1/4] FROM docker.io/library/gradle:8.10.0-jdk21@sha256:8b2b86662dbd50d001f6c4de895ba76c049131c8423d61137e1da464fcf11468 0.0s
=> CACHED [app builder 2/4] WORKDIR /app 0.0s
=> [app builder 3/4] COPY . . 0.2s
=> [app builder 4/4] RUN gradle build 55.8s
=> CACHED [app stage-1 2/3] WORKDIR /app 0.0s
=> [app stage-1 3/3] COPY --from=builder /app/build/libs/com.example.ktor-task-app-all.jar /app/task-app.jar 0.1s
=> [app] exporting to image 0.1s
=> => exporting layers 0.0s
=> => writing image sha256:5085cfa8a92f9a8aa2333b4b54807209c623f7f95357f66771b32f70084e0316 0.0s
=> => naming to docker.io/library/ktor-task-app-app 0.0s
(base) xxxxxx@xxxxxx ktor-todo-app %
Ktorアプリの起動
Docker ComposeでKtorアプリを起動します。
docker compose up
Ktorアプリを実行したログを以下に示します。
(base) xxxxxx@xxxxxx ktor-todo-app % docker compose up
[+] Running 3/3
✔ Network ktor-todo-app_default Created 0.0s
✔ Container ktor-todo-app-db-1 Created 0.0s
✔ Container ktor-todo-app-app-1 Created 0.1s
Attaching to app-1, db-1
db-1 |
db-1 | PostgreSQL Database directory appears to contain a database; Skipping initialization
db-1 |
db-1 | 2024-11-02 10:45:21.080 UTC [1] LOG: starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
db-1 | 2024-11-02 10:45:21.082 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
db-1 | 2024-11-02 10:45:21.082 UTC [1] LOG: listening on IPv6 address "::", port 5432
db-1 | 2024-11-02 10:45:21.084 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db-1 | 2024-11-02 10:45:21.087 UTC [29] LOG: database system was shut down at 2024-10-31 11:28:06 UTC
db-1 | 2024-11-02 10:45:21.100 UTC [1] LOG: database system is ready to accept connections
app-1 | Downloading <https://services.gradle.org/distributions/gradle-8.4-bin.zip>
app-1 | ............10%............20%.............30%............40%.............50%............60%.............70%............80%.............90%............100%
app-1 |
app-1 | Welcome to Gradle 8.4!
app-1 |
app-1 | Here are the highlights of this release:
app-1 | - Compiling and testing with Java 21
app-1 | - Faster Java compilation on Windows
app-1 | - Role focused dependency configurations creation
app-1 |
app-1 | For more details see <https://docs.gradle.org/8.4/release-notes.html>
app-1 |
app-1 | Starting a Gradle Daemon (subsequent builds will be faster)
次に、http://localhost:8080にアクセスすると、ローカル環境同様にhello, world!が表示されます。
Ktorアプリの終了
Docker ComposeでKtorアプリを終了します。
docker compose down
ToDoリストAPIの開発(ステップ1:モデル、API、SPA)
Todoモデルの作成
まずは、ToDoリストのコアとなるモデルを作成していきます。
src/main/kotlin/com/exampleにサブパッケージmodelを作成します。
modelパッケージにTodo.ktファイルを作成します。
このコードは、Todoアイテムを優先度付きで定義し、シリアライズが可能なデータモデルを提供します。
src/main/kotlin/com/example/model/Todo.kt
package com.example.model // パッケージ宣言。クラスの名前空間を管理するためのもの。
import kotlinx.serialization.Serializable // kotlinx.serializationライブラリのシリアライズ機能をインポート。
// Priority列挙型の定義。Todoの優先度を表す。
enum class Priority {
Low, // 優先度:低
Medium, // 優先度:中
High, // 優先度:高
Vital // 優先度:最重要
}
// Todoクラスの定義。このクラスはタスクを表すデータモデル。
@Serializable // このアノテーションにより、JSONやその他のフォーマットでシリアライズ可能にする。
data class Todo(
val name: String, // Todoの名前を格納するプロパティ
val description: String, // Todoの説明を格納するプロパティ
val priority: Priority // Todoの優先度を格納するプロパティ。Priority列挙型を使用。
)
•data class Todo: Todoデータクラスを定義します。data classはデータを保持するためのクラスで、equals(), hashCode(), toString(), copy() などの便利なメソッドが自動生成されます。
このコードは、Ktorを使ってWebアプリケーションを構築する際の基本的なルーティング設定とエラーハンドリングを行います。
src/main/kotlin/com/example/plugins/Routing.kt
package com.example.plugins // パッケージ宣言。プロジェクト内のコード構造を整理する。
import io.ktor.http.* // KtorのHTTP関連機能をインポート。
import io.ktor.server.application.* // Ktorのアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.http.content.* // 静的コンテンツ配信に関連する機能をインポート。
import io.ktor.server.plugins.statuspages.* // KtorのStatusPagesプラグインをインポート。エラーハンドリングに使用。
import io.ktor.server.response.* // HTTPレスポンス送信に必要なクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
import com.example.model.* // 自作のモデル(例: Todo, Priority)をインポート。
// Ktorアプリケーションのルーティングを構成する関数
fun Application.configureRouting() {
// StatusPagesプラグインをインストールして、エラーハンドリングを設定
install(StatusPages) {
// すべての例外をキャッチし、500 Internal Server Errorとしてレスポンスを返す
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
// ルーティング設定
routing {
// ルートパス("/")に対するGETリクエストに応答
get("/") {
call.respondText("Hello World!") // レスポンスとして "Hello World!" を返す
}
// 静的コンテンツの配信設定。URLパスが`/static`のリクエストに対して`static`フォルダ内のリソースを返す。
staticResources("/static", "static")
// "/tasks"パスに対するGETリクエストに応答
get("/tasks") {
// サンプルのTodoリストをレスポンスとして返す
call.respond(
listOf(
Todo("掃除", "家の掃除", Priority.Low), // Todoオブジェクトを返す
Todo("庭の手入れ", "草取り", Priority.Medium),
Todo("買い物", "掃除道具を買う", Priority.High),
Todo("勉強","資格の勉強", Priority.Vital)
)
)
}
}
}
次に、http://localhost:8080/todosにアクセスすると、ソースコードで定義したToDoリストが表示されます。

このコードは、Ktorを使用してJSON形式でレスポンスを返すWebエンドポイントです。ContentNegotiationプラグインをインストールしてkotlinx.serializationによるJSONシリアライゼーションをサポートし、指定されたルートでJSONレスポンスを返します。
src/main/kotlin/com/example/plugins/Serialization.kt
package com.example.plugins // パッケージ宣言。コードの構造を整理するために使用。
import io.ktor.serialization.kotlinx.json.* // kotlinxのJSONシリアライゼーションを使用するためのインポート。
import io.ktor.server.application.* // Ktorアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.plugins.contentnegotiation.* // Ktorのコンテンツネゴシエーションプラグインをインポート。
import io.ktor.server.response.* // HTTPレスポンスを送信するためのクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
// KtorアプリケーションでJSONシリアライゼーションを構成する関数
fun Application.configureSerialization() {
// ContentNegotiationプラグインをインストールして、コンテンツのシリアライズ方法を設定
install(ContentNegotiation) {
json() // KotlinxのJSONシリアライゼーションを使用。これにより、JSONのシリアル化・デシリアル化がサポートされる。
}
// ルーティング設定
routing {
// "/json/kotlinx-serialization"パスに対するGETリクエストに応答
get("/json/kotlinx-serialization") {
// キー「hello」に値「world」を持つマップをJSON形式でレスポンスとして返す
call.respond(mapOf("hello" to "world"))
}
}
}
実際の環境では、JSON をブラウザに直接表示する代わりに、シングル ページ アプリケーション (SPA) で処理されます。具体的には、Webブラウザ内で JavaScript コードが実行され、リクエストが行われ、返されたデータが表示されます。
簡単なSPAを作って、先ほど作成したAPIにアクセス出来るか確認してみましょう。以下のコードを作成して、http://0.0.0.0:8080/static/index.htmlにアクセスしてみましょう。登録されたTodoリストが表示されます。
src/main/resources/static/index.html
<html>
<head>
<title>Simple SPA for ToDos</title>
<script type="application/javascript">
function fetchAndDisplayTodos() {
fetchTodos()
.then(todos => displayTodos(todos))
}
function fetchTodos() {
return fetch(
"/todos",
{
headers: { 'Accept': 'application/json' }
}
).then(resp => resp.json());
}
function displayTodos(todos) {
const todosTableBody = document.getElementById("todosTableBody")
todos.forEach(todo => {
const newRow = todoRow(todo);
todosTableBody.appendChild(newRow);
});
}
function todoRow(todo) {
return tr([
td(todo.name),
td(todo.description),
td(todo.priority)
]);
}
function tr(children) {
const node = document.createElement("tr");
children.forEach(child => node.appendChild(child))
return node;
}
function td(text) {
const node = document.createElement("td");
node.appendChild(document.createTextNode(text));
return node;
}
</script>
</head>
<body>
<h1>ビュー:Todos via JS</h1>
<form action="javascript:fetchAndDisplayTodos()">
<input type="submit" value="ToDoリストを表示する">
</form>
<table>
<thead>
<tr><th>名前</th><th>説明</th><th>優先度</th></tr>
</thead>
<tbody id="todosTableBody">
</tbody>
</table>
</body>
</html>
ToDoリストAPIの開発(ステップ2:メモリ上でデータ管理)
次に、APIを拡張して、インメモリ(シングルトン)でToDoリストのデータを管理するリポジトリを作成してデータアクセスの基礎部分を作っていきます。
このコードは、TODOリストを管理するリポジトリをシングルトンとして提供し、基本的なCRUD(Create, Read, Update, Delete)の操作を簡単にサポートします。また、新たにTODOを追加する機能を追加します。
src/main/kotlin/com/example/model/TodoRepository.kt
package com.example.model // パッケージ宣言。プロジェクト内のコードを整理するために使用。
// TodosRepositoryオブジェクトの定義。シングルトンとして作成され、TODOリストを管理。
object TodosRepository {
// 初期データを含むmutableListを定義。変更可能なリストとして動的に操作可能。
private val todos = mutableListOf(
Todo("掃除", "家の掃除", Priority.Low), // 低優先度のTODO項目
Todo("庭の手入れ", "草取り", Priority.Medium), // 中優先度のTODO項目
Todo("買い物", "掃除道具を買う", Priority.High), // 高優先度のTODO項目
Todo("勉強","資格の勉強", Priority.Vital) // 最重要のTODO項目
)
// 全てのTODO項目を返す関数
fun allTodos(): List<Todo> = todos
// 指定された優先度のTODO項目を返す関数
fun todosByPriority(priority: Priority) = todos.filter {
it.priority == priority // 各TODOの優先度が引数の優先度と一致するかどうかでフィルタリング
}
// 指定された名前のTODOを返す関数。大文字小文字を無視して検索。
fun todoByName(name: String) = todos.find {
it.name.equals(name, ignoreCase = true) // 名前が一致するかどうかを比較
}
// 新しいTODOを追加する関数。重複がある場合は例外をスロー。
fun addTodo(todo: Todo) {
// 同じ名前のTODOが既に存在する場合は例外を投げる
if (todoByName(todo.name) != null) {
throw IllegalStateException("重複したTODOは追加出来ません")
}
// リストに新しいTODOを追加
todos.add(todo)
}
}
このコードは、Ktorを使用してTodoリストを管理するWeb APIの構築方法を示しています。エラーハンドリング、パスパラメータの処理、リクエストの受信、レスポンスの送信といったWebアプリケーションの基本機能を実装しています。
src/main/kotlin/com/example/plugins/Routing.kt
package com.example.plugins // パッケージ宣言。コードのグループを指定し、整理するために使用。
import io.ktor.http.* // HTTPステータスコードや関連機能をインポート。
import io.ktor.server.application.* // Ktorアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.http.content.* // 静的コンテンツの配信に関連する機能をインポート。
import io.ktor.server.plugins.statuspages.* // KtorのStatusPagesプラグインをインポート。エラーハンドリング用。
import io.ktor.server.response.* // HTTPレスポンスを送信するためのクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
import com.example.model.* // 自作のモデル(例: Todo, Priority)をインポート。
import io.ktor.serialization.* // シリアル化関連の機能をインポート。
import io.ktor.server.request.* // リクエストを処理するための機能をインポート。
// アプリケーションのルーティングを構成する関数
fun Application.configureRouting() {
// StatusPagesプラグインをインストールして、エラーハンドリングを設定
install(StatusPages) {
// 任意の例外が発生した場合に500 Internal Server Errorをレスポンスとして返す
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
// ルーティング設定
routing {
// ルートパス("/")に対するGETリクエストに応答
get("/") {
call.respondText("Hello World!") // レスポンスとして "Hello World!" を返す
}
// 静的コンテンツの配信設定。URLパスが`/static`の場合に`static`フォルダのリソースを返す。
staticResources("/static", "static")
// "/todos"ルートの設定
route("/todos") {
// "/todos"に対するGETリクエストで全てのTODOを返す
get {
val todos = TodosRepository.allTodos() // リポジトリから全てのTODOを取得
call.respond(todos) // レスポンスとして返す
}
// "/todos/byName/{todoName}"に対するGETリクエストで指定された名前のTODOを返す
get("/byName/{todoName}") {
val name = call.parameters["todoName"] // パスパラメータから名前を取得
if (name == null) {
call.respond(HttpStatusCode.BadRequest) // 名前がない場合は400 Bad Requestを返す
return@get
}
val todo = TodosRepository.todoByName(name) // 名前に基づいてTODOを検索
if (todo == null) {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
return@get
}
call.respond(todo) // TODOが見つかった場合はレスポンスとして返す
}
// "/todos/byPriority/{priority}"に対するGETリクエストで指定された優先度のTODOを返す
get("/byPriority/{priority}") {
val priorityAsText = call.parameters["priority"] // パスパラメータから優先度を取得
if (priorityAsText == null) {
call.respond(HttpStatusCode.BadRequest) // 優先度がない場合は400 Bad Requestを返す
return@get
}
try {
val priority = Priority.valueOf(priorityAsText) // 文字列をPriority型に変換
val todos = TodosRepository.todosByPriority(priority) // 優先度に基づいてTODOを取得
if (todos.isEmpty()) {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
return@get
}
call.respond(todos) // TODOが見つかった場合はレスポンスとして返す
} catch (ex: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest) // 無効な優先度文字列の場合は400 Bad Requestを返す
}
}
// "/todos"に対するPOSTリクエストで新しいTODOを追加
post {
try {
val todo = call.receive<Todo>() // リクエストボディからTODOオブジェクトを受信
TodosRepository.addTodo(todo) // リポジトリに新しいTODOを追加
call.respond(HttpStatusCode.NoContent) // 成功した場合は204 No Contentを返す
} catch (ex: IllegalStateException) {
call.respond(HttpStatusCode.BadRequest) // 重複したTODOがある場合は400 Bad Requestを返す
} catch (ex: JsonConvertException) {
call.respond(HttpStatusCode.BadRequest) // 無効なJSONデータの場合は400 Bad Requestを返す
}
}
}
}
}
それでは、新しく追加したTODO追加機能を実行してみます。POSTリクエストを簡単に生成するために、Postmanを使用します。
- Postman でこの機能をテストするには、URL への新しい POST リクエストを作成します
http://0.0.0.0:8080/todos
。 - 本文ペインで、新しいタスクを表す次の JSON ドキュメントを追加します。
{ "name": "料理", "description": "夕食を支度する", "priority": "High" }
postmanでリクエストする場合の画面イメージを以下に示します。

次に、http://localhost:8080/todosにアクセスすると、追加されたToDoを含めたすべてのToDoリストが表示されます。

既存のTODOを削除するDELETEリクエストの追加
登録されているTODOを削除するDELETEリクエストを追加します。
src/main/kotlin/com/example/model/TodoRepository.kt
package com.example.model // パッケージ宣言。コードを整理してモジュール化するために使用。
// TodosRepositoryオブジェクトの定義。TODOリストを管理するためのシングルトンオブジェクト。
object TodosRepository {
// 初期データを持つ変更可能なリストを作成。これによりTODOの操作ができる。
private val todos = mutableListOf(
Todo("掃除", "家の掃除", Priority.Low), // 低優先度のTODO項目
Todo("庭の手入れ", "草取り", Priority.Medium), // 中優先度のTODO項目
Todo("買い物", "掃除道具を買う", Priority.High), // 高優先度のTODO項目
Todo("勉強","資格の勉強", Priority.Vital) // 最重要のTODO項目
)
// 全てのTODO項目を返す関数
fun allTodos(): List<Todo> = todos
// 指定された優先度のTODO項目を返す関数
fun todosByPriority(priority: Priority) = todos.filter {
it.priority == priority // TODOの優先度が引数と一致するかどうかをフィルタリング
}
// 指定された名前のTODOを返す関数。名前は大文字小文字を無視して検索される。
fun todoByName(name: String) = todos.find {
it.name.equals(name, ignoreCase = true) // 大文字小文字を無視して名前が一致するかをチェック
}
// 新しいTODOを追加する関数。重複がある場合は例外をスローする。
fun addTodo(todo: Todo) {
// 同じ名前のTODOが既に存在する場合は例外をスロー
if (todoByName(todo.name) != null) {
throw IllegalStateException("重複したTODOは追加出来ません")
}
// リストに新しいTODOを追加
todos.add(todo)
}
// 指定された名前のTODOを削除する関数。削除に成功したかどうかを返す。
fun removeTodo(name: String): Boolean {
return todos.removeIf { it.name == name } // 名前が一致するTODOをリストから削除し、削除が成功したかを返す
}
}
src/main/kotlin/com/example/plugins/Routing.kt
package com.example.plugins // パッケージ宣言。コードをモジュール化し、プロジェクト内で整理するために使用。
import io.ktor.http.* // HTTPステータスコードや関連機能をインポート。
import io.ktor.server.application.* // Ktorアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.http.content.* // 静的コンテンツ配信に関連する機能をインポート。
import io.ktor.server.plugins.statuspages.* // KtorのStatusPagesプラグインをインポート。エラーハンドリング用。
import io.ktor.server.response.* // HTTPレスポンスを送信するためのクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
import com.example.model.* // 自作のモデル(例: Todo, Priority)をインポート。
import io.ktor.serialization.* // シリアル化関連の機能をインポート。
import io.ktor.server.request.* // リクエストを処理するための機能をインポート。
// Ktorアプリケーションのルーティングを構成する関数
fun Application.configureRouting() {
// StatusPagesプラグインをインストールして、エラーハンドリングを設定
install(StatusPages) {
// すべての例外をキャッチし、500 Internal Server Errorとしてレスポンスを返す
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
// ルーティング設定
routing {
// ルートパス("/")に対するGETリクエストに応答
get("/") {
call.respondText("Hello World!") // レスポンスとして "Hello World!" を返す
}
// 静的コンテンツの配信設定。URLパスが`/static`の場合に`static`フォルダのリソースを返す。
staticResources("/static", "static")
// "/todos"ルートの設定
route("/todos") {
// "/todos"に対するGETリクエストで全てのTODOを返す
get {
val todos = TodosRepository.allTodos() // リポジトリから全てのTODOを取得
call.respond(todos) // レスポンスとしてTODOリストを返す
}
// "/todos/byName/{todoName}"に対するGETリクエストで指定された名前のTODOを返す
get("/byName/{todoName}") {
val name = call.parameters["todoName"] // パスパラメータから名前を取得
if (name == null) {
call.respond(HttpStatusCode.BadRequest) // 名前が指定されていない場合は400 Bad Requestを返す
return@get
}
val todo = TodosRepository.todoByName(name) // 名前でTODOを検索
if (todo == null) {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
return@get
}
call.respond(todo) // TODOが見つかった場合はレスポンスとして返す
}
// "/todos/byPriority/{priority}"に対するGETリクエストで指定された優先度のTODOを返す
get("/byPriority/{priority}") {
val priorityAsText = call.parameters["priority"] // パスパラメータから優先度を取得
if (priorityAsText == null) {
call.respond(HttpStatusCode.BadRequest) // 優先度が指定されていない場合は400 Bad Requestを返す
return@get
}
try {
val priority = Priority.valueOf(priorityAsText) // 文字列をPriority型に変換
val todos = TodosRepository.todosByPriority(priority) // 優先度に基づいてTODOを取得
if (todos.isEmpty()) {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
return@get
}
call.respond(todos) // TODOが見つかった場合はレスポンスとして返す
} catch (ex: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest) // 無効な優先度文字列の場合は400 Bad Requestを返す
}
}
// "/todos"に対するPOSTリクエストで新しいTODOを追加
post {
try {
val todo = call.receive<Todo>() // リクエストボディからTODOオブジェクトを受信
TodosRepository.addTodo(todo) // リポジトリに新しいTODOを追加
call.respond(HttpStatusCode.NoContent) // 成功した場合は204 No Contentを返す
} catch (ex: IllegalStateException) {
call.respond(HttpStatusCode.BadRequest) // 重複したTODOがある場合は400 Bad Requestを返す
} catch (ex: JsonConvertException) {
call.respond(HttpStatusCode.BadRequest) // 無効なJSONデータの場合は400 Bad Requestを返す
}
}
// "/todos/{todoName}"に対するDELETEリクエストで指定された名前のTODOを削除
delete("/{todoName}") {
val name = call.parameters["todoName"] // パスパラメータから名前を取得
if (name == null) {
call.respond(HttpStatusCode.BadRequest) // 名前が指定されていない場合は400 Bad Requestを返す
return@delete
}
if (TodosRepository.removeTodo(name)) {
call.respond(HttpStatusCode.NoContent) // 削除が成功した場合は204 No Contentを返す
} else {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
}
}
}
}
}
それでは、新しく追加したTODO削除機能を実行してみます。DELETEリクエストを簡単に生成するために、Postmanを使用します。
- Postman でこの機能をテストするには、URL への新しい POST リクエストを作成します
http://0.0.0.0:8080/todos
/掃除。
postmanでリクエストする場合の画面イメージを以下に示します。

次に、http://localhost:8080/todosにアクセスすると、削除されたToDoを除くすべてのToDoリストが表示されます。

ToDoリストAPIの開発(ステップ3:DB対応リファクタリング)
本格的なデータベースに対応する為に、リポジトリクラスをインタフェース化してインメモリとPostgreSQLを切り替えるためのリファクタリングを行います。変更手順は、以下の通りです。
- インタフェースクラスの追加
- インメモリのTODOリポジトリクラスの追加(元コードの移植、インタフェースの実装、メソッドのオーバーライド)
- Serializationクラスの拡張(TODOリポジトリクラスの組込)
- Applicationクラスの拡張(Serializationクラスの呼出パラメータ追加、TODOリポジトリ)
- Routingクラスの変更(Serializationクラスへ移植したリクエスト処理の削除)
まずは、リポジトリのインタフェースを追加します。
このインターフェースは、Todoオブジェクトを管理するリポジトリの標準的な操作を定義するためのものです。具体的な実装は、TodoRepositoryを実装するクラスで行います。
src/main/kotlin/com/example/model/TodoRepository.kt
package com.example.model // パッケージ宣言。コードを整理してプロジェクト内でモジュール化するために使用。
// TodoRepositoryインターフェースの定義。TODOリストを管理するための共通の操作を提供。
interface TodoRepository {
// すべてのTODOを取得する関数。戻り値はTodoオブジェクトのリスト。
fun allTodos(): List<Todo>
// 指定された優先度に基づいてTODOを取得する関数。戻り値は条件に一致するTodoオブジェクトのリスト。
fun todosByPriority(priority: Priority): List<Todo>
// 指定された名前のTODOを取得する関数。戻り値は条件に一致するTodoオブジェクトまたはnull。
fun todoByName(name: String): Todo?
// 新しいTODOを追加する関数。戻り値はなし(Unit)。
fun addTodo(todo: Todo)
// 指定された名前のTODOを削除する関数。戻り値は削除に成功した場合はtrue、失敗した場合はfalse。
fun removeTodo(name: String): Boolean
}
今までのインメモリ(シングルトン)のToDoリストデータをリポジトリインタフェースの実装クラスに変更します。
このクラスは、Todoリストを簡単に管理するための基本的な操作をサポートし、インターフェースTodoRepositoryを実装することで、今回のTODO管理アプリで使える標準的なデータ操作のインターフェースとその機能を提供しています。
src/main/kotlin/com/example/model/FakeTodoRepository.kt
package com.example.model // パッケージ宣言。コードを整理し、プロジェクト内の構造を分かりやすくするために使用。
// FakeTodoRepositoryクラスの定義。TodoRepositoryインターフェースを実装し、TODO管理のためのフェイク実装を提供。
class FakeTodoRepository : TodoRepository {
// 初期データを持つ変更可能なリストを定義。このリストはTODO項目を格納し、後で操作できる。
private val todos = mutableListOf(
Todo("掃除", "家の掃除", Priority.Low), // 低優先度のTODO項目
Todo("庭の手入れ", "草取り", Priority.Medium), // 中優先度のTODO項目
Todo("買い物", "掃除道具を買う", Priority.High), // 高優先度のTODO項目
Todo("勉強","資格の勉強", Priority.Vital) // 最重要のTODO項目
)
// すべてのTODO項目を取得する関数
override fun allTodos(): List<Todo> = todos
// 指定された優先度のTODO項目を取得する関数
override fun todosByPriority(priority: Priority) = todos.filter {
it.priority == priority // TODOの優先度が引数と一致するかどうかをフィルタリング
}
// 指定された名前のTODOを取得する関数。名前は大文字小文字を無視して検索される。
override fun todoByName(name: String) = todos.find {
it.name.equals(name, ignoreCase = true) // 大文字小文字を無視して名前が一致するかをチェック
}
// 新しいTODOを追加する関数。重複がある場合は例外をスローする。
override fun addTodo(todo: Todo) {
// 同じ名前のTODOが既に存在する場合は例外をスロー
if (todoByName(todo.name) != null) {
throw IllegalStateException("重複したTODOは追加出来ません")
}
// リストに新しいTODOを追加
todos.add(todo)
}
// 指定された名前のTODOを削除する関数。削除に成功したかどうかを返す。
override fun removeTodo(name: String): Boolean {
return todos.removeIf { it.name == name } // 名前が一致するTODOをリストから削除し、削除が成功したかを返す
}
}
ToDoリストをルーティングしシリアライズする処理をRouting.ktからSerialization.ktに移植して実装し直します。今回は、ルーティングとシリアライズの処理を一元化して分かり易さを重視しています。
src/main/kotlin/com/example/plugins/Serialization.kt
package com.example.plugins // パッケージ宣言。コードを整理し、プロジェクト内の構造を分かりやすくするために使用。
// 必要なモデルやライブラリをインポート
import com.example.model.TodoRepository // Todoリポジトリのインターフェースをインポート。
import com.example.model.Priority // 優先度を表す列挙型をインポート。
import com.example.model.Todo // Todoデータモデルをインポート。
import io.ktor.http.* // HTTPステータスコードや関連機能をインポート。
import io.ktor.serialization.* // シリアル化関連の機能をインポート。
import io.ktor.serialization.kotlinx.json.* // kotlinxのJSONシリアル化をインポート。
import io.ktor.server.application.* // Ktorアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.plugins.contentnegotiation.* // コンテンツネゴシエーションプラグインをインポート。
import io.ktor.server.request.* // リクエストを処理するためのクラスをインポート。
import io.ktor.server.response.* // レスポンスを送信するためのクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
// シリアライズ機能とルーティングを設定する関数
fun Application.configureSerialization(repository: TodoRepository) {
// ContentNegotiationプラグインをインストールして、JSONシリアライズを設定
install(ContentNegotiation) {
json() // kotlinx.serializationを使ってJSONのシリアル化をサポート
}
// ルーティング設定
routing {
// シンプルなGETエンドポイント。JSON形式で"hello": "world"を返す。
get("/json/kotlinx-serialization") {
call.respond(mapOf("hello" to "world"))
}
// "/todos"ルートの設定
route("/todos") {
// 全てのTODOを取得するGETリクエスト
get {
val todos = repository.allTodos() // リポジトリから全てのTODOを取得
call.respond(todos) // レスポンスとして返す
}
// "/todos/byName/{todoName}"に対するGETリクエストで指定された名前のTODOを取得
get("/byName/{todoName}") {
val name = call.parameters["todoName"] // パスパラメータから名前を取得
if (name == null) {
call.respond(HttpStatusCode.BadRequest) // パラメータがない場合は400 Bad Requestを返す
return@get
}
val todo = repository.todoByName(name) // 名前に基づいてTODOを取得
if (todo == null) {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
return@get
}
call.respond(todo) // TODOが見つかった場合はレスポンスとして返す
}
// "/todos/byPriority/{priority}"に対するGETリクエストで指定された優先度のTODOを取得
get("/byPriority/{priority}") {
val priorityAsText = call.parameters["priority"] // パスパラメータから優先度を取得
if (priorityAsText == null) {
call.respond(HttpStatusCode.BadRequest) // パラメータがない場合は400 Bad Requestを返す
return@get
}
try {
val priority = Priority.valueOf(priorityAsText) // 文字列をPriority型に変換
val todos = repository.todosByPriority(priority) // 優先度に基づいてTODOを取得
if (todos.isEmpty()) {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
return@get
}
call.respond(todos) // TODOが見つかった場合はレスポンスとして返す
} catch (ex: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest) // 無効な優先度の場合は400 Bad Requestを返す
}
}
// 新しいTODOを追加するPOSTリクエスト
post {
try {
val todo = call.receive<Todo>() // リクエストボディからTODOオブジェクトを受信
repository.addTodo(todo) // リポジトリに新しいTODOを追加
call.respond(HttpStatusCode.NoContent) // 成功した場合は204 No Contentを返す
} catch (ex: IllegalStateException) {
call.respond(HttpStatusCode.BadRequest) // 重複するTODOの場合は400 Bad Requestを返す
} catch (ex: JsonConvertException) {
call.respond(HttpStatusCode.BadRequest) // 無効なJSONデータの場合は400 Bad Requestを返す
}
}
// "/todos/{todoName}"に対するDELETEリクエストで指定された名前のTODOを削除
delete("/{todoName}") {
val name = call.parameters["todoName"] // パスパラメータから名前を取得
if (name == null) {
call.respond(HttpStatusCode.BadRequest) // パラメータがない場合は400 Bad Requestを返す
return@delete
}
if (repository.removeTodo(name)) {
call.respond(HttpStatusCode.NoContent) // 削除が成功した場合は204 No Contentを返す
} else {
call.respond(HttpStatusCode.NotFound) // TODOが見つからない場合は404 Not Foundを返す
}
}
}
}
}
src/main/kotlin/com/example/plugins/Routing.kt
package com.example.plugins // パッケージ宣言。プロジェクト内でコードを整理するために使用。
// 必要なKtorのライブラリをインポート
import io.ktor.http.* // HTTPステータスコードや関連機能をインポート。
import io.ktor.server.application.* // Ktorアプリケーションの構成に必要なクラスをインポート。
import io.ktor.server.http.content.* // 静的コンテンツ配信に関連する機能をインポート。
import io.ktor.server.plugins.statuspages.* // エラーハンドリング用のStatusPagesプラグインをインポート。
import io.ktor.server.response.* // HTTPレスポンス送信に必要なクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
// Ktorアプリケーションでのルーティング設定を行う関数
fun Application.configureRouting() {
// StatusPagesプラグインをインストールして、エラーハンドリングを設定
install(StatusPages) {
// 例外が発生した場合の処理を設定
exception<Throwable> { call, cause ->
// 500 Internal Server Errorのレスポンスを返し、発生したエラーのメッセージを含める
call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError)
}
}
// ルーティング設定
routing {
// "/"パスに対するGETリクエストに応答
get("/") {
call.respondText("Hello World!") // レスポンスとして "Hello World!" を返す
}
// 静的コンテンツの配信設定。URLパスが`/static`の場合に`static`フォルダのリソースを返す。
staticResources("/static", "static")
}
}
FakeTodoRepositoryを使用して、TODOリストのデータを扱う機能を追加します。
src/main/kotlin/com/example/Application.kt
package com.example // パッケージ宣言。コードを整理し、プロジェクト内での構造を分かりやすくするために使用。
// 必要なクラスやモジュールをインポート
import com.example.model.FakeTodoRepository // `FakeTodoRepository`クラスをインポート。
import com.example.plugins.* // `plugins`パッケージ内の関数やクラスをインポート。
import io.ktor.server.application.* // Ktorアプリケーションの基本的なクラスをインポート。
import io.ktor.server.engine.* // Ktorのサーバーエンジンのインポート。
import io.ktor.server.netty.* // KtorのNettyサーバーエンジンをインポート。
// メイン関数。アプリケーションのエントリーポイント。
fun main() {
// 組み込みのNettyサーバーを作成し、8080ポートでホスト`0.0.0.0`でリッスン
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true) // サーバーを起動し、終了するまで待機
}
// Ktorアプリケーションのモジュール関数。アプリケーションの設定を行う。
fun Application.module() {
// `FakeTodoRepository`のインスタンスを作成。TODOリストを管理するリポジトリ。
val repository = FakeTodoRepository()
// シリアル化設定を行う関数を呼び出し、`repository`を渡す。
configureSerialization(repository)
// データベース設定を行う関数を呼び出し。
configureDatabases()
// ルーティング設定を行う関数を呼び出し。
configureRouting()
}
動作確認用のSPAを拡張して、先ほど作成したAPIにアクセス出来るか確認してみましょう。以下のコードを作成して、http://0.0.0.0:8080/static/index.htmlにアクセスしてみましょう。登録されたTodoリストが表示されます。
src/main/resources/static/index.html
<html>
<head>
<title>A Simple SPA For Todos</title>
<script type="application/javascript">
function displayAllTodos() {
clearTodosTable();
fetchAllTodos().then(displayTodos)
}
function displayTodosWithPriority() {
clearTodosTable();
const priority = readTodoPriority();
fetchTodosWithPriority(priority).then(displayTodos)
}
function displayTodo(name) {
fetchTodoWithName(name).then(t =>
todoDisplay().innerHTML
= `${t.priority} priority todo ${t.name} with description "${t.description}"`
)
}
function deleteTodo(name) {
deleteTodoWithName(name).then(() => {
clearTodoDisplay();
displayAllTodos();
})
}
function deleteTodoWithName(name) {
return sendDELETE(`/todos/${name}`)
}
function addNewTodo() {
const todo = buildTodoFromForm();
sendPOST("/todos", todo).then(displayAllTodos);
}
function buildTodoFromForm() {
return {
name: getTodoFormValue("newTodoName"),
description: getTodoFormValue("newTodoDescription"),
priority: getTodoFormValue("newTodoPriority")
}
}
function getTodoFormValue(controlName) {
return document.addTodoForm[controlName].value;
}
function todoDisplay() {
return document.getElementById("currentTodoDisplay");
}
function readTodoPriority() {
return document.priorityForm.priority.value
}
function fetchTodosWithPriority(priority) {
return sendGET(`/todos/byPriority/${priority}`);
}
function fetchTodoWithName(name) {
return sendGET(`/todos/byName/${name}`);
}
function fetchAllTodos() {
return sendGET("/todos")
}
function sendGET(url) {
return fetch(
url,
{headers: {'Accept': 'application/json'}}
).then(response => {
if (response.ok) {
return response.json()
}
return [];
});
}
function sendPOST(url, data) {
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
}
function sendDELETE(url) {
return fetch(url, {
method: "DELETE"
});
}
function todosTable() {
return document.getElementById("todosTableBody");
}
function clearTodosTable() {
todosTable().innerHTML = "";
}
function clearTodoDisplay() {
todoDisplay().innerText = "None";
}
function displayTodos(todos) {
const todosTableBody = todosTable()
todos.forEach(todo => {
const newRow = todoRow(todo);
todosTableBody.appendChild(newRow);
});
}
function todoRow(todo) {
return tr([
td(todo.name),
td(todo.priority),
td(viewLink(todo.name)),
td(deleteLink(todo.name)),
]);
}
function tr(children) {
const node = document.createElement("tr");
children.forEach(child => node.appendChild(child));
return node;
}
function td(content) {
const node = document.createElement("td");
if (content instanceof Element) {
node.appendChild(content)
} else {
node.appendChild(document.createTextNode(content));
}
return node;
}
function viewLink(todoName) {
const node = document.createElement("a");
node.setAttribute(
"href", `javascript:displayTodo("${todoName}")`
)
node.appendChild(document.createTextNode("view"));
return node;
}
function deleteLink(todoName) {
const node = document.createElement("a");
node.setAttribute(
"href", `javascript:deleteTodo("${todoName}")`
)
node.appendChild(document.createTextNode("delete"));
return node;
}
</script>
</head>
<body onload="displayAllTodos()">
<h1>TODO Manager Client</h1>
<form action="javascript:displayAllTodos()">
<span>View all the todos</span>
<input type="submit" value="Go">
</form>
<form name="priorityForm" action="javascript:displayTodosWithPriority()">
<span>View todos with priority</span>
<select name="priority">
<option name="Low">Low</option>
<option name="Medium">Medium</option>
<option name="High">High</option>
<option name="Vital">Vital</option>
</select>
<input type="submit" value="Go">
</form>
<form name="addTodoForm" action="javascript:addNewTodo()">
<span>Create new todo with</span>
<label for="newTodoName">name</label>
<input type="text" id="newTodoName" name="newTodoName" size="10">
<label for="newTodoDescription">description</label>
<input type="text" id="newTodoDescription" name="newtodoDescription" size="20">
<label for="newTodoPriority">priority</label>
<select id="newTodoPriority" name="newTodoPriority">
<option name="Low">Low</option>
<option name="Medium">Medium</option>
<option name="High">High</option>
<option name="Vital">Vital</option>
</select>
<input type="submit" value="Go">
</form>
<hr>
<div>
Current task is <em id="currentTodoDisplay">None</em>
</div>
<hr>
<table>
<thead>
<tr>
<th>Name</th>
<th>Priority</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody id="todosTableBody">
</tbody>
</table>
</body>
</html>
以下の様に、登録されたTodoリストが表示されます。

ToDoリストAPIの開発(ステップ4:PostgreSQL対応)
データベーススキーマを作成する
- name、description、priorityの列を持つtodoという単一のテーブルを作成します。これらをクラスのプロパティにマッピングする必要があります。
- テーブルがすでに存在する場合はテーブルを再作成するため、スクリプトを繰り返し実行できます。
- idという追加の列があり、これはSERIAL 型です。これは整数値で、各行に主キーを付与するために使用されます。これらの値は、データベースによって自動的に割り当てられます。
SQL
DROP TABLE IF EXISTS todo;
CREATE TABLE todo(id SERIAL PRIMARY KEY, name VARCHAR(50), description VARCHAR(50), priority VARCHAR(50));
INSERT INTO todo (name, description, priority) VALUES ('掃除', 'おうちの掃除', 'Low');
INSERT INTO todo (name, description, priority) VALUES ('庭の手入れ', '草取り', 'Medium');
INSERT INTO todo (name, description, priority) VALUES ('買い物', '掃除道具', 'High');
INSERT INTO todo (name, description, priority) VALUES ('壁塗り', '柵を塗装する', 'Medium');
INSERT INTO todo (name, description, priority) VALUES ('運動', '近くの散策', 'Medium');
INSERT INTO todo (name, description, priority) VALUES ('治療', '健康診断', 'High');
PostgreSQLデータベースに非同期でアクセスするためリポジトリインタフェースを変更する
このインターフェースは、非同期のTodoリスト管理機能を提供し、CRUD(Create, Read, Update, Delete)の操作をサポートします。非同期処理はコルーチンを使用して実装されるため、スケーラブルで応答性の高いアプリケーションを構築するのに適しています。
src/main/kotlin/com/example/model/TodoRepository.kt
package com.example.model // パッケージ宣言。コードを整理し、プロジェクト内でモジュール化するために使用。
// TodoRepositoryインターフェースの定義。非同期処理をサポートするために`suspend`キーワードを使用している。
interface TodoRepository {
// すべてのTODO項目を非同期に取得する関数。戻り値はTodoオブジェクトのリスト。
suspend fun allTodos(): List<Todo>
// 指定された優先度に基づいてTODO項目を非同期に取得する関数。戻り値は条件に一致するTodoオブジェクトのリスト。
suspend fun todosByPriority(priority: Priority): List<Todo>
// 指定された名前のTODOを非同期に取得する関数。戻り値は条件に一致するTodoオブジェクトまたはnull。
suspend fun todoByName(name: String): Todo?
// 新しいTODOを非同期に追加する関数。戻り値はなし(Unit)。
suspend fun addTodo(todo: Todo)
// 指定された名前のTODOを非同期に削除する関数。削除に成功した場合はtrue、失敗した場合はfalseを返す。
suspend fun removeTodo(name: String): Boolean
}
FakeTaskRepositoryを非同期アクセスに対応する
インターフェース メソッドの実装は、別のコルーチン ディスパッチャーで作業ジョブを開始できるようになります。
FakeTaskRepositoryはその実装で Dispatcher を切り替える必要はありませんが、一致するように のメソッドを調整します。
また、PostgresTaskRepositoryデータベースに対してクエリを非同期的に実行する を作成するための基礎を築きます。
このクラスは、非同期のTodoリスト管理機能を提供し、テストや開発環境で使いやすいフェイクのリポジトリを実装しています。非同期処理をサポートするためにCoroutine対応のsuspend関数が使われています。
src/main/kotlin/com/example/model/FakeTodoRepository.kt
package com.example.model // パッケージ宣言。コードを整理し、プロジェクト内の構造を分かりやすくするために使用。
// FakeTodoRepositoryクラスの定義。TodoRepositoryインターフェースを実装し、TODOリストを管理するためのフェイク(テスト用)リポジトリを提供。
class FakeTodoRepository : TodoRepository {
// 初期データを持つ変更可能なリストを定義。TODOリストとして利用。
private val todos = mutableListOf(
Todo("掃除", "家の掃除", Priority.Low), // 低優先度のTODO項目
Todo("庭の手入れ", "草取り", Priority.Medium), // 中優先度のTODO項目
Todo("買い物", "掃除道具を買う", Priority.High), // 高優先度のTODO項目
Todo("勉強","資格の勉強", Priority.Vital) // 最重要のTODO項目
)
// すべてのTODO項目を非同期に取得する関数
override suspend fun allTodos(): List<Todo> = todos
// 指定された優先度に基づいてTODO項目を非同期に取得する関数
override suspend fun todosByPriority(priority: Priority) = todos.filter {
it.priority == priority // TODOの優先度が引数と一致するかどうかをフィルタリング
}
// 指定された名前のTODOを非同期に取得する関数。大文字小文字を無視して検索。
override suspend fun todoByName(name: String) = todos.find {
it.name.equals(name, ignoreCase = true) // 名前が一致するかをチェック(大文字小文字を無視)
}
// 新しいTODOを非同期に追加する関数。重複する名前のTODOがある場合は例外をスロー。
override suspend fun addTodo(todo: Todo) {
if (todoByName(todo.name) != null) {
throw IllegalStateException("重複したTODOは追加出来ません") // 名前が重複している場合は例外をスロー
}
todos.add(todo) // 新しいTODOをリストに追加
}
// 指定された名前のTODOを非同期に削除する関数。削除が成功した場合はtrue、失敗した場合はfalseを返す。
override suspend fun removeTodo(name: String): Boolean {
return todos.removeIf { it.name == name } // 名前が一致するTODOを削除し、結果を返す
}
}
PostgreSQLのデータベースにアクセスするための設定を追加する
このコードは、Ktorアプリケーションで使用するデータベース接続を設定します。Exposedライブラリを使ってデータベースへの接続を簡単に管理し、アプリケーションがデータベースとやり取りできるようにします。
src/main/kotlin/com/example/plugins/Databases.kt
package com.example.plugins // パッケージ宣言。コードを整理し、プロジェクト内で構造を分かりやすくするために使用。
// 必要なKtorのライブラリをインポート
import io.ktor.http.* // HTTPステータスコードや関連機能をインポート。
import io.ktor.server.application.* // Ktorアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.request.* // HTTPリクエスト処理に必要なクラスをインポート。
import io.ktor.server.response.* // HTTPレスポンス送信に必要なクラスをインポート。
import io.ktor.server.routing.* // Ktorのルーティング機能をインポート。
import java.sql.* // JavaのSQLライブラリをインポート。
import kotlinx.coroutines.* // コルーチンを使用するためのライブラリをインポート。
import org.jetbrains.exposed.sql.* // Exposedライブラリを使用してSQLデータベースの操作を行うためのクラスをインポート。
// Ktorアプリケーションでデータベース接続を設定する関数
fun Application.configureDatabases() {
// Databaseオブジェクトのconnectメソッドを使用してデータベースに接続
Database.connect(
"jdbc:postgresql://db:5432/tododb", // 接続先のデータベースURL(PostgreSQLのJDBC接続)
user = "user", // データベース接続ユーザー名
password = "password" // データベース接続パスワード
)
}
DAOアクセスするための、ExposedのDAOモジュールライブラリを追加します。以下の、エントリーを追加してください。
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
gradle.build.kts
// プロジェクトのプロパティからバージョン情報を取得する変数の定義
val kotlin_version: String by project // Kotlinのバージョンをプロジェクトのプロパティから取得
val logback_version: String by project // Logbackのバージョンをプロジェクトのプロパティから取得
val exposed_version: String by project // Exposedライブラリのバージョンをプロジェクトのプロパティから取得
val h2_version: String by project // H2データベースのバージョンをプロジェクトのプロパティから取得
val postgres_version: String by project // PostgreSQLのバージョンをプロジェクトのプロパティから取得
// 使用するプラグインの設定
plugins {
kotlin("jvm") version "2.0.21" // Kotlin JVMプラグインを使用し、指定バージョンで設定
id("io.ktor.plugin") version "2.3.12" // Ktorプラグインを使用してプロジェクトを構成
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" // Kotlinのシリアル化プラグインを使用
}
// プロジェクト情報の設定
group = "com.example" // プロジェクトのグループID
version = "0.0.1" // プロジェクトのバージョン
// アプリケーション設定ブロック
application {
mainClass.set("com.example.ApplicationKt") // アプリケーションのメインクラスを指定
// 開発モードのフラグを設定
val isDevelopment: Boolean = project.ext.has("development") // 開発モードのプロパティがあるか確認
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") // JVM引数として設定
}
// リポジトリ設定
repositories {
mavenCentral() // 依存関係を解決するためにMaven Centralリポジトリを使用
}
// プロジェクトの依存関係を定義
dependencies {
implementation("io.ktor:ktor-server-core-jvm") // Ktorのサーバーコアモジュール
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") // JSONシリアル化モジュール
implementation("io.ktor:ktor-server-content-negotiation-jvm") // コンテンツネゴシエーションモジュール
implementation("org.jetbrains.exposed:exposed-core:$exposed_version") // Exposedのコアモジュール
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version") // ExposedのJDBCモジュール
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version") // ExposedのDAOモジュール
implementation("com.h2database:h2:$h2_version") // H2データベースドライバ
implementation("io.ktor:ktor-server-host-common-jvm") // Ktorの共通サーバーモジュール
implementation("io.ktor:ktor-server-status-pages-jvm") // KtorのStatus Pagesモジュール
implementation("org.postgresql:postgresql:$postgres_version") // PostgreSQLデータベースドライバ
implementation("io.ktor:ktor-server-netty-jvm") // KtorのNettyサーバーエンジン
implementation("ch.qos.logback:logback-classic:$logback_version") // Logbackのクラシック版(ロギング用)
testImplementation("io.ktor:ktor-server-test-host-jvm") // Ktorのテスト用ホストモジュール
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") // KotlinのJUnitテストモジュール
}
PostgreSQLデータベースのテーブルにアクセスするためのDAOクラスの追加
データベースにアクセスするためのDAOクラスを追加します。
このコードは、Exposedライブラリを使用してデータベーステーブルを定義し、データベース行をDAOオブジェクトとして管理し、非同期にトランザクションを実行するための基盤を提供しています。
src/main/kotlin/com/example/db/mapping.kt
package com.example.db // パッケージ宣言。コードを整理し、プロジェクト内の構造を分かりやすくするために使用。
// 必要なクラスやライブラリをインポート
import com.example.model.Priority // Priority列挙型をインポート。
import com.example.model.Todo // Todoデータモデルをインポート。
import kotlinx.coroutines.Dispatchers // コルーチンで使用されるディスパッチャーをインポート。
import org.jetbrains.exposed.dao.IntEntity // ExposedライブラリのDAOサポートクラスをインポート。
import org.jetbrains.exposed.dao.IntEntityClass // DAOクラスを生成するためのサポートクラスをインポート。
import org.jetbrains.exposed.dao.id.EntityID // エンティティIDを扱うクラスをインポート。
import org.jetbrains.exposed.dao.id.IntIdTable // Int型IDテーブルを扱うクラスをインポート。
import org.jetbrains.exposed.sql.Transaction // トランザクションを扱うクラスをインポート。
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction // 非同期トランザクションを扱う関数をインポート。
// Exposedライブラリを使用して定義されたTodoテーブルオブジェクト
object TodoTable : IntIdTable("todo") {
val name = varchar("name", 50) // "name"列を50文字までの文字列として定義
val description = varchar("description", 50) // "description"列を50文字までの文字列として定義
val priority = varchar("priority", 50) // "priority"列を50文字までの文字列として定義
}
// ExposedのIntEntityを使用して定義されたTodoDAOクラス。テーブルの行をオブジェクトとして表現。
class TodoDAO(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TodoDAO>(TodoTable) // DAOクラスを作成するためのコンパニオンオブジェクト
var name by TodoTable.name // TodoTableの"name"列をプロパティとしてマッピング
var description by TodoTable.description // TodoTableの"description"列をプロパティとしてマッピング
var priority by TodoTable.priority // TodoTableの"priority"列をプロパティとしてマッピング
}
// 非同期トランザクションを実行する関数。ブロック内のコードは別スレッドで実行される。
suspend fun <T> suspendTransaction(block: Transaction.() -> T): T =
newSuspendedTransaction(Dispatchers.IO, statement = block) // Dispatchers.IOを使用してI/O操作を別スレッドで処理
// DAOオブジェクトをモデルオブジェクトに変換する関数
fun daoToModel(dao: TodoDAO) = Todo(
dao.name, // DAOのnameプロパティをモデルのnameにマッピング
dao.description, // DAOのdescriptionプロパティをモデルのdescriptionにマッピング
Priority.valueOf(dao.priority) // DAOのpriorityをPriority列挙型に変換してモデルにマッピング
)
PostgreSQLのデータベース用のリポジトリインタフェースの実装クラスを作成
PostgreSQLのデータベース用のリポジトリインタフェースの実装クラスを作成します。
このクラスは、Exposedライブラリを使用してデータベースにアクセスし、Todoオブジェクトを操作するためのリポジトリの実装を提供します。非同期処理をサポートするためにsuspend関数を使用しています。
src/main/kotlin/com/example/model/PostgresTodoRepository.kt
package com.example.model // パッケージ宣言。コードを整理し、プロジェクト内で構造を分かりやすくするために使用。
// 必要なクラスやライブラリをインポート
import com.example.db.TodoDAO // DAOクラスをインポート。
import com.example.db.TodoTable // Todoテーブルをインポート。
import com.example.db.daoToModel // DAOオブジェクトをモデルオブジェクトに変換する関数をインポート。
import com.example.db.suspendTransaction // 非同期トランザクションを実行する関数をインポート。
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq // SQL条件式を構築するためのヘルパーをインポート。
import org.jetbrains.exposed.sql.deleteWhere // 条件付きで行を削除するための関数をインポート。
// TodoRepositoryインターフェースを実装するクラス。PostgreSQL用の実装。
class PostgresTodoRepository : TodoRepository {
// 全てのTODOを非同期に取得する関数。DAOオブジェクトをモデルオブジェクトに変換して返す。
override suspend fun allTodos(): List<Todo> = suspendTransaction {
TodoDAO.all().map(::daoToModel) // すべてのDAOオブジェクトを取得し、モデルオブジェクトに変換
}
// 指定された優先度のTODOを非同期に取得する関数。
override suspend fun todosByPriority(priority: Priority): List<Todo> = suspendTransaction {
TodoDAO
.find { (TodoTable.priority eq priority.toString()) } // 優先度が一致するTODOを検索
.map(::daoToModel) // 検索結果をモデルオブジェクトに変換
}
// 指定された名前のTODOを非同期に取得する関数。最初に見つかった1件のみを返す。
override suspend fun todoByName(name: String): Todo? = suspendTransaction {
TodoDAO
.find { (TodoTable.name eq name) } // 名前が一致するTODOを検索
.limit(1) // 最初の1件のみを制限
.map(::daoToModel) // 検索結果をモデルオブジェクトに変換
.firstOrNull() // 見つからなければnullを返す
}
// 新しいTODOを非同期に追加する関数。
override suspend fun addTodo(todo: Todo): Unit = suspendTransaction {
TodoDAO.new { // 新しいDAOオブジェクトを作成
name = todo.name // 名前を設定
description = todo.description // 説明を設定
priority = todo.priority.toString() // 優先度を設定(文字列形式に変換)
}
}
// 指定された名前のTODOを非同期に削除する関数。削除に成功したかどうかを返す。
override suspend fun removeTodo(name: String): Boolean = suspendTransaction {
val rowsDeleted = TodoTable.deleteWhere { // 条件に一致する行を削除
TodoTable.name eq name // 名前が一致する行を削除
}
rowsDeleted == 1 // 削除された行が1件であればtrueを返す
}
}
リポジトリインタフェースの実装クラスの呼出変更
FakeTodoRepositoryをPostgresTodoRepositoryに変更します。これで、実際のデータベースにアクセスする準備が完了しました。
src/main/kotlin/com/example/Application.kt
package com.example // パッケージ宣言。コードを整理し、プロジェクト内の構造を分かりやすくするために使用。
// 必要なクラスやモジュールをインポート
import com.example.model.PostgresTodoRepository // `PostgresTodoRepository`クラスをインポート。
import com.example.plugins.* // `plugins`パッケージ内の関数やクラスをインポート。
import io.ktor.server.application.* // Ktorアプリケーション構成に必要なクラスをインポート。
import io.ktor.server.engine.* // Ktorのサーバーエンジンをインポート。
import io.ktor.server.netty.* // KtorのNettyサーバーエンジンをインポート。
// アプリケーションのエントリーポイントとなるメイン関数。
fun main() {
// 組み込みのNettyサーバーを作成し、8080ポートでホスト`0.0.0.0`でリッスン。
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true) // サーバーを起動し、終了するまで待機。
}
// Ktorアプリケーションのモジュール関数。アプリケーションの設定を行う。
fun Application.module() {
// PostgresTodoRepositoryのインスタンスを作成。TODOリストを管理するリポジトリ。
val repository = PostgresTodoRepository()
// シリアル化設定を行う関数を呼び出し、`repository`を渡す。
configureSerialization(repository)
// データベース接続設定を行う関数を呼び出し。
configureDatabases()
// ルーティング設定を行う関数を呼び出し。
configureRouting()
}
動作確認用のSPAを実行して、先ほど作成したAPIにアクセス出来るか確認してみましょう。

http://0.0.0.0:8080/static/index.htmlにアクセスしてみましょう。
データベースに登録されたTodoリストが表示されます。変更や削除を行って、データベースが実際に変更されていることを確認してみましょう。
まとめ
これからもKotlinとmacOS、Dockerコンテナを活用して、より高度なアプリケーション開発に挑戦してみてください!
最後に、この記事がKotlinとKtorの学習やアプリ開発に役立つことを願っています。