Spring Boot 与 Docker
观察 GraphQL 的实际运行

本教程将向您展示如何通过结合 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 项目时默认使用 Kotlin DSL,因此这是推荐的选择。但如果您更熟悉 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 开发工具

  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 界面。

要访问向导,请转到 文件 | 新建 | 项目,然后选择 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 限定符。

为了能够在 JPA 中使用 Kotlin 的非空属性,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。通过空值声明和表达“有值或无值”的语义,Kotlin 使得应用程序更加安全,而无需像 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 修饰符。

为了能够在 JPA 中使用 Kotlin 的非空属性,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。通过空声明和表达“有值或无值”的语义,Kotlin 使得应用程序更加安全,而无需像 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) 是 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列出了所有提供的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 应用程序,然后访问 http://localhost: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 中使用带有抽象方法的工具类不同,在 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 中将简洁的类声明分组到同一个文件中并不罕见。

这里我们不使用带有 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 属性),但由于 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),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 而不是集成测试,它类似于 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 的参考文档。

本页目录