Kotlin で Spring Boot Web アプリケーションの作成

このチュートリアルでは、Spring BootKotlin (英語) の機能を組み合わせてサンプルブログアプリケーションを効率的に構築する方法を説明します。

Kotlin から始める場合は、Kotlin ツアー (英語) を受講するか、Kotlin のコードサンプルを提供する Spring Framework リファレンスドキュメントを使用して言語を学習できます。

Spring Kotlin のサポートは、Spring Framework および Spring Boot のリファレンスドキュメントに記載されています。ヘルプが必要な場合は、 StackOverflow の spring および kotlin タグ (英語) で検索または質問するか、Kotlin Slack (英語) の #spring チャンネルで議論してください。

新しいプロジェクトを作成する

まず、Spring Boot アプリケーションを作成する必要がありますが、これはいくつかの方法で実行できます。

Initializr Web サイトの使用

https://start.spring.io にアクセスして、Kotlin 言語を選択してください。Gradle は、Kotlin で最も一般的に使用されるビルドツールであり、Kotlin プロジェクトの生成時にデフォルトで使用される Kotlin DSL を提供するため、これが推奨される選択肢です。ただし、Maven に慣れている場合は、Maven を使用することもできます。https://start.spring.io/#!language=kotlin&type=gradle-project-kotlin を使用して、デフォルトで Kotlin および Gradle を選択できることに注意してください。

  1. "Gradle" を選択 - 使用するビルドツールに応じて、Kotlin" または "Maven"

  2. 次のアーティファクト座標を入力します: blog

  3. 次の依存関係を追加します。

    • Spring Web

    • Mustache

    • Spring Data JDBC

    • H2 Database

    • Spring Boot DevTools

  4. プロジェクトの生成をクリックします。

.zip ファイルには、ルートディレクトリに標準プロジェクトが含まれているため、展開する前に空のディレクトリを作成することをお勧めします。

コマンドラインを使用する

コマンドラインから (英語) Initializr HTTP API を、たとえば UN*X のようなシステム上の curl で使用できます。

$ mkdir blog && cd blog
$ curl https://start.spring.io/starter.zip -d language=kotlin -d type=gradle-project-kotlin -d dependencies=web,mustache,jdbc,h2,devtools -d packageName=com.example.blog -d name=Blog -o blog.zip

Gradle を使用する場合は、-d type=gradle-project を追加します。

IntelliJ IDEA の使用

Spring Initializr は IntelliJ IDEA Ultimate エディションにも統合されており、コマンドラインまたは Web UI の IDE を離れることなく、新しいプロジェクトを作成およびインポートできます。

ウィザードにアクセスするには、ファイル | 新規 | プロジェクト、および Spring Initializr を選択します。

ウィザードの手順に従って、次のパラメーターを使用します。

  • 成果物: "blog"

  • タイプ: "Gradle - Kotlin」または "Maven"

  • 言語: Kotlin

  • 名前: "Blog"

  • 依存関係: "Spring Web Starter"、"Mustache"、"Spring Data JDBC"、"H2 Database"、"Spring Boot DevTools"

null 安全

Kotlin の重要な機能の一つは、null 安全性 (英語) です。これは、実行時に有名な NullPointerException に遭遇するのではなく、コンパイル時に null 値をクリーンに処理します。これにより、Optional のようなラッパーのコストを負担することなく、null 許容宣言と「値の有無」のセマンティクスを表現できるため、アプリケーションの安全性が向上します。Kotlin では、null 許容値を持つ関数型構造の使用が許可されていることにご注意ください。Kotlin の null 安全性に関する包括的なガイド (英語) を参照してください。

Java では型システムで null 安全性を表現することはできませんが、Spring はツールフレンドリーな JSpecify (英語) アノテーションを通じて API の null 安全性を提供します。Kotlin は、Java API の JSpecify アノテーションを Kotlin の null 可能性にすぐに変換します。

ビルドを理解する

プラグイン

明らかな Kotlin、Gradle (英語) Maven (英語) プラグインに加えて、デフォルト設定では kotlin-spring プラグイン (英語) が宣言されています。このプラグインは、Spring アノテーションまたはメタアノテーションが付与されたクラスとメソッド(Java とは異なり、Kotlin ではデフォルトの修飾子は final です)を自動的に開きます。これは、たとえば CGLIB プロキシに必要な open 修飾子を追加することなく、@Configuration または @Transactional Bean を作成できるため便利です。

build.gradle.kts
plugins {
    id("org.springframework.boot") version "4.0.1"
    id("io.spring.dependency-management") version "1.1.7"
    kotlin("jvm") version "2.2.21"
    kotlin("plugin.spring") version "2.2.21"
}

// ...

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
    }
}
pom.xml
<build>
    <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
    <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    <plugin>
        <artifactId>kotlin-maven-plugin</artifactId>
        <groupId>org.jetbrains.kotlin</groupId>
        <configuration>
            <args>
                <arg>-Xjsr305=strict</arg>
                <arg>-Xannotation-default-target=param-property</arg>
            </args>
            <compilerPlugins>
                <plugin>spring</plugin>
            </compilerPlugins>
        </configuration>
        <dependencies>
            <dependency>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-allopen</artifactId>
                <version>${kotlin.version}</version>
            </dependency>
        </dependencies>
    </plugin>
    </plugins>
</build>

依存関係

このような Spring Boot Web アプリケーションには Kotlin 固有のライブラリが必要であり、デフォルトで構成されています。

  • kotlin-stdlib は Kotlin 標準ライブラリです (Gradle で自動的に追加)

  • kotlin-reflect は Kotlin 反射ライブラリです

  • jackson-module-kotlin は、Kotlin クラスとデータクラスのシリアライゼーション / デシリアライゼーションのサポートを追加します (単一のコンストラクタークラスを自動的に使用できます。また、セカンダリコンストラクターまたは静的ファクトリを持つクラスもサポートされます。)

build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
    implementation("org.springframework.boot:spring-boot-starter-mustache")
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("tools.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    runtimeOnly("com.h2database:h2")
    runtimeOnly("org.springframework.boot:spring-boot-devtools")
    testImplementation("org.springframework.boot:spring-boot-starter-data-jdbc-test")
    testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
}
pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-mustache</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>tools.jackson.module</groupId>
        <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jdbc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-test-junit5</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

データベーススキーマ

Spring Data JDBC はテーブルを自動作成しないため、データベーススキーマファイルを作成する必要があります。

src/main/resources/schema.sql

CREATE TABLE IF NOT EXISTS "users" (
    "ID" BIGINT AUTO_INCREMENT PRIMARY KEY,
    "LOGIN" VARCHAR(255) NOT NULL UNIQUE,
    "FIRSTNAME" VARCHAR(255) NOT NULL,
    "LASTNAME" VARCHAR(255) NOT NULL,
    "DESCRIPTION" TEXT
);

CREATE TABLE IF NOT EXISTS "article" (
    "ID" BIGINT AUTO_INCREMENT PRIMARY KEY,
    "TITLE" VARCHAR(255) NOT NULL,
    "HEADLINE" VARCHAR(500) NOT NULL,
    "CONTENT" TEXT NOT NULL,
    "author_id" BIGINT NOT NULL,
    "SLUG" VARCHAR(255) NOT NULL UNIQUE,
    "ADDED_AT" TIMESTAMP NOT NULL,
    FOREIGN KEY ("author_id") REFERENCES "users"("ID")
);

生成されたアプリケーションを理解する

src/main/kotlin/com/example/blog/BlogApplication.kt

package com.example.blog

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class BlogApplication

fun main(args: Array<String>) {
    runApplication<BlogApplication>(*args)
}

Java と比較すると、セミコロンがないこと、空クラスに括弧がないこと(@Bean アノテーションを使用して Bean を宣言する必要がある場合は追加できます)、runApplication トップレベル関数が使用されていることに気付くでしょう。runApplication<BlogApplication>(*args) は SpringApplication.run(BlogApplication::class.java, *args) の Kotlin 慣用的な代替であり、次の構文でアプリケーションをカスタマイズするために使用できます。

src/main/kotlin/com/example/blog/BlogApplication.kt

fun main(args: Array<String>) {
    runApplication<BlogApplication>(*args) {
        setBannerMode(Banner.Mode.OFF)
    }
}

初めての Kotlin コントローラーの作成

シンプルなコントローラーを作成して、シンプルな Web ページを表示しましょう。

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"
    }
}

ここでは、既存の Spring 型に Kotlin 関数または演算子を追加できる Kotlin 拡張 (英語) を使用していることに注意してください。ここでは、model.addAttribute("title", "Blog") ではなく model["title"] = "Blog" と記述できるように、org.springframework.ui.set 拡張関数をインポートしています。Spring Framework KDoc API (英語) は、Java API を充実させるために提供されているすべての Kotlin 拡張機能をリストします。

また、関連する 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}}

BlogApplication.kt の main 機能を実行して Web アプリケーションを起動し、http://localhost:8080/ に移動すると、"Blog" という見出しのある落ち着いた Web ページが表示されます。

JUnit を使ったテスト

Spring Boot でデフォルトで使用される JUnit は、非 null の val プロパティの使用を可能にするコンストラクター / メソッドパラメーターのオートワイヤーや、通常の非静的メソッドで @BeforeAll/@AfterAll を使用する可能性など、Kotlin で非常に便利なさまざまな機能を提供します。

Kotlin で JUnit テストを書く

この例では、さまざまな機能をデモンストレーションするための統合テストを作成しましょう。

  • 表現力豊かなテスト関数名を提供するために、キャメルケースではなくバッククォートで囲まれた実際の文を使用します。

  • JUnit はコンストラクターとメソッドパラメーターの注入を許可しており、これは Kotlin の読み取り専用および非 NULL プロパティとよく適合します。

  • このコードは expectBody Kotlin 拡張を活用しています (それをインポートする必要があります)

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class IntegrationTests(@Autowired val restClient: RestTestClient) {

    @Test
    fun `Assert blog page title, content and status code`() {
        println(">> Assert blog page title, content and status code")
        restClient.get().uri("/")
            .exchangeSuccessfully()
            .expectBody<String>()
            .value { assertThat(it).contains("<h1>Blog</h1>", "Lorem") }
    }
}

テストインスタンスのライフサイクル

特定のクラスのすべてのテストの前または後に、メソッドを実行しなければならない場合があります。JUnit では、テストクラスはテストごとに 1 回インスタンス化されるため、これらのメソッドはデフォルトで静的である必要があります(これは Kotlin の companion object (英語) に相当し、非常に冗長で分かりにくいです)。

しかし、JUnit ではこのデフォルトの動作を変更し、テストクラスをクラスごとに 1 回ずつインスタンス化することができます。これは様々な方法 (英語) で実行できますが、ここではプロパティファイルを使用してプロジェクト全体のデフォルトの動作を変更します。

src/test/resources/junit-platform.properties

junit.jupiter.testinstance.lifecycle.default = per_class

この構成により、上記の IntegrationTests の更新バージョンに示すように、通常のメソッドで @BeforeAll および @AfterAll アノテーションを使用できるようになります。

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class IntegrationTests(@Autowired val restClient: RestTestClient) {

    @BeforeAll
    fun setup() {
        println(">> Setup")
    }

    @Test
    fun `Assert blog page title, content and status code`() {
        println(">> Assert blog page title, content and status code")
        restClient.get().uri("/")
            .exchangeSuccessfully()
            .expectBody<String>()
            .value { assertThat(it).contains("<h1>Blog</h1>", "Lorem") }
    }

    @Test
    fun `Assert article page title, content and status code`() {
        println(">> TODO")
    }

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

独自の拡張機能を作成する

Java のように抽象メソッドで util クラスを使用する代わりに、Kotlin では通常、Kotlin 拡張機能を介してそのような機能を提供します。ここでは、英語の日付形式のテキストを生成するために、既存の LocalDateTime 型に format() 関数を追加します。

src/main/kotlin/com/example/blog/Extensions.kt

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(), "-")

これらの拡張機能を次のセクションで活用します。

Spring Data JDBC でフェッチ

Spring Data JDBC では、データを取得するために不変のデータクラスを使用します。これは、Kotlin ではより慣用的なアプローチです。

プロパティとコンストラクターパラメーターを同時に宣言できる Kotlin プライマリコンストラクターの簡潔な構文 (英語) データクラス (英語) を使用してモデルを作成します。

src/main/kotlin/com/example/blog/Entities.kt

@Table("article")
data class Article(
    val title: String,
    val headline: String,
    val content: String,
    @Column("author_id")
    val author: AggregateReference<User, Long>,
    val slug: String = title.toSlug(),
    val addedAt: LocalDateTime = LocalDateTime.now(),
    @Id val id: Long? = null
)

@Table("users")
data class User(
    val login: String,
    val firstname: String,
    val lastname: String,
    val description: String? = null,
    @Id val id: Long? = null
)

ここでは、String.toSlug() 拡張を使用して、Article コンストラクターの slug パラメーターにデフォルト引数を指定していることに注意してください。デフォルト値を持つオプションパラメーターは、位置引数を使用する際に省略できるように、最後に定義されています(Kotlin は名前付き引数 (英語) もサポートしています)。Kotlin では、簡潔なクラス宣言を同じファイルにまとめることは珍しくないことに注意してください。

@Table アノテーションはクラスをデータベーステーブルにマッピングし、@Column は外部キー関連を表すために使用されます。Spring Data JDBC は、外部キー関連を表すために AggregateReference を使用します。AggregateReference は、関連エンティティ全体をロードするのではなく、ID のみを保存します。

また、Spring Data JDBC リポジトリを次のように宣言します。

src/main/kotlin/com/example/blog/Repositories.kt

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

interface UserRepository : CrudRepository<User, Long> {
    fun findByLogin(login: String): User?
}

また、基本的なユースケースが期待どおりに動作するかどうかを確認するための JDBC テストを作成します。

src/test/kotlin/com/example/blog/RepositoriesTests.kt

@DataJdbcTest
class RepositoriesTests @Autowired constructor(
    val userRepository: UserRepository,
    val articleRepository: ArticleRepository
) {

    @Test
    fun `When findByIdOrNull then return Article`() {
        val johnDoe = userRepository.save(User("johnDoe", "John", "Doe"))
        val article = articleRepository.save(
            Article("Lorem", "Lorem", "dolor sit amet", AggregateReference.to(johnDoe.id!!))
        )
        val found = articleRepository.findByIdOrNull(article.id!!)
        assertThat(found).isEqualTo(article)
    }

    @Test
    fun `When findByLogin then return User`() {
        val johnDoe = userRepository.save(User("johnDoe", "John", "Doe"))
        val user = userRepository.findByLogin(johnDoe.login)
        assertThat(user).isEqualTo(johnDoe)
    }
}
ここでは、Spring Data にデフォルトで提供される CrudRepository.findByIdOrNull Kotlin 拡張を使用します。これは、Optional ベースの CrudRepository.findById の null 可能バリアントです。詳細については、素晴らしい null はあなたの役に立つ、間違いではない (英語) ブログ投稿を参照してください。

ブログエンジンを実装する

"blog" Mustache テンプレートを更新します。

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}}

そして、新しい「記事」を作成します。

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>
    </header>

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

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

{{> footer}}

ブログページと記事ページをフォーマットされた日付でレンダリングするために、HtmlController を更新します。HtmlController は単一のコンストラクター(暗黙の @Autowired)を持つため、ArticleRepository と UserRepository の両方のコンストラクターパラメーターは自動的にオートワイヤリングされます。Spring Data JDBC では、ユーザー ID でユーザーを検索し、作成者関連を手動で解決する必要があることに注意してください。

src/main/kotlin/com/example/blog/HtmlController.kt

@Controller
class HtmlController(
    private val articleRepository: ArticleRepository,
    private val userRepository: UserRepository,
    private val properties: BlogProperties
) {

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

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

        val renderedArticle = article.render()
        model["title"] = renderedArticle.title
        model["article"] = renderedArticle
        return "article"
    }

    private fun Article.render(): RenderedArticle {
        val author = userRepository.findById(author.id)
            .orElseThrow { ResponseStatusException(NOT_FOUND, "Author not found") }

        return 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
    )
}

次に、新しい BlogConfiguration クラスにデータの初期化を追加します。Spring Data JDBC では、外部キー参照を作成するために AggregateReference.to() を使用していることに注意してください。

src/main/kotlin/com/example/blog/BlogConfiguration.kt

@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 = AggregateReference.to(johnDoe.id!!)
            )
        )

        articleRepository.save(
            Article(
                title = "Ipsum",
                headline = "Ipsum",
                content = "dolor sit amet",
                author = AggregateReference.to(johnDoe.id)
            )
        )
    }
}
コードの可読性を高めるために、名前付きパラメーターを使用している点に注意してください。AggregateReference.to() メソッドは、関連エンティティの ID のみを使用して参照を作成します。

また、それに応じて統合テストも更新します。

src/test/kotlin/com/example/blog/IntegrationTests.kt

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class IntegrationTests(@Autowired val restClient: RestTestClient) {

    @BeforeAll
    fun setup() {
        println(">> Setup")
    }

    @Test
    fun `Assert blog page title, content and status code`() {
        println(">> Assert blog page title, content and status code")
        restClient.get().uri("/")
            .exchangeSuccessfully()
            .expectBody<String>()
            .value { assertThat(it).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"
        restClient.get().uri("/article/${title.toSlug()}")
            .exchangeSuccessfully()
            .expectBody<String>()
            .value { assertThat(it).contains(title, "Lorem", "dolor sit amet") }
    }

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

Web アプリケーションを起動(または再起動)して http://localhost:8080/ に移動すると、クリック可能なリンクを含む記事のリストが表示され、特定の記事が表示されます。

HTTP API を公開する

@RestController アノテーション付きコントローラーを介して HTTP API を実装します。

src/main/kotlin/com/example/blog/HttpControllers.kt

@RestController
@RequestMapping("/api/article")
class ArticleController(
    private val articleRepository: ArticleRepository,
    private val userRepository: UserRepository
) {

    @GetMapping("/")
    fun findAll() = articleRepository.findAllByOrderByAddedAtDesc().map { it.toDto() }

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

    private fun Article.toDto(): ArticleDto {
        val author = userRepository.findById(author.id)
            .orElseThrow { ResponseStatusException(NOT_FOUND, "Author not found") }
        return ArticleDto(slug, title, headline, content, author, addedAt.format())
    }

    data class ArticleDto(
        val slug: String,
        val title: String,
        val headline: String,
        val content: String,
        val author: User,
        val addedAt: String
    )
}

@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(NOT_FOUND, "This user does not exist")
}

テストでは、統合テストの代わりに、Mockito (英語) に似ていますが Kotlin により適した @WebMvcTest および Mockk (英語) を活用します。

@MockBean および @SpyBean アノテーションは Mockito に固有であるため、Mockk に同様の @MockkBean および @SpykBean アノテーションを提供する SpringMockK [GitHub] (英語) を活用します。

build.gradle.kts
testImplementation("com.ninja-squad:springmockk:5.0.1")
pom.xml
<dependency>
    <groupId>com.ninja-squad</groupId>
    <artifactId>springmockk</artifactId>
    <version>5.0.1</version>
    <scope>test</scope>
</dependency>

src/test/kotlin/com/example/blog/HttpControllersTests.kt

@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", id = 1L)
        val authorRef = AggregateReference.to<User, Long>(1L)
        val lorem5Article = Article("Lorem", "Lorem", "dolor sit amet", authorRef)
        val ipsumArticle = Article("Ipsum", "Ipsum", "dolor sit amet", authorRef)
        every { userRepository.findById(1L) } returns Optional.of(johnDoe)
        every { articleRepository.findAllByOrderByAddedAtDesc() } returns listOf(lorem5Article, ipsumArticle)
        mockMvc.perform(get("/api/article/").accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk)
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("\$.[0].author.login").value(johnDoe.login))
            .andExpect(jsonPath("\$.[0].slug").value(lorem5Article.slug))
            .andExpect(jsonPath("\$.[1].author.login").value(johnDoe.login))
            .andExpect(jsonPath("\$.[1].slug").value(ipsumArticle.slug))
    }

    @Test
    fun `List users`() {
        val johnDoe = User("johnDoe", "John", "Doe")
        val janeDoe = User("janeDoe", "Jane", "Doe")
        every { userRepository.findAll() } returns listOf(johnDoe, janeDoe)
        mockMvc.perform(get("/api/user/").accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk)
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("\$.[0].login").value(johnDoe.login))
            .andExpect(jsonPath("\$.[1].login").value(janeDoe.login))
    }
}
$ は文字列の補間に使用されるため、文字列でエスケープする必要があります。

構成プロパティ

Kotlin では、アプリケーションプロパティを管理するための推奨される方法は、読み取り専用プロパティを使用することです。

src/main/kotlin/com/example/blog/BlogProperties.kt

@ConfigurationProperties("blog")
data class BlogProperties(var title: String, val banner: Banner) {
    data class Banner(val title: String? = null, val content: String)
}

次に、BlogApplication レベルで有効にします。

src/main/kotlin/com/example/blog/BlogApplication.kt

@SpringBootApplication
@EnableConfigurationProperties(BlogProperties::class)
class BlogApplication

application.properties の編集時に、カスタムプロパティが認識されるようになります(自動補完、検証など)。

src/main/resources/application.properties

blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.
spring.sql.init.mode=always

それに応じてテンプレートとコントローラーを編集します。

src/main/resources/templates/blog.mustache

{{> header}}

<div class="articles">

    {{#banner.title}}
    <section>
        <header class="banner">
            <h2 class="banner-title">{{banner.title}}</h2>
        </header>
        <div class="banner-content">
            {{banner.content}}
        </div>
    </section>
    {{/banner.title}}

    ...

</div>

{{> footer}}

src/main/kotlin/com/example/blog/HtmlController.kt

@Controller
class HtmlController(
    private val articleRepository: ArticleRepository,
    private val userRepository: UserRepository,
    private val properties: BlogProperties
) {

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

Web アプリケーションを再起動し、http://localhost:8080/ をリフレッシュすると、ブログのホームページにバナーが表示されます。

結論

これで、サンプル Kotlin ブログアプリケーションの構築が完了しました。ソースコードは GitHub から入手できます (英語) 。また、特定の機能について詳しく知りたい場合は、Spring Framework および Spring Boot のリファレンスドキュメントも参照してください。

コードを入手する

プロジェクト