使用 Spring Boot 和 Kotlin 構建 Web 應用程式

本教學課程展示如何通過結合 Spring BootKotlin 的強大功能,有效地構建一個範例部落格應用程式。

如果您是 Kotlin 的新手,可以閱讀 參考文檔、依照線上 Kotlin Koans 教學課程,或者直接使用 Spring Framework 參考文檔學習該語言,現在該文檔提供了 Kotlin 的程式碼範例。

Spring Kotlin 支援記錄在 Spring FrameworkSpring Boot 參考文檔中。 如果您需要幫助,請在 StackOverflow 上搜尋或提出帶有 springkotlin 標籤的問題,或在 Kotlin Slack#spring 頻道中進行討論。

建立新專案

首先,我們需要建立一個 Spring Boot 應用程式,這可以通過多種方式完成。

使用 Initializr 網站

訪問 https://start.spring.io 並選擇 Kotlin 語言。 Gradle 是 Kotlin 中最常用的構建工具,它提供了一個 Kotlin DSL,預設情況下在生成 Kotlin 專案時使用,因此這是推薦的選擇。 但是,如果您對 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 JPA

    • H2 資料庫

    • 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,jpa,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 版本中,允許您建立和導入新專案,而無需離開 IDE 使用命令列或 Web UI。

要訪問精靈,請轉到「檔案」|「新增」|「專案」,然後選擇「Spring Initializr」。

按照精靈的步驟使用以下參數

  • Artifact: "blog"

  • Type: "Gradle - Kotlin" 或 "Maven"

  • Language: Kotlin

  • Name: "Blog"

  • Dependencies: "Spring Web Starter"、"Mustache"、"Spring Data JPA"、"H2 Database" 和 "Spring Boot DevTools"

了解 Gradle 建置

如果您正在使用 Maven 建置,則可以跳到專用部分

外掛程式

除了顯而易見的 Kotlin Gradle 外掛程式之外,預設配置還聲明了 kotlin-spring 外掛程式,該外掛程式會自動打開使用 Spring 注釋或元注釋註釋或元註釋的類別和方法(與 Java 不同,Kotlin 中的預設限定符為 final)。 這有助於建立 @Configuration@Transactional bean,而無需新增 CGLIB 代理所需的 open 限定符。

為了能夠將 Kotlin 不可為空的屬性與 JPA 一起使用,還啟用了 Kotlin JPA 外掛程式。 它會為使用 @Entity@MappedSuperclass@Embeddable 註釋的任何類別生成無引數建構函式。

build.gradle.kts

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

plugins {
  id("org.springframework.boot") version "3.2.2"
  id("io.spring.dependency-management") version "1.1.4"
  kotlin("jvm") version "1.9.22"
  kotlin("plugin.spring") version "1.9.22"
  kotlin("plugin.jpa") version "1.9.22"
}

編譯器選項

Kotlin 的主要功能之一是 null 安全 - 它可以在編譯時乾淨地處理 null 值,而不是在執行時遇到著名的 NullPointerException。 這通過可為空性聲明和表達「值或無值」語義,而無需付出像 Optional 這樣的包裝器的代價,使應用程式更安全。 請注意,Kotlin 允許將函數式結構與可為空值一起使用; 請查看此 Kotlin null 安全綜合指南

儘管 Java 不允許人們在其類型系統中表達 null 安全,但 Spring Framework 通過在 org.springframework.lang 套件中聲明的工具友好型注釋,提供了整個 Spring Framework API 的 null 安全。 預設情況下,在 Kotlin 中使用的 Java API 的類型被識別為 平台類型,對其進行了空值檢查放寬。 Kotlin 對 JSR 305 注釋的支援 + Spring 可為空性注釋為整個 Spring Framework API 向 Kotlin 開發人員提供 null 安全,其優勢是在編譯時處理 null 相關問題。

可以通過新增帶有 strict 選項的 -Xjsr305 編譯器標誌來啟用此功能。

build.gradle.kts

tasks.withType<KotlinCompile> {
  kotlinOptions {
    freeCompilerArgs += "-Xjsr305=strict"
  }
}

依賴項

此 Spring Boot Web 應用程式需要 2 個 Kotlin 特定程式庫(標準程式庫使用 Gradle 自動新增),並且預設情況下已配置

  • kotlin-reflect 是 Kotlin 反射程式庫

  • jackson-module-kotlin 增加了對 Kotlin 類別和資料類別的序列化/反序列化的支援(可以自動使用單一建構函式類別,並且還支援具有輔助建構函式或靜態工廠的類別)

build.gradle.kts

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")
  runtimeOnly("com.h2database:h2")
  runtimeOnly("org.springframework.boot:spring-boot-devtools")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
}

最新版本的 H2 需要特殊配置才能正確轉義保留的關鍵字,例如 user

src/main/resources/application.properties

spring.jpa.properties.hibernate.globally_quoted_identifiers=true
spring.jpa.properties.hibernate.globally_quoted_identifiers_skip_column_definitions=true

Spring Boot Gradle 外掛程式自動使用通過 Kotlin Gradle 外掛程式聲明的 Kotlin 版本。

了解 Maven 建置

外掛程式

除了顯而易見的 Kotlin Maven 外掛程式之外,預設配置還聲明了 kotlin-spring 外掛程式,該外掛程式會自動打開使用 Spring 注釋或元注釋註釋或元註釋的類別和方法(與 Java 不同,Kotlin 中的預設限定符為 final)。 這有助於建立 @Configuration@Transactional bean,而無需新增 CGLIB 代理所需的 open 限定符。

為了能夠將 Kotlin 不可為空的屬性與 JPA 一起使用,還啟用了 Kotlin JPA 外掛程式。 它會為使用 @Entity@MappedSuperclass@Embeddable 註釋的任何類別生成無引數建構函式。

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>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-plugin</artifactId>
        <configuration>
          <compilerPlugins>
            <plugin>jpa</plugin>
            <plugin>spring</plugin>
          </compilerPlugins>
          <args>
            <arg>-Xjsr305=strict</arg>
          </args>
        </configuration>
        <dependencies>
          <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-noarg</artifactId>
            <version>${kotlin.version}</version>
          </dependency>
          <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-allopen</artifactId>
            <version>${kotlin.version}</version>
          </dependency>
        </dependencies>
      </plugin>
    </plugins>
  </build>

Kotlin 的主要功能之一是 null 安全 - 它可以在編譯時乾淨地處理 null 值,而不是在執行時遇到著名的 NullPointerException。 這通過可為空性聲明和表達「值或無值」語義,而無需付出像 Optional 這樣的包裝器的代價,使應用程式更安全。 請注意,Kotlin 允許將函數式結構與可為空值一起使用; 請查看此 Kotlin null 安全綜合指南

儘管 Java 不允許人們在其類型系統中表達 null 安全,但 Spring Framework 通過在 org.springframework.lang 套件中聲明的工具友好型注釋,提供了整個 Spring Framework API 的 null 安全。 預設情況下,在 Kotlin 中使用的 Java API 的類型被識別為 平台類型,對其進行了空值檢查放寬。 Kotlin 對 JSR 305 注釋的支援 + Spring 可為空性注釋為整個 Spring Framework API 向 Kotlin 開發人員提供 null 安全,其優勢是在編譯時處理 null 相關問題。

可以通過新增帶有 strict 選項的 -Xjsr305 編譯器標誌來啟用此功能。

另請注意,Kotlin 編譯器已配置為生成 Java 8 位元組碼(預設情況下為 Java 6)。

依賴項

此 Spring Boot Web 應用程式需要 3 個 Kotlin 特定程式庫,並且預設情況下已配置

  • kotlin-stdlib 是 Kotlin 標準程式庫

  • kotlin-reflect 是 Kotlin 反射程式庫

  • jackson-module-kotlin 增加了對 Kotlin 類別和資料類別的序列化/反序列化的支援(可以自動使用單一建構函式類別,並且還支援具有輔助建構函式或靜態工廠的類別)

pom.xml

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</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-web</artifactId>
  </dependency>
  <dependency>
    <groupId>com.fasterxml.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-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

了解生成的應用程式

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) 是 Kotlin 慣用替代方案,用於取代 SpringApplication.run(BlogApplication::class.java, *args),可用於使用以下語法自訂應用程式。

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

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

編寫您的第一個 Kotlin 控制器

讓我們建立一個簡單的控制器來顯示一個簡單的網頁。

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

}

請注意,我們在這裡使用 Kotlin 擴展,該擴展允許將 Kotlin 函數或運算符新增到現有的 Spring 類型。 在這裡,我們導入 org.springframework.ui.set 擴展函數,以便能夠編寫 model["title"] = "Blog" 而不是 model.addAttribute("title", "Blog")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.ktmain 函數啟動 Web 應用程式,然後轉到 https://127.0.0.1:8080/,您應該會看到一個帶有「部落格」標題的樸素網頁。

使用 JUnit 5 進行測試

Spring Boot 中預設使用的 JUnit 5 現在提供了各種非常適合 Kotlin 的功能,包括 建構函式/方法參數的自動裝配,該功能允許使用不可為空的 val 屬性,並且可以在常規非靜態方法上使用 @BeforeAll/@AfterAll

在 Kotlin 中編寫 JUnit 5 測試

為了這個範例,讓我們建立一個整合測試,以展示各種功能

  • 我們在反引號之間使用真實的句子而不是駝峰式大小寫,以提供表達力強的測試函數名稱

  • JUnit 5 允許注入建構函式和方法參數,這非常適合 Kotlin 的唯讀和不可為空的屬性

  • 此程式碼利用 getForObjectgetForEntity Kotlin 擴展(您需要導入它們)

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

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

}

測試執行個體生命週期

有時,您需要在給定類別的所有測試之前或之後執行一個方法。 與 Junit 4 一樣,JUnit 5 預設情況下要求這些方法是靜態的(在 Kotlin 中翻譯為 companion object,這非常冗長且不直接),因為測試類別每個測試僅實例化一次。

但是 Junit 5 允許您更改此預設行為,並每個類別實例化測試類別一次。 這可以通過 各種方式 完成,在這裡我們將使用屬性檔案來更改整個專案的預設行為

src/test/resources/junit-platform.properties

junit.jupiter.testinstance.lifecycle.default = per_class

使用此配置,我們現在可以在常規方法上使用 @BeforeAll@AfterAll 注釋,如上面 IntegrationTests 的更新版本所示。

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

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

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

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

}

建立您自己的擴展

在 Kotlin 中,通常會透過 Kotlin 擴充功能來提供這類功能,而不是像 Java 那樣使用具有抽象方法的 util 類別。在這裡,我們將為現有的 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(), "-")

我們將在下一節中利用這些擴充功能。

使用 JPA 持久化

為了讓延遲載入 (lazy fetching) 正常運作,實體 (entities) 應該要是 open,如 KT-28525 中所述。我們將使用 Kotlin 的 allopen 插件來達到此目的。

使用 Gradle

build.gradle.kts

plugins {
  ...
  kotlin("plugin.allopen") version "1.9.22"
}

allOpen {
  annotation("jakarta.persistence.Entity")
  annotation("jakarta.persistence.Embeddable")
  annotation("jakarta.persistence.MappedSuperclass")
}

或使用 Maven

pom.xml

<plugin>
  <artifactId>kotlin-maven-plugin</artifactId>
  <groupId>org.jetbrains.kotlin</groupId>
  <configuration>
    ...
    <compilerPlugins>
      ...
      <plugin>all-open</plugin>
    </compilerPlugins>
    <pluginOptions>
      <option>all-open:annotation=jakarta.persistence.Entity</option>
      <option>all-open:annotation=jakarta.persistence.Embeddable</option>
      <option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
    </pluginOptions>
  </configuration>
</plugin>

然後,我們使用 Kotlin 的 主要建構子簡潔語法建立我們的模型,該語法允許同時宣告屬性和建構子參數。

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

@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)

請注意,我們在此使用我們的 String.toSlug() 擴充功能來為 Article 建構子的 slug 參數提供預設引數。具有預設值的可選參數定義在最後一個位置,以便在使用位置引數時可以省略它們(Kotlin 也支援具名引數)。請注意,在 Kotlin 中,將簡潔的類別宣告分組在同一個檔案中並不少見。

這裡我們不使用具有 val 屬性的 data 類別,因為 JPA 並非設計用於不可變類別或 data 類別自動產生的方法。如果您使用其他 Spring Data 風格,它們中的大多數都設計為支援此類結構,因此在使用 Spring Data MongoDB、Spring Data JDBC 等時,您應該使用類似 data class User(val login: String, …​) 的類別。
雖然 Spring Data JPA 可以透過 Persistable 使用自然 ID(它可以是 User 類別中的 login 屬性),但由於 KT-6653,它與 Kotlin 不太相容,因此建議始終在 Kotlin 中使用具有產生 ID 的實體。

我們也如下宣告我們的 Spring Data JPA 儲存庫。

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

我們編寫 JPA 測試來檢查基本用例是否按預期運作。

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

@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)
  }
}
我們在這裡使用 Spring Data 預設提供的 CrudRepository.findByIdOrNull Kotlin 擴充功能,它是基於 OptionalCrudRepository.findById 的可為空變體。閱讀很棒的 Null is your friend, not a mistake 部落格文章以了解更多詳細資訊。

實作部落格引擎

我們更新 "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}}

我們建立一個新的 "article" 模板。

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),因此 ArticleRepositoryMarkdownConverter 建構子參數將會自動自動裝配。

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

@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)

}

然後,我們將資料初始化新增到新的 BlogConfiguration 類別。

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 = johnDoe
    ))
    articleRepository.save(Article(
        title = "Ipsum",
        headline = "Ipsum",
        content = "dolor sit amet",
        author = johnDoe
    ))
  }
}
請注意具名參數的使用,以使程式碼更具可讀性。

我們也相應地更新整合測試。

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

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

}

啟動(或重新啟動)Web 應用程式,然後前往 https://127.0.0.1:8080/,您應該會看到文章清單,其中包含指向特定文章的可點擊連結。

公開 HTTP API

我們現在將透過 @RestController 註解的控制器來實作 HTTP API。

src/main/kotlin/com/example/blog/HttpControllers.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")
}

對於測試,我們將利用 @WebMvcTestMockk,而不是整合測試,Mockk 類似於 Mockito,但更適合 Kotlin。

由於 @MockBean@SpyBean 註解是 Mockito 特有的,因此我們將利用 SpringMockK,它為 Mockk 提供了類似的 @MockkBean@SpykBean 註解。

使用 Gradle

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

或使用 Maven

pom.xml

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-engine</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>com.ninja-squad</groupId>
  <artifactId>springmockk</artifactId>
  <version>4.0.2</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")
    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))
        .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 {
  // ...
}

為了產生 您自己的元資料,以便讓您的 IDE 識別這些自訂屬性,應使用 spring-boot-configuration-processor 相依性來設定 kapt,如下所示。

build.gradle.kts

plugins {
  ...
  kotlin("kapt") version "1.9.22"
}

dependencies {
  ...
  kapt("org.springframework.boot:spring-boot-configuration-processor")
}
請注意,由於 kapt 提供的模型中的限制,某些功能(例如偵測預設值或已淘汰項目)無法運作。此外,由於 KT-18022,Maven 尚不支援註解處理,請參閱 initializr#438 了解更多詳細資訊。

在 IntelliJ IDEA 中

  • 請確保 Spring Boot 插件已在選單「檔案 | 設定 | 插件 | Spring Boot」中啟用

  • 透過選單「檔案 | 設定 | 建置、執行、部署 | 編譯器 | 註解處理器 | 啟用註解處理」來啟用註解處理

  • 由於 Kapt 尚未整合到 IDEA 中,您需要手動執行命令 ./gradlew kaptKotlin 才能產生元資料

編輯 application.properties 時,現在應該可以識別您的自訂屬性(自動完成、驗證等)。

src/main/resources/application.properties

blog.title=Blog
blog.banner.title=Warning
blog.banner.content=The blog will be down tomorrow.

相應地編輯模板和控制器。

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 repository: ArticleRepository,
           private val properties: BlogProperties) {

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

  // ...

重新啟動 Web 應用程式,重新整理 https://127.0.0.1:8080/,您應該會在部落格首頁上看到橫幅。

結論

我們現在已完成建置此範例 Kotlin 部落格應用程式。原始碼可在 Github 上取得。如果您需要更多關於特定功能的詳細資訊,您也可以查看Spring FrameworkSpring Boot 參考文件。