使用 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 应用程序,然后访问 http://localhost: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 应用程序,然后访问 http://localhost: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 应用程序,刷新 http://localhost:8080/,您应该能在博客主页上看到横幅。

结论

我们现在已经完成了这个 Kotlin 博客示例应用程序的构建。源代码可在 Github 上找到。如果您需要更多关于特定功能的详细信息,还可以查看 Spring FrameworkSpring Boot 参考文档。