使用 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 参考文档。