Kotlin 中的 Spring 项目

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

默认 final

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

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

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

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

  • @Component

  • @Async

  • @Transactional

  • @Cacheable

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

一些涉及代理和 Kotlin 编译器自动生成 final 方法的用例需要额外注意。 例如,一个带有属性的 Kotlin 类会生成相关的 final getter 和 setter。 为了能够代理相关方法,应该优先使用类型级别的 @Component 注解而不是方法级别的 @Bean, 以便 kotlin-spring 插件可以打开这些方法。 一个典型的用例是 @Scope 及其流行的 @RequestScope 特化。

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

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

使用不可变类实例进行持久化

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

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

您可以选择添加 the data keyword 以使编译器自动从主构造函数中声明的所有属性派生以下成员:

  • equals()hashCode()

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

  • 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)需要一个默认构造函数,这阻碍了这种设计。 幸运的是,对于这种 “default constructor hell”, 有一个解决方法,因为 Kotlin 提供了一个 kotlin-jpa 插件,它为带有 JPA 注解的类生成合成的无参构造函数。

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

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

注入依赖项

优先使用构造函数注入

我们的建议是尽量优先使用构造函数注入,并使用 val 只读(并且 尽可能非空)https://kotlinlang.org/docs/properties.html[属性], 如下例所示:

@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 注解。

作为替代方案,您可以通过声明以下 PropertySourcesPlaceholderConfigurer bean 来自定义属性占位符前缀:

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

您可以通过声明多个 PropertySourcesPlaceholderConfigurer bean 来支持使用标准 ${…​} 语法的组件(例如 Spring Boot 执行器或 @LocalServerPort) 以及使用自定义 %{…​} 语法的组件,如下例所示:

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

@Bean
fun defaultPropertyConfigurer() = PropertySourcesPlaceholderConfigurer()

此外,可以通过 JVM 系统属性(或通过 SpringProperties 机制) 设置 spring.placeholder.escapeCharacter.default 属性来全局更改或禁用默认转义字符。

检查型异常

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 中是不可能的,Java 只支持使用处协变。

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

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

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,以及 Mockk 用于模拟。

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

构造函数注入

专用部分 中所述, JUnit Jupiter 允许构造函数注入 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,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("http://localhost: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()
	}
}

类似规范的测试

您可以使用 Kotlin 和 JUnit Jupiter 的 @Nested 测试 类支持创建类似规范的测试。以下示例展示了如何实现:

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)
		}
	}
}