Kotlin中的Spring项目 (Spring Projects in Kotlin)

本节提供一些在Kotlin中开发Spring项目的具体提示和建议。

默认情况下为final (Final by Default)

默认情况下,Kotlin中的所有类和成员函数都是final。类上的open修饰符与Java的final相反:它允许其他人继承自此类。这也适用于成员函数,因为它们需要标记为open才能被覆盖。

虽然Kotlin的JVM友好型设计通常与Spring无缝衔接,但如果未考虑此特定Kotlin特性,则可能会阻止应用程序启动。这是因为Spring bean(例如默认情况下需要在运行时扩展的@Configuration注解类,出于技术原因)通常由CGLIB代理。解决方法是在由CGLIB代理的Spring bean的每个类和成员函数上添加open关键字,这很快就会变得很麻烦,而且违反了Kotlin保持代码简洁和可预测性的原则。

也可以通过使用@Configuration(proxyBeanMethods = false)来避免CGLIB代理配置类。有关更多详细信息,请参阅proxyBeanMethodsJavadoc

幸运的是,Kotlin提供了一个kotlin-spring插件(kotlin-allopen插件的预配置版本),它会自动为使用以下注解之一进行注解或元注解的类型打开类及其成员函数

  • @Component

  • @Async

  • @Transactional

  • @Cacheable

元注解支持意味着使用@Configuration@Controller@RestController@Service@Repository注解的类型会自动打开,因为这些注解使用@Component进行元注解。

一些涉及代理和Kotlin编译器自动生成最终方法的用例需要格外小心。例如,具有属性的Kotlin类将生成相关的final getter和setter。为了能够代理相关方法,应首选类型级别的@Component注解而不是方法级别的@Bean,以便kotlin-spring插件打开这些方法。一个典型的用例是@Scope及其流行的@RequestScope专业化。

start.spring.io默认情况下启用kotlin-spring插件。因此,在实践中,您可以像在Java中一样,无需任何额外的open关键字即可编写Kotlin bean。

Spring框架文档中的Kotlin代码示例没有明确指定类及其成员函数上的open。这些示例是为使用kotlin-allopen插件的项目编写的,因为这是最常用的设置。

使用不可变类实例进行持久化 (Using Immutable Class Instances for Persistence)

在Kotlin中,在主构造函数中声明只读属性很方便,也被认为是最佳实践,如下例所示

class Person(val name: String, val age: Int)

您可以选择添加data关键字,以便编译器自动从主构造函数中声明的所有属性派生以下成员

  • equals()hashCode()

  • 形式为"User(name=John, age=42)"toString()

  • 对应于其声明顺序中的属性的componentN()函数

  • copy()函数

如下例所示,即使Person属性是只读的,这也允许轻松更改单个属性

data class Person(val name: String, val age: Int)

val jack = Person(name = "Jack", age = 1)
val olderJack = jack.copy(age = 2)

常见的持久化技术(例如JPA)需要一个默认构造函数,这会阻止这种设计。幸运的是,对于这种“默认构造函数问题”有一个解决方法,因为Kotlin提供了一个kotlin-jpa插件,该插件会为使用JPA注解进行注解的类生成合成的无参构造函数。

如果您需要对其他持久化技术利用这种机制,您可以配置kotlin-noarg插件。

从 Kay 版本发布列车开始,Spring Data 支持 Kotlin 不可变类实例,并且如果模块使用 Spring Data 对象映射(例如 MongoDB、Redis、Cassandra 等),则不需要 `kotlin-noarg` 插件。

注入依赖

推荐使用构造器注入

我们建议尽可能使用带有 `val` 只读属性(如果可能,则为非空)的构造器注入,如下例所示

@Component
class YourBean(
	private val mongoTemplate: MongoTemplate,
	private val solrClient: SolrClient
)
只有一个构造函数的类会自动装配其参数。这就是为什么在上例中不需要显式使用 `@Autowired constructor` 的原因。

如果您确实需要使用字段注入,可以使用 `lateinit var` 构造,如下例所示

@Component
class YourBean {

	@Autowired
	lateinit var mongoTemplate: MongoTemplate

	@Autowired
	lateinit var solrClient: SolrClient
}

内部函数名称混淆

使用 `internal` 可见性修饰符 的 Kotlin 函数在编译成 JVM 字节码时,其名称会被混淆,这在按名称注入依赖项时会产生副作用。

例如,这个 Kotlin 类

@Configuration
class SampleConfiguration {

	@Bean
	internal fun sampleBean() = SampleBean()
}

转换为编译后的 JVM 字节码的这个 Java 表示形式

@Configuration
@Metadata(/* ... */)
public class SampleConfiguration {

	@Bean
	@NotNull
	public SampleBean sampleBean$demo_kotlin_internal_test() {
		return new SampleBean();
	}
}

因此,表示为 Kotlin 字符串的相关 bean 名称是 `"sampleBean\$demo_kotlin_internal_test"`,而不是常规 `public` 函数用例的 `"sampleBean"`。确保在按名称注入此类 bean 时使用混淆后的名称,或者添加 `@JvmName("sampleBean")` 来禁用名称混淆。

注入配置属性

在 Java 中,您可以使用注解(例如 `@Value("${property}")`)来注入配置属性。但是,在 Kotlin 中,`$` 是一个保留字符,用于 字符串插值

因此,如果您希望在 Kotlin 中使用 `@Value` 注解,则需要通过编写 `@Value("\${property}")` 来转义 `$` 字符。

如果您使用 Spring Boot,则可能应该使用 `@ConfigurationProperties` 而不是 `@Value` 注解。

或者,您可以通过声明以下配置 bean 来自定义属性占位符前缀

@Bean
fun propertyConfigurer() = PropertySourcesPlaceholderConfigurer().apply {
	setPlaceholderPrefix("%{")
}

您可以使用配置 bean 自定义使用 `${…​}` 语法的现有代码(例如 Spring Boot 执行器或 `@LocalServerPort`),如下例所示

@Bean
fun kotlinPropertyConfigurer() = PropertySourcesPlaceholderConfigurer().apply {
	setPlaceholderPrefix("%{")
	setIgnoreUnresolvablePlaceholders(true)
}

@Bean
fun defaultPropertyConfigurer() = PropertySourcesPlaceholderConfigurer()

检查异常

Java 和 Kotlin 异常处理 非常相似,主要区别在于 Kotlin 将所有异常视为未检查异常。但是,当使用代理对象(例如用 `@Transactional` 注解的类或方法)时,抛出的检查异常默认情况下会被包装在 `UndeclaredThrowableException` 中。

要像在 Java 中一样获取抛出的原始异常,应该使用 `@Throws` 注解方法以显式指定抛出的检查异常(例如 `@Throws(IOException::class)`)。

注解数组属性

Kotlin 注解与 Java 注解大多相似,但数组属性(在 Spring 中广泛使用)的行为有所不同。如 Kotlin 文档 中所述,您可以省略 `value` 属性名称(与其他属性不同),并将其指定为 `vararg` 参数。

为了理解这意味着什么,请考虑 `@RequestMapping`(这是最常用的 Spring 注解之一)为例。此 Java 注解声明如下

public @interface RequestMapping {

	@AliasFor("path")
	String[] value() default {};

	@AliasFor("value")
	String[] path() default {};

	RequestMethod[] method() default {};

	// ...
}

`@RequestMapping` 的典型用例是将处理程序方法映射到特定的路径和方法。在 Java 中,您可以为注解数组属性指定单个值,它会自动转换为数组。

这就是为什么您可以编写 `@RequestMapping(value = "/toys", method = RequestMethod.GET)` 或 `@RequestMapping(path = "/toys", method = RequestMethod.GET)` 的原因。

但是,在 Kotlin 中,您必须编写 `@RequestMapping("/toys", method = [RequestMethod.GET])` 或 `@RequestMapping(path = ["/toys"], method = [RequestMethod.GET])`(需要使用命名数组属性指定方括号)。

对于此特定的 `method` 属性(最常见的属性),另一种方法是使用快捷注解,例如 `@GetMapping`、`@PostMapping` 等。

如果没有指定 `@RequestMapping` 的 `method` 属性,则将匹配所有 HTTP 方法,而不仅仅是 `GET` 方法。

声明位置方差

在用 Kotlin 编写的 Spring 应用程序中处理泛型类型可能需要在某些用例中理解 Kotlin 声明位置方差,它允许在声明类型时定义方差,这在仅支持使用位置方差的 Java 中是不可能的。

例如,在 Kotlin 中声明 `List<Foo>` 从概念上等同于 `java.util.List<? extends Foo>`,因为 `kotlin.collections.List` 被声明为 `interface List<out E> : kotlin.collections.Collection<E>`

在使用 Java 类时,例如当从 Kotlin 类型转换为 Java 类型时编写 `org.springframework.core.convert.converter.Converter` 时,需要使用泛型类型上的 `out` Kotlin 关键字来考虑这一点。

class ListOfFooConverter : Converter<List<Foo>, CustomJavaList<out Foo>> {
    // ...
}

转换任何类型的对象时,可以使用 `*` 星号投影代替 `out Any`。

class ListOfAnyConverter : Converter<List<*>, CustomJavaList<*>> {
    // ...
}
Spring Framework 尚未利用声明位置方差类型信息来注入 bean,请订阅 spring-framework#22313 以跟踪相关进度。

测试

本节介绍 Kotlin 和 Spring Framework 组合的测试。推荐的测试框架是 JUnit 5 以及用于模拟的 Mockk

如果您使用 Spring Boot,请参阅 相关文档

构造器注入

专用部分 所述,JUnit Jupiter (JUnit 5) 允许构造器注入 bean,这在 Kotlin 中非常有用,以便使用 `val` 而不是 `lateinit var`。您可以使用 `@TestConstructor(autowireMode = AutowireMode.ALL)` 来启用所有参数的自动装配。

您也可以在 `junit-platform.properties` 文件中将默认行为更改为 `ALL`,方法是使用 `spring.test.constructor.autowire.mode = all` 属性。
@SpringJUnitConfig(TestConfig::class)
@TestConstructor(autowireMode = AutowireMode.ALL)
class OrderServiceIntegrationTests(val orderService: OrderService,
                                   val customerService: CustomerService) {

    // tests that use the injected OrderService and CustomerService
}

PER_CLASS 生命周期

Kotlin 允许您在反引号 (`) 之间指定有意义的测试函数名称。使用 JUnit Jupiter (JUnit 5),Kotlin 测试类可以使用 `@TestInstance(TestInstance.Lifecycle.PER_CLASS)` 注解来启用测试类的单实例化,这允许在非静态方法上使用 `@BeforeAll` 和 `@AfterAll` 注解,这非常适合 Kotlin。

您也可以在 `junit-platform.properties` 文件中将默认行为更改为 `PER_CLASS`,方法是使用 `junit.jupiter.testinstance.lifecycle.default = per_class` 属性。

以下示例演示了非静态方法上的 `@BeforeAll` 和 `@AfterAll` 注解

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IntegrationTests {

  val application = Application(8181)
  val client = WebClient.create("https://127.0.0.1:8181")

  @BeforeAll
  fun beforeAll() {
    application.start()
  }

  @Test
  fun `Find all users on HTML page`() {
    client.get().uri("/users")
        .accept(TEXT_HTML)
        .retrieve()
        .bodyToMono<String>()
        .test()
        .expectNextMatches { it.contains("Foo") }
        .verifyComplete()
  }

  @AfterAll
  fun afterAll() {
    application.stop()
  }
}

类似规范的测试

您可以使用 JUnit 5 和 Kotlin 创建类似规范的测试。以下示例演示了如何操作

class SpecificationLikeTests {

  @Nested
  @DisplayName("a calculator")
  inner class Calculator {
     val calculator = SampleCalculator()

     @Test
     fun `should return the result of adding the first number to the second number`() {
        val sum = calculator.sum(2, 4)
        assertEquals(6, sum)
     }

     @Test
     fun `should return the result of subtracting the second number from the first number`() {
        val subtract = calculator.subtract(4, 2)
        assertEquals(2, subtract)
     }
  }
}