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

本教程向您展示如何透過結合 Spring BootKotlin 的強大功能來高效地構建示例部落格應用程式。

如果您剛開始接觸 Kotlin,可以透過閱讀參考文件、學習線上 Kotlin Koans 教程,或者直接使用現在提供了 Kotlin 程式碼示例的 Spring Framework 參考文件來學習這門語言。

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. 輸入以下 Artifact 座標:blog

  3. 新增以下依賴

    • Spring Web

    • Mustache

    • Spring Data JPA

    • H2 Database

    • Spring Boot DevTools

  4. 點選“生成專案”。

.zip 檔案在根目錄中包含一個標準專案,因此您可能需要在解壓前建立一個空目錄。

使用命令列

您可以在 UN*X 類系統上使用 curl 等工具,從命令列呼叫 Initializr HTTP API

$ 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”

  • 型別:“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 應用程式,需要 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 值,而不是在執行時遇到著名的 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 應用程式,需要 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,可以新增一些)以及使用了頂層函式 runApplicationrunApplication<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 擴充套件,它允許向現有的 Spring 型別新增 Kotlin 函式或運算子。在這裡,我們匯入了 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://: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 中那樣使用帶有抽象方法的工具類。在這裡,我們將向現有的 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 使得透過 Persistable 使用自然 ID(可以是 User 類中的 login 屬性)成為可能,但這與 Kotlin 不太匹配,因為存在 KT-6653,這就是為什麼建議在 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》以獲取更多詳細資訊。

實現部落格引擎

我們更新了“部落格”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 建構函式引數將自動裝配。

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,而不是整合測試,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 能夠識別這些自定義屬性,應該按照以下方式配置 kapt 並新增 spring-boot-configuration-processor 依賴。

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 參考文件。