使用 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,也可以使用它。请注意,您可以使用 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 数据库" 和 "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,则可以添加一些)以及 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 扩展,它允许向现有的 Spring 类型添加 Kotlin 函数或运算符。在这里,我们导入 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://127.0.0.1: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")
  }

}

创建您自己的扩展

与 Java 中使用带有抽象方法的 util 类不同,Kotlin 中通常通过 Kotlin 扩展来提供此类功能。在这里,我们将向现有的 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 中,将简洁的类声明分组到同一个文件中并不罕见。

这里我们不使用 dataval 属性,因为 JPA 并非设计用于与不可变类或 data 类自动生成的 方法一起使用。如果您使用其他 Spring Data 版本,大多数都设计为支持此类结构,因此在使用 Spring Data MongoDB、Spring Data JDBC 等时,您应该使用诸如 data class User(val login: String, …​) 之类的类。
虽然 Spring Data JPA 允许通过 Persistable 使用自然 ID(它可能是 User 类中的 login 属性),但由于 KT-6653,它不适合 Kotlin,因此建议始终在 Kotlin 中使用具有生成的 ID 的实体。

我们还声明我们的 Spring Data JPA 存储库如下所示。

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

interface ArticleRepository : CrudRepository<Article, Long> {
  fun findBySlug(slug: String): Article?
  fun findAllByOrderByAddedAtDesc(): Iterable<Article>
}

interface UserRepository : CrudRepository<User, Long> {
  fun findByLogin(login: String): User?
}

我们编写 JPA 测试以检查基本用例是否按预期工作。

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

@DataJpaTest
class RepositoriesTests @Autowired constructor(
    val entityManager: TestEntityManager,
    val userRepository: UserRepository,
    val articleRepository: ArticleRepository) {

  @Test
  fun `When findByIdOrNull then return Article`() {
    val johnDoe = User("johnDoe", "John", "Doe")
    entityManager.persist(johnDoe)
    val article = Article("Lorem", "Lorem", "dolor sit amet", johnDoe)
    entityManager.persist(article)
    entityManager.flush()
    val found = articleRepository.findByIdOrNull(article.id!!)
    assertThat(found).isEqualTo(article)
  }

  @Test
  fun `When findByLogin then return User`() {
    val johnDoe = User("johnDoe", "John", "Doe")
    entityManager.persist(johnDoe)
    entityManager.flush()
    val user = userRepository.findByLogin(johnDoe.login)
    assertThat(user).isEqualTo(johnDoe)
  }
}
我们在这里使用 Spring Data 默认提供的 CrudRepository.findByIdOrNull Kotlin 扩展,它是基于 OptionalCrudRepository.findById 的可空变体。阅读精彩的 Null 是你的朋友,而不是错误 博客文章以了解更多详细信息。

实现博客引擎

我们更新“blog” Mustache 模板。

src/main/resources/templates/blog.mustache

{{> header}}

<h1>{{title}}</h1>

<div class="articles">

  {{#articles}}
    <section>
      <header class="article-header">
        <h2 class="article-title"><a href="/article/{{slug}}">{{title}}</a></h2>
        <div class="article-meta">By  <strong>{{author.firstname}}</strong>, on <strong>{{addedAt}}</strong></div>
      </header>
      <div class="article-description">
        {{headline}}
      </div>
    </section>
  {{/articles}}
</div>

{{> footer}}

我们创建一个“article”新模板。

src/main/resources/templates/article.mustache

{{> header}}

<section class="article">
  <header class="article-header">
    <h1 class="article-title">{{article.title}}</h1>
    <p class="article-meta">By  <strong>{{article.author.firstname}}</strong>, on <strong>{{article.addedAt}}</strong></p>
  </header>

  <div class="article-description">
    {{article.headline}}

    {{article.content}}
  </div>
</section>

{{> footer}}

我们更新 HtmlController 以使用格式化日期呈现博客和文章页面。由于 HtmlController 具有单个构造函数(隐式 @Autowired),因此 ArticleRepositoryMarkdownConverter 构造函数参数将自动自动装配。

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

@Controller
class HtmlController(private val repository: ArticleRepository) {

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

  @GetMapping("/article/{slug}")
  fun article(@PathVariable slug: String, model: Model): String {
    val article = repository
        .findBySlug(slug)
        ?.render()
        ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")
    model["title"] = article.title
    model["article"] = article
    return "article"
  }

  fun Article.render() = RenderedArticle(
      slug,
      title,
      headline,
      content,
      author,
      addedAt.format()
  )

  data class RenderedArticle(
      val slug: String,
      val title: String,
      val headline: String,
      val content: String,
      val author: User,
      val addedAt: String)

}

然后,我们将数据初始化添加到新的 BlogConfiguration 类中。

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

@Configuration
class BlogConfiguration {

  @Bean
  fun databaseInitializer(userRepository: UserRepository,
              articleRepository: ArticleRepository) = ApplicationRunner {

    val johnDoe = userRepository.save(User("johnDoe", "John", "Doe"))
    articleRepository.save(Article(
        title = "Lorem",
        headline = "Lorem",
        content = "dolor sit amet",
        author = johnDoe
    ))
    articleRepository.save(Article(
        title = "Ipsum",
        headline = "Ipsum",
        content = "dolor sit amet",
        author = johnDoe
    ))
  }
}
请注意命名参数的使用,以使代码更具可读性。

我们还相应地更新了集成测试。

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class IntegrationTests(@Autowired val restTemplate: TestRestTemplate) {

  @BeforeAll
  fun setup() {
    println(">> Setup")
  }

  @Test
  fun `Assert blog page title, content and status code`() {
    println(">> Assert blog page title, content and status code")
    val entity = restTemplate.getForEntity<String>("/")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains("<h1>Blog</h1>", "Lorem")
  }

  @Test
  fun `Assert article page title, content and status code`() {
    println(">> Assert article page title, content and status code")
    val title = "Lorem"
    val entity = restTemplate.getForEntity<String>("/article/${title.toSlug()}")
    assertThat(entity.statusCode).isEqualTo(HttpStatus.OK)
    assertThat(entity.body).contains(title, "Lorem", "dolor sit amet")
  }

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

}

启动(或重新启动)Web 应用程序,然后转到 https://127.0.0.1:8080/,您应该会看到文章列表,并带有可点击的链接以查看特定文章。

公开 HTTP API

我们现在将通过 @RestController 注解的控制器实现 HTTP API。

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

@RestController
@RequestMapping("/api/article")
class ArticleController(private val repository: ArticleRepository) {

  @GetMapping("/")
  fun findAll() = repository.findAllByOrderByAddedAtDesc()

  @GetMapping("/{slug}")
  fun findOne(@PathVariable slug: String) =
      repository.findBySlug(slug) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This article does not exist")

}

@RestController
@RequestMapping("/api/user")
class UserController(private val repository: UserRepository) {

  @GetMapping("/")
  fun findAll() = repository.findAll()

  @GetMapping("/{login}")
  fun findOne(@PathVariable login: String) =
      repository.findByLogin(login) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "This user does not exist")
}

对于测试,我们将利用 @WebMvcTestMockk,而不是集成测试,它类似于 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://127.0.0.1:8080/,您应该会在博客主页上看到横幅。

结论

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