使用 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 從命令列,例如在類 Unix 系統上使用 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。

按照嚮導的步驟使用以下引數

  • 偽專案: “blog”

  • 型別: “Gradle - Kotlin” 或 “Maven”

  • 語言: Kotlin

  • 名稱: “Blog”

  • 依賴項: “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 值,而不是在執行時遇到著名的 NullPointerException。這使得應用程式更安全,透過可空性宣告和表達“有值或無值”的語義,而無需付出像 Optional 這樣的包裝器的成本。請注意,Kotlin 允許使用函式式結構處理可空值;檢視這篇 關於 Kotlin 空安全的全面指南

儘管 Java 在其型別系統中不允許表達空安全性,但 Spring Framework 透過 org.springframework.lang 包中宣告的工具友好型註解,為整個 Spring Framework API 提供了空安全性。預設情況下,Kotlin 中使用的 Java API 的型別被識別為 平臺型別,對於這些型別,空檢查會放寬。Kotlin 對 JSR 305 註解的支援 + Spring 可空性註解為 Kotlin 開發者提供了整個 Spring Framework API 的空安全性,並能在編譯時處理 null 相關問題。

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

build.gradle.kts

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

依賴

此類 Spring Boot Web 應用需要兩個 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 值,而不是在執行時遇到著名的 NullPointerException。這使得應用程式更安全,透過可空性宣告和表達“有值或無值”的語義,而無需付出像 Optional 這樣的包裝器的成本。請注意,Kotlin 允許使用函式式結構處理可空值;檢視這篇 關於 Kotlin 空安全的全面指南

儘管 Java 在其型別系統中不允許表達空安全性,但 Spring Framework 透過 org.springframework.lang 包中宣告的工具友好型註解,為整個 Spring Framework API 提供了空安全性。預設情況下,Kotlin 中使用的 Java API 的型別被識別為 平臺型別,對於這些型別,空檢查會放寬。Kotlin 對 JSR 305 註解的支援 + Spring 可空性註解為 Kotlin 開發者提供了整個 Spring Framework API 的空安全性,並能在編譯時處理 null 相關問題。

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

還請注意,Kotlin 編譯器配置為生成 Java 8 位元組碼(預設是 Java 6)。

依賴

此類 Spring Boot Web 應用需要三個 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)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 控制器

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

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 列出了所有提供的 Kotlin 擴充套件,以豐富 Java API。

我們還需要建立相應的 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://:8080/,您應該會看到一個樸素的網頁,上面有一個“Blog”標題。

使用 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 那樣使用帶有抽象方法的工具類。在這裡,我們將向現有的 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 進行持久化

為了使延遲載入按預期工作,實體應該是 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 允許使用自然 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 以使用格式化的日期來渲染部落格和文章頁面。ArticleRepository 的建構函式引數將自動注入,因為 HtmlController 只有一個建構函式(隱式 @Autowired)。

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://: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,它類似於 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://:8080/,您應該會在部落格主頁上看到橫幅。

結論

我們現在已經完成了這個示例 Kotlin 部落格應用的構建。原始碼 可在 Github 上找到。如果您需要更多關於特定功能的詳細資訊,還可以檢視 Spring FrameworkSpring Boot 參考文件。