Closed66

サーバーサイド Kotlin で Spring Boot の Getting Started をやってみる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

今後の仕事でサーバーサイド Kotlin を使うかもしれないので今のうちに基本的な使い方を調べておこうと思う。

  • Getting Started / Hello World
  • REST API の作成
  • HTTP リクエストの送信
  • JSON エンコード/デコード
  • 環境変数ロード
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Kotlin に関する過去のスクラップ

実は過去に Kotlin に関する短いスクラップをいくつか書いている。

https://zenn.dev/tatsuyasusukida/scraps/629dbe48038415

https://zenn.dev/tatsuyasusukida/scraps/896374ae20e27a

https://zenn.dev/tatsuyasusukida/scraps/e80907b730dba5

これらのスクラップを書いたときは Jetpack Compose を使って Android アプリ開発に Kotlin を使おうと思っていた。

今はサーバーサイドで Kotlin を使おうとしている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エディタは何を使えば良い?

やはり IntelliJ IDEA なのかな?

入門!実践!サーバーサイド Kotlin によると VSCode と IntelliJ IDEA のどちらでも良いが初心者のおすすめは IntelliJ IDEA のようだ。

https://www.amazon.co.jp/dp/B082H8GWMR

サンプルなのに開発環境のページまで読ませてもらえるのはありがたい、感謝。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

暇なのでプロジェクトを作成する

下記の 3 つの方法がある。

  • Web ページを使用する
  • コマンドを実行する
  • IntelliJ IDEA を使う(Ultimate 版のみ)

Web ページを使用する場合は https://start.spring.io/ にアクセスする。

コマンドを使用する場合は下記のとおり。

コマンド
mkdir -p ~/workspace/kotlin/blog && cd ~/workspace/kotlin/blog
curl https://start.spring.io/starter.zip \
  -d language=kotlin \
  -d type=gradle-project-kotlin \
  -d dependencies=web,mustache,jpa,h2,devtools \
  -d packageName=com.example.blog \
  -d name=Blog \
  -o blog.zip
unzip blog.zip
rm -f blog.zip

特にこだわりがなければコマンドの方が楽ちん。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

IntelliJ IDEA のインストール

ダウンロードした dmg ファイルを開いて IntelliJ IDEA CE を Applications ディレクトリにドラッグアンドドロップする。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

初めてのコーディング

コマンド
touch src/main/kotlin/com/example/blog/HtmlController.kt
src/main/kotlin/com/example/blog/HtmlController.kt
package com.example.blog

import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping

@Controller
class HtmlController {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = "Blog"
    return "blog"
  }
}

VSCode + TypeScript に慣れすぎていてコード補完が効かないのが不安でしょうがない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Gradle エラー

何やらエラーが出ている。

No matching variant of org.springframework.boot:spring-boot-gradle-plugin:3.0.5 was found. The consumer was configured to find a runtime of a library compatible with Java 8, packaged as a jar, and its dependencies declared externally, as well as attribute 'org.gradle.plugin.api-version' with value '7.6.1' but:

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Java のバージョンを上げてみる

Java のバージョンを 17 にしてみる。

コマンド
brew install openjdk@17
コンソール出力
==> Installing openjdk@17
==> Pouring openjdk@17--17.0.6.monterey.bottle.tar.gz
==> Caveats
For the system Java wrappers to find this JDK, symlink it with
  sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk

openjdk@17 is keg-only, which means it was not symlinked into /usr/local,
because this is an alternate version of another formula.

If you need to have openjdk@17 first in your PATH, run:
  echo 'export PATH="/usr/local/opt/openjdk@17/bin:$PATH"' >> ~/.zshrc

For compilers to find openjdk@17 you may need to set:
  export CPPFLAGS="-I/usr/local/opt/openjdk@17/include"
==> Summary
🍺  /usr/local/Cellar/openjdk@17/17.0.6: 634 files, 304.8MB
==> Running `brew cleanup openjdk@17`...
Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).

コンソール出力に従ってシンボリックリンクを作成する。

コマンド
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk

この時点で java -version を実行すると下記の結果が得られるので大丈夫そう。

実行結果
openjdk version "17.0.6" 2023-01-17
OpenJDK Runtime Environment Homebrew (build 17.0.6+0)
OpenJDK 64-Bit Server VM Homebrew (build 17.0.6+0, mixed mode, sharing)

念のため PATH も通しておく。

コマンド
echo 'export PATH="/usr/local/opt/openjdk@17/bin:$PATH"' >> ~/.zshrc

こちらの記事がとてもわかりやすかった。

https://zenn.dev/hayato94087/articles/c0345e6c2c53e7

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

引き続きコーディング

コマンド
touch src/main/resources/templates/header.mustache
touch src/main/resources/templates/footer.mustache
touch src/main/resources/templates/blog.mustache
src/main/resources/templates/header.mustache
<html>
<head>
  <title>{{title}}</title>
</head>
<body>
src/main/resources/templates/footer.mustache
</body>
</html>
src/main/resources/templates/blog.mustache
{{> header}}

<h1>{{title}}</h1>

{{> footer}}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Web サーバー起動

BlogApplication.kt を開いて main 関数の左側にある ▶︎ ボタンを押す。

しかし Java のバージョンが古いことによるエラーメッセージが表示されてしまった。

エラーメッセージ
objc[33430]: Class JavaLaunchHelper is implemented in both /Library/Java/JavaVirtualMachines/jdk1.8.0_101.jdk/Contents/Home/bin/java (0x10dcd34c0) and /Library/Java/JavaVirtualMachines/jdk1.8.0_101.jdk/Contents/Home/jre/lib/libinstrument.dylib (0x10de9b4e0). One of the two will be used. Which one is undefined.
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError: com/example/blog/BlogApplicationKt has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 52.0
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
	at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
	at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
	at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
	at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
	at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
	at java.security.AccessController.doPrivileged(Native Method)
	at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)

Process finished with exit code 1
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

IntelliJ IDEA で Java バージョン変更

こちらの記事がわかりやすい。

https://zenn.dev/tm35/articles/18287f1a3c020b

メニュー > File > Project Structure... または Command + ; を押す。

Project Settings > Project > Project SDK で SDK のバージョンを指定できる。

  • 1.8
  • Kotlin SDK
  • openjdk-17.jdk

多分 openjdk-17.jdk を選べば動きそうだが興味本位で Kotlin SDK を指定してみたところダメだった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

スクラップ名の変更

最初のタイトルは「サーバーサイド Kotlin について調べる」だった。

実態に鑑みて「サーバーサイド Kotlin で Spring Boot の Getting Started をやってみる」に変更しよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

JUnit インテグレーションテスト

コマンド
touch src/test/kotlin/com/example/blog/IntegrationTests.kt
src/test/kotlin/com/example/blog/IntegrationTests.kt
import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.client.getForEntity
import org.springframework.http.HttpStatus

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
    @Test
    fun `Assert blog page title, content and status code`() {
        val entity = restTemplate.getForEntity<String>("/")
        assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(entity.body).contains("<h1>Blog</h1>")
    }
}

IntelliJ IDEA でメソッド名の左に表示される ▶︎ ボタンを押すとテストを実行できる。

実行したところテストに失敗した。

色々調べると下記のエラーメッセージにたどり着いた。

エラーメッセージ
Caused by: java.lang.UnsupportedClassVersionError: IntegrationTests has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 52.0

また Java のバージョンが原因のようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

初心に帰る

心機一転、復習を兼ねてプロジェクトを作る所からやり直してみよう。

コマンド
mkdir -p ~/workspace/kotlin/blog2 && cd ~/workspace/kotlin/blog2
curl https://start.spring.io/starter.zip \
  -d language=kotlin \
  -d type=gradle-project-kotlin \
  -d dependencies=web,mustache,jpa,h2,devtools \
  -d packageName=com.example.blog2 \
  -d name=Blog \
  -o blog2.zip
unzip blog2.zip
rm -f blog2.zip
コマンド
touch src/main/kotlin/com/example/blog2/HtmlController.kt
touch src/main/resources/templates/header.mustache
touch src/main/resources/templates/footer.mustache
touch src/main/resources/templates/blog.mustache
touch src/test/kotlin/com/example/blog2/IntegrationTests.kt
src/main/kotlin/com/example/blog2/HtmlController.kt
package com.example.blog2

import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping

@Controller
class HtmlController {

  @GetMapping("/")
  fun blog(model: Model): String {
    model["title"] = "Blog"
    return "blog"
  }
}
src/main/resources/templates/header.mustache
<html>
<head>
  <title>{{title}}</title>
</head>
<body>
src/main/resources/templates/footer.mustache
</body>
</html>
src/main/resources/templates/blog.mustache
{{> header}}

<h1>{{title}}</h1>

{{> footer}}
src/test/kotlin/com/example/blog2/IntegrationTests.kt
import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.client.getForEntity
import org.springframework.http.HttpStatus

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
    @Test
    fun `Assert blog page title, content and status code`() {
        val entity = restTemplate.getForEntity<String>("/")
        assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(entity.body).contains("<h1>Blog</h1>")
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エラーメッセージが変わった

エラーメッセージ
Unable to find a @SpringBootConfiguration, you need to use @ContextConfiguration or @SpringBootTest(classes=...) with your test
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

もしかして冒頭に package が必要?

src/test/kotlin/com/example/blog2/IntegrationTests.kt
package com.example.blog2 // この行を追加しました。

import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.client.getForEntity
import org.springframework.http.HttpStatus

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
    @Test
    fun `Assert blog page title, content and status code`() {
        val entity = restTemplate.getForEntity<String>("/")
        assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(entity.body).contains("<h1>Blog</h1>")
    }
}

今度は成功した。

成功メッセージ
9:57:12: Execution finished ':test --tests "com.example.blog2.IntegrationTests.Assert blog page title, content and status code"'.
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

要注意

公式ドキュメントに記載されているソースコードでは packageimport が省略されているので注意が必要です。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

setup() と teardown()

テストケースの実行前後にメソッドを実行するにはまずプロパティファイルを作成する。

コマンド
mkdir -p src/test/resources
touch src/test/resources/junit-platform.properties
src/test/resources/junit-platform.properties
junit.jupiter.testinstance.lifecycle.default = per_class

次にアノテーションを使用して実行したいメソッドを書いていく。

src/test/kotlin/com/example/blog2/IntegrationTests.kt
package com.example.blog2

import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.client.getForEntity
import org.springframework.http.HttpStatus

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
    @BeforeAll
    fun setup() {
        println(">> Setup")
    }

    @Test
    fun `Assert blog page title, content and status code`() {
        val entity = restTemplate.getForEntity<String>("/")
        assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(entity.body).contains("<h1>Blog</h1>")
    }

    @AfterAll
    fun teardown() {
        println(">> Tear down")
    }
}

テストを実行したところ実行結果に >> Setup と >> Tear down が含まれていたので実行されている模様。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

LocalDateTime + String クラスの拡張

自分も詳しく知らないけど Kotlin では Extensions という機能を使って既存のクラスを拡張できるようだ。

https://kotlinlang.org/docs/extensions.html

日本語訳はこちら。

https://dogwood008.github.io/kotlin-web-site-ja/docs/reference/extensions.html

この機能を使って LocalDateTime に format メソッドを追加してみる。

コマンド
touch src/main/kotlin/com/example/blog2/Extensions.kt
src/main/kotlin/com/example/blog2/Extensions.kt
package com.example.blog2

import java.time.LocalDateTime
import java.time.format.DateTimeFormatterBuilder
import java.time.temporal.ChronoField
import java.util.*

fun LocalDateTime.format(): String = this.format(englishDateFormatter)

private val daysLookup = (1..31).associate { it.toLong() to getOrdinal(it) }

private val englishDateFormatter = DateTimeFormatterBuilder()
        .appendPattern("yyyy-MM-dd")
        .appendLiteral(" ")
        .appendText(ChronoField.DAY_OF_MONTH, daysLookup)
        .appendLiteral(" ")
        .appendPattern("yyyy")
        .toFormatter((Locale.ENGLISH))

private fun getOrdinal(n: Int) = when {
    n in 11..13 -> "${n}th"
    n % 10 == 1 -> "${n}st"
    n % 10 == 2 -> "${n}nd"
    n % 10 == 3 -> "${n}rd"
    else -> "${n}th"
}

fun String.toSlug() = lowercase(Locale.getDefault())
        .replace("\n", " ")
        .replace("[^a-z\\d\\s]".toRegex(), " ")
        .split(" ")
        .joinToString("-")
        .replace("-+".toRegex(), "-")

手っ取り早く試したい場合は HtmlController.kt に変更を加える。

src/main/kotlin/com/example/blog2/HtmlController.kt
package com.example.blog2

import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping
import java.time.LocalDateTime

@Controller
class HtmlController {

    @GetMapping("/")
    fun blog(model: Model): String {
        model["title"] = LocalDateTime.now().format() + "My Blog".toSlug()
        return "blog"
    }
}

成功すると http://localhost:8080 にアクセスすると 2023-04-12 12th 2023my-blog と表示される。

変更を反映するにはサーバーを再起動する必要があるのでかなり面倒。

ウォッチモードが無いか後から探してみようと思う。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

今日はここまで

今日やったことを振り返る。

  • Java バージョン 17 インストール
  • Web サーバー起動
  • JUnit テスト実行
  • LocalDateTime + String クラス拡張

今のところ順調に進んでいて良かった。

次回は JPA を使ったデータ永続化の手順を検証してみようと思う。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エンティティのコーディング

コマンド
touch src/main/kotlin/com/example/blog2/Entities.kt
src/main/kotlin/com/example/blog2/Entities.kt
package com.example.blog2

import com.example.blog2.toSlug
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.Id
import jakarta.persistence.ManyToOne
import java.time.LocalDateTime

@Entity
class Article(
    var title: String,
    var headline: String,
    var content: String,
    @ManyToOne var author: User,
    var slug: String = title.toSlug(),
    var addedAt: LocalDateTime = LocalDateTime.now(),
    @Id @GeneratedValue var id: Long? = null
)

@Entity
class User(
    var login: String,
    var firstname: String,
    var lastname: String,
    var description: String? = null,
    @Id @GeneratedValue var id: Long? = null
)
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

リポジトリのコーディング

コマンド
touch src/main/kotlin/com/example/blog2/Repositories.kt
src/main/kotlin/com/example/blog2/Repositories.kt
package com.example.blog2

import org.springframework.data.repository.CrudRepository

interface ArticleRepository : CrudRepository<Article, Long> {
    fun findBySlug(slug: String): Article?
    fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}

interface UserRepository : CrudRepository<User, Long> {
    fun findByLogin(login: String): User?
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストコードのコーディング

コマンド
touch src/test/kotlin/com/example/blog2/RepositoriesTests.kt
src/test/kotlin/com/example/blog2/RepositoriesTests.kt
package com.example.blog2

import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.data.repository.findByIdOrNull

@DataJpaTest
class RepositoriesTests @Autowired constructor(
    val entityManager: TestEntityManager,
    val userRepository: UserRepository,
    val articleRepository: ArticleRepository
) {
    @Test
    fun `When findByIdOrNull then return Article`() {
        val johnDoe = User("johnDoe", "John", "Doe")
        entityManager.persist(johnDoe)
        val article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
        entityManager.persist(article)
        entityManager.flush()
        val found = articleRepository.findByIdOrNull(article.id!!)
        assertThat(found).isEqualTo(article)
    }

    @Test
    fun `When findByLogin then return User`() {
        val johnDoe = User("johnDoe", "John", "Doe")
        entityManager.persist(johnDoe)
        entityManager.flush()
        val user = userRepository.findByLogin(johnDoe.login)
        assertThat(user).isEqualTo(johnDoe)
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストの実行

テストメソッドの隣の ▶︎ ボタンを押してテストを実行したら下記のエラーメッセージが表示された。

エラーメッセージ
A problem occurred configuring root project 'demo'.
> Could not resolve all files for configuration ':classpath'.
   > Could not resolve org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.7.22.
     Required by:
         project : > org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:1.7.22 > org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.22
         project : > org.jetbrains.kotlin.plugin.jpa:org.jetbrains.kotlin.plugin.jpa.gradle.plugin:1.7.22 > org.jetbrains.kotlin:kotlin-noarg:1.7.22
      > Multiple incompatible variants of org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.8.0 were selected:
           - Variant org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.8.0 variant gradle71RuntimeElements has attributes {org.gradle.category=library, org.gradle.dependency.bundling=external, org.gradle.jvm.environment=standard-jvm, org.gradle.jvm.version=8, org.gradle.libraryelements=jar, org.gradle.plugin.api-version=7.1, org.gradle.status=release, org.gradle.usage=java-runtime}
           - Variant org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.8.0 variant gradle76RuntimeElements has attributes {org.gradle.category=library, org.gradle.dependency.bundling=external, org.gradle.jvm.environment=standard-jvm, org.gradle.jvm.version=8, org.gradle.libraryelements=jar, org.gradle.plugin.api-version=7.6, org.gradle.status=release, org.gradle.usage=java-runtime}

* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Web サーバーも起動できない

昨日まで動いていた BlogApplication.kt の main メソッドも起動できなくなっていた。

したがってテストコードに固有の問題では無さそうだ。

今日開始する時に昨日の再現性の確認から始めれば良かったな。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

build.gradle.kts を元に戻したら起動できた

build.gradle.kts に追加した部分をコメントアウトした。

build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.0.5"
	id("io.spring.dependency-management") version "1.1.0"
	kotlin("jvm") version "1.7.22"
	kotlin("plugin.spring") version "1.7.22"
	kotlin("plugin.jpa") version "1.7.22"
//	kotlin("plugin.allopen") version "1.8.0"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-mustache")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	developmentOnly("org.springframework.boot:spring-boot-devtools")
	runtimeOnly("com.h2database:h2")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "17"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

//allOpen {
//	annotation("jakarta.persistence.Entity")
//	annotation("jakarta.persistence.Embeddable")
//	annotation("jakarta.persistence.MappedSuperclass")
//}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

allopen プラグイン

build.gradle.kts で "plugin.allopen" だけ追加して Web サーバーを再起動したら失敗した。

build.gradle.kts
plugins {
	kotlin("plugin.allopen") version "1.8.0"
}

原因はこの 1 行にあると思われる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

allopen か all-open か

IntelliJ IDEA に allopen じゃなくて all-open ではないですかと提案されるが実際に all-open にすると起動できなくなる。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

allopen のバージョンに何を指定すれば良いか

plugin.spring や plugin.jpa に合わせて 1.7.22 を指定してみたら Web サーバーは起動した。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テスト再実行

再実行したらテストは起動した様子だが別のエラーが発生した。

エラーメッセージ
Syntax error in SQL statement "insert into [*]user (description, firstname, lastname, login, id) values (?, ?, ?, ?, ?)"; expected "identifier"; SQL statement:
insert into user (description, firstname, lastname, login, id) values (?, ?, ?, ?, ?) [42001-214]
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テーブル名などをクオートする

src/main/resources/application.properties に下記の内容を設定する。

src/main/resources/application.properties
spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions = true
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テスト成功!

コンソールに SQL 文が出力される。

テストログ(抜粋)
...
Hibernate: drop table if exists "article" cascade 
Hibernate: drop table if exists "user" cascade 
Hibernate: drop sequence if exists "article_seq"
Hibernate: drop sequence if exists "user_seq"
Hibernate: create sequence "article_seq" start with 1 increment by 50
Hibernate: create sequence "user_seq" start with 1 increment by 50
Hibernate: create table "article" ("id" bigint not null, "added_at" timestamp(6), "content" varchar(255), "headline" varchar(255), "slug" varchar(255), "title" varchar(255), "author_id" bigint, primary key ("id"))
Hibernate: create table "user" ("id" bigint not null, "description" varchar(255), "firstname" varchar(255), "lastname" varchar(255), "login" varchar(255), primary key ("id"))
Hibernate: alter table if exists "article" add constraint "FKfhk3yc24nq2uawud4m6pd89q2" foreign key ("author_id") references "user"
...
Hibernate: select next value for "user_seq"
Hibernate: select next value for "article_seq"
Hibernate: insert into "user" ("description", "firstname", "lastname", "login", "id") values (?, ?, ?, ?, ?)
Hibernate: insert into "article" ("added_at", "author_id", "content", "headline", "slug", "title", "id") values (?, ?, ?, ?, ?, ?, ?)
Hibernate: select next value for "user_seq"
Hibernate: insert into "user" ("description", "firstname", "lastname", "login", "id") values (?, ?, ?, ?, ?)
Hibernate: select u1_0."id",u1_0."description",u1_0."firstname",u1_0."lastname",u1_0."login" from "user" u1_0 where u1_0."login"=?
Hibernate: drop table if exists "article" cascade 
Hibernate: drop table if exists "user" cascade 
Hibernate: drop sequence if exists "article_seq"
Hibernate: drop sequence if exists "user_seq"
...

テーブル名などがしっかりクオートされている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

疑問

データベースからデータを持ってくる処理などは一切書いていないのに動いているのが不思議。

よくわからないが findBySlug などメソッド名から自動的にコードが生成されているのだろうか?

あとデータは実際にどこに保存されているのだろう。

エラーメッセージに H2 という単語があったので、もしかしたら H2 という RDB が使われているのかも知れない。

https://www.h2database.com/html/main.html

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テンプレートのコーディング

src/main/resources/templates/blog.mustache
{{> header}}

<h1>{{title}}</h1>

<div class="articles">

  {{#articles}}
    <section>
      <header class="article-header">
        <h2 class="article-title"><a href="/article/{{slug}}">{{title}}</a></h2>
        <div class="article-meta">By <strong>{{author.firstname}}</strong>, on <strong>{{addedAt}}</strong></div>
      </header>
      <div class="article-description">
        {{headline}}
      </div>
    </section>
  {{/articles}}
</div>

{{> footer}}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Emmet を使う

IntelliJ IDEA に提案された通り Handlebars/Mustache プラグインをインストールしたら Emmet を使えるようになった。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

さらにテンプレートのコーディング

コマンド
touch src/main/resources/templates/article.mustache
src/main/resources/templates/article.mustache
{{> header}}

<section class="article">
    <header class="article-header">
        <h1 class="article-title">{{article.title}}</h1>
        <p class="article-meta">
            By <strong>{{article.author.firstname}}</strong>,
            on <strong>{{article.addedAt}}}</strong>
        </p>

        <div class="article-description">
            {{article.headline}}

            {{article.content}}
        </div>
    </header>
</section>

{{> footer}}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コントローラーのコーディング

src/main/kotlin/com/example/blog2/HtmlController.kt
package com.example.blog2

import org.springframework.http.HttpStatus
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.ui.set
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime

@Controller
class HtmlController(private val repository: ArticleRepository) {

    @GetMapping("/")
    fun blog(model: Model): String {
        model["title"] = "Blog"
        model["articles"] = repository.findAllByOrderByAddedAtDesc().map { it.render() }
        return "blog"
    }

    @GetMapping("/article/{slug}")
    fun article(@PathVariable slug: String, model: Model): String {
        val article = repository
            .findBySlug(slug)
            ?.render()
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")

        model["title"] = article.title
        model["article"] = article
        return "article"
    }

    fun Article.render() = RenderedArticle(
        slug,
        title,
        headline,
        content,
        author,
        addedAt.format(),
    )

    data class RenderedArticle(
        val slug: String,
        val title: String,
        val headline: String,
        val content: String,
        val author: User,
        val addedAt: String
    )
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コンフィグレーションのコーディング

コマンド
touch src/main/kotlin/com/example/blog2/BlogConfiguration.kt
src/main/kotlin/com/example/blog2/BlogConfiguration.kt
package com.example.blog2

import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class BlogConfiguration {
    @Bean
    fun databaseInitializer(userRepository: UserRepository, articleRepository: ArticleRepository) = ApplicationRunner {
        val johnDoe = userRepository.save(User("johnDoe", "John", "Doe"))

        articleRepository.save(Article(
            title = "Lorem",
            headline = "Lorem",
            content = "dolor sit amet",
            author = johnDoe
        ))
        
        articleRepository.save(Article(
            title = "Ipsum",
            headline = "Ipsum",
            content = "dolor sit amet",
            author = johnDoe
        ))
    }
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストコードのコーディング

src/test/kotlin/com/example/blog2/IntegrationTests.kt
package com.example.blog2

import org.assertj.core.api.AssertionsForClassTypes.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.client.getForEntity
import org.springframework.http.HttpStatus

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {
    @BeforeAll
    fun setup() {
        println(">> Setup")
    }

    @Test
    fun `Assert blog page title, content and status code`() {
        println(">> Assert Blog page title, content and status code")
        val entity = restTemplate.getForEntity<String>("/")
        assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(entity.body).contains("<h1>Blog</h1>", "Lorem")
    }

    @Test
    fun `Assert article page title, content and status code`() {
        println(">> Assert article page title, content and status code")
        val title = "Lorem"
        val entity = restTemplate.getForEntity<String>("/article/${title.toSlug()}")
        assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(entity.body).contains(title, "Lorem", "dolor sit amet")
    }

    @AfterAll
    fun teardown() {
        println(">> Tear down")
    }
}

テストクラスの ▶︎ ボタンを押してテストを実行したら成功した。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

REST API の追加

HttpControllers.kt に下記の内容を追記する。

src/main/kotlin/com/example/blog2/HtmlController.kt
@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {
    @GetMapping("/")
    fun findAll() = repository.findAllByOrderByAddedAtDesc()

    @GetMapping("/{slug}")
    fun findOne(@PathVariable slug: String) =
        repository.findBySlug(slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
}

@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {
    @GetMapping("/")
    fun findAll() = repository.findAll()

    @GetMapping("/{login}")
    fun findOne(@PathVariable login: String) =
        repository.findByLogin(login) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

動作確認

この時点で http://localhost:8080/api/article/http://localhost:8080/api/user/ にアクセスしてみる。

/api/article/
[
  {
    "title": "Ipsum",
    "headline": "Ipsum",
    "content": "dolor sit amet",
    "author": {
      "login": "johnDoe",
      "firstname": "John",
      "lastname": "Doe",
      "description": null,
      "id": 1
    },
    "slug": "ipsum",
    "addedAt": "2023-04-13T11:00:50.797998",
    "id": 2
  },
  {
    "title": "Lorem",
    "headline": "Lorem",
    "content": "dolor sit amet",
    "author": {
      "login": "johnDoe",
      "firstname": "John",
      "lastname": "Doe",
      "description": null,
      "id": 1
    },
    "slug": "lorem",
    "addedAt": "2023-04-13T11:00:50.794377",
    "id": 1
  }
]
/api/user/
[
  {
    "login": "johnDoe",
    "firstname": "John",
    "lastname": "Doe",
    "description": null,
    "id": 1
  }
]

何もしていないのに JSON で出力されるのはすごいけどブラックボックスすぎて少し怖い。

文字列とかバイナリを返したい場合はどうすれば良いんだろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

API テスト準備

build.gradle.kts の dependencies に下記を追記する。

build.gradle.kts
testImplementation("org.springframework.boot:spring-boot-starter-test") {
  exclude(module = "mockito-core")
}
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("com.ninja-squad:springmockk:4.0.2")

testImplementation("org.springframework.boot:spring-boot-starter-test") については既に含まれているのでコメントアウトした方が良いかもしれない。

springmockk のバージョンは最新は 4.0.2 のようなのでこちらも合わせておく。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストコードのコーディング(途中)

コマンド
touch src/test/kotlin/com/example/blog2/HttpControllersTests.kt
src/test/kotlin/com/example/blog2/HttpControllersTests.kt
package com.example.blog2

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc

@WebMvcTest
class HttpControllersTests(@Autowired val mockMvc: MockMvc) {
    @MockkBean
    lateinit var userRepository: UserRepository

    @MockkBean
    lateinit var articleRepository: ArticleRepository

    @Test
    fun `List articles`() {
        val johnDoe = User("johnDoe", "John", "Doe")
        val lorem5Article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
        val ipsumArticle = Article("Ipsum", "Ipsum", "dolor sit amet", johnDoe)
        every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(lorem5Article, ipsumArticle)
        mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
    }
}

この時点で MockkBean, every, get の自動読み込みができないので何かがおかしい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

一旦クローズ

他に優先して調べることができたので一旦クローズする。

サーバーサイド Kotlin はまだまだ情報が少ないので本格的に使用するにはかなりの覚悟が必要そうな感じがした。

Getting Started についてもつまづきポイントというか初見殺しが多く、Java で Spring Boot を使用した経験がない人にはかなり辛いのではないかと思う。

一方、Kotlin は Java との相互運用性があり、Java 資産を活用できることや JVM で運用できることはとても魅力的に感じた。

プログラミング言語としての Kotlin も現代的な機能を多く備えており、Java と比べて表現力が高いので上手に使いこなせれば効率的に進められるのではないかと思う。

Java よりも Kotlin の方が書いていて楽しいので開発のモチベーション的にもメリットがある。

今回の調査を通じて Kotlin にますます興味を持てたので今後も機会を見つけてキャッチアップを続けていきたい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

まとめ

  • Java / Kotlin の Web アプリ開発フレームワークのデファクトスタンダードは Spring Boot らしい。
  • エディタには IntelliJ IDEA がおすすめ。
  • プロジェクトを作成するには spring initializr という Web ページを使用する。
  • Spring Boot 3.0.5 を使うためには Java バージョンを 8 → 17 に上げる必要がある。
  • Java のバージョンを上げるには OpenJDK v17 などをインストールする。
  • IntelliJ IDEA のキーボードショートカットを学ぶにはチートシートがおすすめ。
  • IntelliJ IDEA で Java バージョンを変更するには Project Structure モーダルを使う。
  • 途中で Java バージョンを変えると不具合につながるので注意する。
  • 冒頭の package を忘れがちなので注意する。
  • テストコードで setup() / teardown() をするには junit-platform.properties ファイル作成が必要になる。
  • Kotlin では Extensions 機能を使って既存クラスを拡張できる。
  • JPA を使うとデータを永続化できる。
  • JPA を使うには build.gradle.kts で allOpen を設定する必要がある。
  • JPA ではテーブルごとに Entity / Repository を定義する。
  • Repository ではメソッド名が規約(例:findBySlug)に従っていると実装が自動生成される。
  • allOpen プラグインを使うと指定したアノテーションを使った時に open キーワードを省略できる。
  • open キーワードを使うとサブクラスでメソッドなどをオーバーライドできるようになる。
  • allopen プラグインのバージョンは jvm バージョンに合わせる必要がある。
  • JPA が生成する SQL に User などの予約語が含まれるとエラーになる。
  • src/main/resources/application.properties に設定を加えるとクオートされるようになる。
  • IntelliJ IDEA で Handlebars/Mustache プラグインをインストールすると Emmet が使えるようになる。
  • コントローラーで GetMapping アノテーションを使うと GET エンドポイントを作成できる。
  • コンフィグレーションを使用するとサーバー起動時にデータベースにレコードを挿入できる。
  • RestController アノテーションを使うと REST エンドポイントを作成できる。
  • MockkBean でハマったが多分 Gradle ツールウィンドウでリロードすれば使えるようになる。
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

おわりに

Getting Started にしてはかなりハマり所が多くて上級者向けだと感じた。

まったく Spring Boot を知らない状態でいきなり Kotlin で始めたのも悪かったかも知れない。

面倒だけどまずは Java で Spring Boot に入門し、それから Kotlin で Spring Boot に入門した方が遠いようで近道かも知れない。

ただ Kotlin に慣れると Java が辛いので悩ましいところ。

今回は Java 17 を使ったが Java 8 を使いたいというニーズは根強そうなので Spring Boot 3 系ではなく 2 系を次は学んでみたい。

Spring Initializr を見たところ 2 系の最新バージョンは 2.7.11 はなのかな?

Spring Boot 自体は結構わかりやすいので良かった。

ただ JPA データ永続化の部分で何も書かなくても魔法のようにデータの読み書きができてしまったことはすごいと思ったと共に不安に感じた。

どういうしくみで動いているんだろう?

Spring Boot の学習リソースとしては書籍がたくさんあるのでありがたい反面、選択肢が多くて迷ってしまう。

Zenn でも先人たちが本を書いている。

https://zenn.dev/search?q=spring%2520boot&page=1&source=books

結局 Spring Boot のガイドやリファレンスドキュメントを読んだ方が良いかもしれない。

https://spring.io/guides

https://docs.spring.io/spring-boot/docs/current/reference/html/

2 系を使いたいが 3 系の方が情報量は多そう。

このスクラップは2023/04/14にクローズされました