🪩

Ktorで低コスト運用できる静的Webサイト構築ハンズオン

2024/12/02に公開

Kotlinでポートフォリオサイトを作ってみたいぃぃぃ !!!

Kotlin大好きな皆さんこんにちは。てべすてんです。

Kotlin大好きな皆さんなら「Kotlinでポートフォリオサイトを作ってみたいぃぃぃ!」と思ったことが一度はあるはず。(圧)
今回はそんな人に送る 低コスト運用できる Kotlin製Webサイト構築のお話です。

SG とは?

SGとは Static Generation と呼ばれる、Webアプリケーションの各パスを あらかじめ htmlなどのファイルとして出力する機能です。 NextjsなどのWebフロントエンドでよく使われる言葉になります。

SGの美味しい点は Webアプリを 安価な静的ホスティング環境(github pagesやvercelなどなど) におくだけでデプロイできる 点です。デプロイがファイルを置くだけで終わる手頃さや コストの低さが非常に魅力的でしょう。(特に今回使う Github Pages はpublic レポジトリであれば無料で使えることができます!)

ただし、ビルドしたタイミングで生成した html を後からいじることはできないので、ドキュメント等のような頻繁には更新されないWebサイトに向いています。まさにポートフォリオサイトにぴったりなのではないでしょうか。

しかし、KtorはそのままではSGすることはできない(そもそもそういう志向のフレームワークではない)ため、今回 Ktor Static Generation としてライブラリを用意しました。

Ktor Static Generation

Ktor Static Generation はざっくり以下の3ステップであなたのWebアプリを htmlファイルとして出力することができるようになります。

  1. ktorプロジェクトに 本ライブラリをセットアップ
  2. staticGeneration() 関数で route を設定する
  3. ./gradlew staticGenerate を実行

ハンズオン: 簡単なWebサイトを作る

今回はこのライブラリを使って、以下のような簡単なWebサイトを作ってみます。

ktor, kotlinx-html, kotlinx-css を使って Github Pages にデプロイするところまでをサンプルとして作ってみます。

トップページ ブログ詳細ページ
/ /blog/{blogId}

1. Project template をダウンロード

まずは ktor のプロジェクトを作っていきます。以下のURLにアクセスして適当なProject artifactを設定し、ダウンロードします。必要に応じて他のプラグインも入れてみてもいいかもしれません。

https://start.ktor.io/settings?name=ktorSgSample&website=tbsten.github.io&artifact=io.github.tbsten.ktorSgSample&kotlinVersion=2.0.21&ktorVersion=3.0.1&buildSystem=GRADLE_KTS&buildSystemArgs.version_catalog=true&engine=NETTY&configurationIn=YAML&addSampleCode=true&plugins=

ダウンロードした zipファイルを解凍して、IntelliJ idea等で開きます。以下 筆者は Intellij IDEA で進めていきます。

2. ライブラリのセットアップ

今回は 以下の3つのライブラリを使用するため、このステップでこれらを導入していきます。

  • ktor static generation
  • kotlinx html ... HTMLをkotlinで記述するために使用
  • kotlinx css ... CSSをkotlinで記述するために使用

まずは ktor static generation を導入してきます。READMEにある通り gradle plugin と dependencies、 タスク定義の3点を設定します。

import me.tbsten.ktor.staticGeneration.KtorStaticGenerationTask

plugins {
    id("me.tbsten.ktor.static.generation") version "0.2.0"
}

dependencies {
    implementation("me.tbsten.ktor:ktor-static-generation:0.2.0")
}

val staticGenerate by tasks.getting(KtorStaticGenerationTask::class) {
    mainClass.set("<your_package>.StaticGenerationKt")
    classpath(sourceSets.main.get().runtimeClasspath)
}

最新バージョンは Github の Release を参照してください。 (執筆時点では 0.2.0)

また <your_package> 部分を置き換え忘れないようにすることにも注意です。

続いて kotlinx.html と kotlinx.css を導入してきます。こちらは dependencies に追加するだけです。なお kotlinx.html には便利なユーティリティがktor公式から提供されているため惜しみなく使ってみたいと思います。

build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-html:0.11.0")
    implementation("io.ktor:ktor-server-html-builder:$ktor_version")
    implementation("org.jetbrains.kotlin-wrappers:kotlin-css:1.0.0-pre.834")
}

詳細な導入方法・最新バージョンは以下をご覧ください。

version catalog を使用する場合

お好みで version catalog を使用したい方は以下を参照してください。

libs.versions.toml
[versions]
ktor-static-generation = "0.2.0"
...

[libraries]
ktor-static-generation = { module = "me.tbsten.ktor:ktor-static-generation", version.ref = "ktor-static-generation" }
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html", version = "0.11.0" }
ktor-kotlinx-html-builder = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor-version" }
kotlinx-css = { module = "org.jetbrains.kotlin-wrappers:kotlin-css", version = "1.0.0-pre.834" }
...

[plugins]
ktorStaticGeneration = { id = "me.tbsten.ktor.static.generation", version.ref = "ktor-static-generation" }
...

build.gradle.kts
import me.tbsten.ktor.staticGeneration.KtorStaticGenerationTask

plugins {
    alias(libs.plugins.ktorStaticGeneration)
}

dependencies {
    implementation(libs.ktor.static.generation)
    implementation(libs.kotlinx.html)
    implementation(libs.ktor.kotlinx.html.builder)
    implementation(libs.kotlinx.css)
}

val staticGenerate by tasks.getting(KtorStaticGenerationTask::class) {
    mainClass.set("<your_package>.StaticGenerationKt")
    classpath(sourceSets.main.get().runtimeClasspath)
}

ここまでできたら IDE の Gradle Sync をしておきましょう。

次に、Ktorの設定を行なっていきます。以下のようにKtorのPluginとしてStaticGenerationをinstallします。

/src/main/kotlin/io/github/tbsten/Application.kt
+ import me.tbsten.ktor.staticGeneration.StaticGeneration

  ...
  fun Application.module() {
+     install(StaticGeneration)
      configureRouting()
  }

また StaticGenerationのエントリーポイントになるmain関数を用意する必要もあります。これはアプリのエントリーポイント (デフォルトだと /src/main/kotlin/your_package/Application.kt 内のmain関数) とは別で staticGenerateする際に呼び出されるmain関数になります。

/src/main/kotlin/io/github/tbsten/StaticGeneration.kt
import kotlinx.coroutines.runBlocking
import me.tbsten.ktor.staticGeneration.generateStatic

fun main() {
    runBlocking { 
        generateStatic { 
            configureRouting()
        }
    }
}

これでプロジェクト全体の設定は完了です。

3. static generation 対象の route を定義

通常 ktor では以下のように get() {} を使って route を定義します。

get("/") {
  call.respondText("<h1>Hellow World !</h1>", ContentType.Text.Html)
}

が、Ktor Static Generation ライブラリではこれを以下のように staticGenerate() {} で置き換えます。Ktor Static Generation ライブラリにどの route を static generation の対象にすればいいのかを教えるためです。逆に get のままにしてしまうと Static Generation 時に htmlファイルになってくれません。

/src/main/kotlin/io/github/tbsten/Routing.kt
  fun Application.configureRouting() {
      routing {
-         get("/") {
+         staticGeneration("/") {
              call.respondText("Hello World!")
          }
      }
  }

また上記のように単純なパスであれば 単純な置き換えで済むのですが、動的なパラメータを含む場合は注意が必要です。パラメータがある場合、Static Generate時にどのパラメータに何を渡せばいいか ライブラリ側で判断がつかないため、 staticPaths という引数を使ってFlowの形式で指定します。
また正規表現のパスも同様です。

// ❌ NG
staticGeneration("/blog/{blogId}") {
  ...
}
// ✅ OK
staticGeneration(
  "/blog/{blogId}",
  staticPaths = { flowOf("/blog/1", "/blog/2") },
) { ... }

なお staticPaths の型は suspend () -> Flow<String> のようになっているため、非同期処理を絡めて以下のように書くことも可能でしょう。

staticGeneration(
  "/blog/{blogId}",
  staticPaths = {
    fetchAllBlogs() // List<Blog>
      .map { "/blog/${it.blogId}" } // Blog のIDを使ってパスを組み立てる
      .asFlow()
  },
) { ... }

今回は簡単なサンプルなので 一旦以下のような route を追加することにしました。次のステップで html, css の装飾を追加していきます。

staticGeneration(
    "/blog/{blogId}",
    staticPaths = { listOf("/blog/1", "/blog/2").asFlow() },
) {
    val blogId = call.parameters["blogId"]
    call.respondText("Hello blog id=$blogId")
}

4. kotlinx.html, kotlin.css で HTML と CSS を操る

続いて上記で追加したルートでレスポンスするテキストを綺麗な HTMLとCSS で作り直してみます。

以下のように call.respondHtml { } を使って html を kotlin コードの中で記述することができます。各タグが拡張関数として定義されてる都合上それぞれ importしないといけないのは若干面倒ですが、kotlinのなかで非常に心地いいのではないのでしょうか (圧)。

またkotlinの言語機能を最大限生かしてUIを構築しているため、完成したコードからJetpack Composeと似た雰囲気を感じますね。

/src/main/kotlin/io/github/tbsten/Routing.kt
  staticGeneration("/") {
-     call.respondText("Hello World!")
+     call.respondHtml {
+         body {
+             h1 { +"Hello TBSten blog" }
+         }
+     }
  }

また 共通化する際は 以下のように レシーバの型(拡張関数の拡張される側のクラス)は内部で使うタグに応じて取捨選択する必要があるかと思います。

// h1 のレシーバがFlowOrHeadingContent のためそれに合わせてFlowOrHeadingContentにしている。
fun FlowOrHeadingContent.pageTitle(title: String) {
    h1 {
        +title
    }
}

続いて CSS です。HTMLと違って CSS には便利な call.respondCss { } 関数はないため自作します。

と言っても Ktorのドキュメント に参考になる実装があるのでそれをそのまま持ってきます。

/src/main/kotlin/io/github/tbsten/util/ResponseCssExt.kt
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import kotlinx.css.CssBuilder

suspend inline fun ApplicationCall.respondCss(builder: CssBuilder.() -> Unit) {
    this.respondText(CssBuilder().apply(builder).toString(), ContentType.Text.CSS)
}

用意した respondCss() を呼び出す route を作ります。

/src/main/kotlin/io/github/tbsten/Routing.kt
+ staticGeneration("/style.css") {
+     call.respondCss {
+         // TODO
+     }
+ }

先ほど用意したトップページにh1タグを置いたので適当にスタイルを当ててみます。

/src/main/kotlin/io/github/tbsten/Routing.kt
  staticGeneration("/style.css") {
      call.respondCss {
+         body {
+             fontSize = 16.px
+         }
+         h1 {
+             backgroundColor = Color.red
+             color = Color.white
+             fontSize = 32.px
+         }
      }
  }

最後に忘れずに linkタグ (cssを読み込むタグ) を htmlの方に 追加しておきます。

  staticGeneration("/") {
      call.respondHtml {
+         head {
+             link {
+                 rel = "stylesheet"
+                 href = "./style.css"
+             }
+         }
          body { ... }
     }
  }

ここまでできたら一度 開発用サーバを立ち上げてきちんと表示されているか見てみましょう。
と言っても普段の ktor と何ら変わりなく、./gradlew run でサーバを立ち上げ http://localhost:8080 をブラウザで開きます。

(Auto Reload機能 を有効にしていないため、変更するたびにサーバを再起動する必要があることに注意してください。余裕があればこちらの設定もして見てもいいかもしれません)

./gradlew run
# TODO http://localhost:8080 を開く

すると以下のようにきちんと表示されているはずです!

目に悪い配色だよ!

トップページがうまく表示されていそうなので、ブログの詳細ページもつくって見ましょう。

/src/main/kotlin/io/github/tbsten/Routing.kt
+ staticGeneration(
+     "/blog/{blogId}",
+     staticPaths = { listOf("/blog/1", "/blog/2").asFlow() },
+ ) {
+     val blogId = call.parameters["blogId"]
+     call.respondHtml {
+         head {
+             link {
+                 rel = "stylesheet"
+                 href = "../style.css"
+             }
+         }
+         body {
+             h1 { +"Blog - $blogId" }
+             p {
+                 +"Blog $blogId の内容 ".repeat(100)
+             }
+         }
+     }
+ }

こちらも問題なく表示されるはずです。

雑な見た目ですが これでサンプルアプリは一旦完成しました。
今度はこれを Github Actions にデプロイしていきましょう。

5. staticGenerate task で出力

2 . ライブラリのセットアップ でセットアップした gradle plugin には staticGenerate GradleTask が入っています。こちらを呼び出して今までサーバを立ち上げないと見れなかったものをhtmlファイルとして出力してみましょう。

./gradlew staticGenerate

緑色の BUILD SUCCESSFUL が表示されれば 成功です。出力結果は build/ktor-static-generate-output に出力されています。以下のように htmlファイルやcssファイルが出力されていることが確認できると思います。

しかしよくみると /style.css に対応するファイルの名前が /style.css になってしまっています。

通常 Ktor Static Generate ライブラリは ContentTypeから拡張子を予測して その拡張子をパスの後ろに付与してファイル出力します。つまり ContentTypeがCSSで帰ってくる場合は 元々のパス.css というようなファイルが出力されることになるのですが、元々のパスに .css がすでに含まれているためこのような変なファイル名になってしまっています。

Ktor Static Generate ではこの拡張子の挙動を変更できます。staticGeneration 関数の extension 引数を使います。今回は変に推測せずに拡張子はつけいないようにしたいので extension = "" を指定します。

/src/main/kotlin/io/github/tbsten/Routing.kt
  staticGeneration(
      "/style.css",
+     extension = "",
  ) {

一旦 ./gradlew clean でリフレッシュしてからもう一度 ./gradlew staticGenerate して見ましょう。今度は style.css のように出力されるはずです。

./gradlew clean
./gradlew staticGenerate

以上で staticGenerate を使ってファイル出力までできるようになりました!

最後に Github Pages にデプロイするところまでやって見ましょう。

6. Github Pages にデプロイする。

以下のようなファイルを用意します。

/.github/workflows/deploy.yaml
name: Deploy Pages

on:
  push:
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: write # for peaceiris/actions-gh-pages

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout sources
        uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: 17
      - uses: gradle/actions/setup-gradle@v4

      - name: Build App
        run: ./gradlew staticGenerate

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./build/ktor-static-generate-output

簡単な解説

  • mainブランチにpushした時 または 手動で起動
  • デプロイフロー
    • actions/checkout actionで コードをチェックアウト
    • javaとgradleをセットアップ
    • ./gradlew staticGenerate で前述と同様に static generation
    • ./build/ktor-static-generate-output に出力された ファイルをpagesにデプロイ

ここまできたら Github のRepositoryを作って push しておきます。リポジトリの可視性はpublicにしておきましょう。

pushまでしたら リポジトリの Actions タブを開きます。デプロイのActionが元気よく走っていることでしょう。ビルドログを眺めながらコーヒーでも煎れて優雅に待ちましょう。ただこのAction はデプロイをするのではなく、ビルド結果を gh-pages というブランチにpushするだけです。本当のデプロイはこの後 設定していくことになります。

無事 Actionが通ったら Settings -> (サイドバー) Pages -> Branch を gh-pages にして Save ボタンを押します。

押下後もう一度 Actionsタブに移動すると 再度 Actionが走り始めます。この Action が完了すればデプロイ完了です。

Action の Summary の図の中に表示されているリンクがデプロイされたリンクになります( https://<user-name>.github.io/<repository-name> の形式になるはず)。

きちんと表示されていそうでしたー! 🙌


これにてハンズオン編は終了です。

仕組み

このセクションでは Ktor Static Generation ライブラリの実装の詳細に迫っていきます。

構成

Ktor Static Generation ライブラリ は gradle-plugin モジュール と ライブラリモジュール 2つの モジュールから構成されています。それぞれ以下のような役割があります。

モジュール ライブラリ
Gradle Plugin Gradle Plugin を定義し staticGenerate Taskを配布する。
ライブラリモジュール ランタイムに必要な Routing.staticGeneration() generateStatic() などを公開する。

KtorStaticGenerationTask は ただのJavaExec task

gradle-plugin モジュールから配布される staticGenerate タスクは KtorStaticGenerationTask というクラスとしてregisterされますが、これはほとんどただのJavaExec タスク (./gradlew run とかで使うやつ) です。
序盤のセットアップで mainClassclasspath を設定していたのはこれが理由です。

またこのような構成になっていることで、ライブラリユーザが自由に対象SourceSetを変更することもできる余地が残っています。

generateStatic() は ktor のテストAPIを利用してサーバを動かしている

エントリーポイントでmain関数から呼び出す generateStatic() 関数の内部では ktorのテスト実行用関数であるrunTestApplication() 関数 が利用されています。
runTestApplicationのラムダ内で使えるHttpClientを使ってstaticPathsに設定されたすべてのpathにリクエストを送り レスポンスをファイルに出力する、といった手はずで実装されているといった感じです。

そのため test ではないソースセットに io.ktor:ktor-server-test-host が入っているちょっと不思議な構成になっています。

https://github.com/TBSten/ktor-static-generation/blob/8b04525f46c95dfa7ad5be449304eb46f94fa509/ktor-static-generation/build.gradle.kts#L11-L14

test ではないので、できれば CLI等からも使いやすいように、ktorライブラリ側で名前を変えるなり モジュール分けするなりして欲しいなと思ってます が jetbrainsさんにはそんなに期待してないです

Discussion