跳到主要内容
版本:7.0.3

Kotlin 中的 Spring 项目

Hunyuan 7b 中英对照 Spring Projects in Kotlin

本节提供了一些针对使用Kotlin开发Spring项目时的具体提示和建议。

默认为最后

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

虽然Kotlin对JVM的友好设计通常能够与Spring无缝配合,但如果不考虑这一特定情况,某些Kotlin特性可能会阻碍应用程序的启动。这是因为Spring Bean(例如被@Configuration注解的类,出于技术原因这些类默认需要在运行时进行扩展)通常是由CGLIB代理的。解决办法是在所有由CGLIB代理的Spring Bean的类及成员函数前加上open关键字,但这样做很快就会变得繁琐不堪,而且违背了Kotlin保持代码简洁和可预测性的原则。

备注

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

幸运的是,Kotlin提供了一个kotlin-spring插件(这是kotlin-allopen插件的预配置版本),该插件能够自动为带有以下注解或元注解的类型打开其类及其成员函数:

  • @Component

  • @Async

  • @Transactional

  • @Cacheable

元注释(meta-annotation)支持意味着,被@Configuration@Controller@RestController@Service@Repository这些注解标记的类会自动被处理,因为这些注解本身又被@Component元注解所覆盖。

注意

在某些涉及代理以及Kotlin编译器自动生成最终方法(final methods)的用例中,需要格外小心。例如,一个具有属性的Kotlin类会自动生成相应的final getter和setter方法。为了能够对这些方法进行代理,应优先使用类型级别的@Component注解,而非方法级别的@Bean注解,这样才能让kotlin-spring插件对这些方法进行代理处理。一个典型的用例就是@Scope注解及其流行的@RequestScope特化版本。

start.spring.io 默认启用了 kotlin-spring 插件。因此,在实际应用中,你可以像编写 Java 代码一样编写 Kotlin 类,而无需使用额外的 open 关键字。

备注

Spring Framework文档中的Kotlin代码示例并未在类及其成员函数上显式指定open属性。这些示例是为使用kotlin-allopen插件的项目编写的,因为这是最常用的配置方式。

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

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

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

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

  • 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)要求存在默认构造函数,这阻碍了这种设计方式的实现。幸运的是,对于这种“默认构造函数带来的麻烦”,有解决办法。因为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
)
备注

具有单个构造函数的类,其参数会自动进行赋值(即自动“wire”)。这就是为什么在上述示例中不需要显式的@Autowired构造函数的原因。

如果你确实需要使用字段注入(field injection),你可以使用 lateinit var 结构,如下例所示:

@Component
class YourBean {

@Autowired
lateinit var mongoTemplate: MongoTemplate

@Autowired
lateinit var solrClient: SolrClient
}

内部函数名称重命名

在Kotlin中,使用internal可见性修饰符的函数,在编译为JVM字节码时其函数名会被修改(即名称会被“篡改”)。这在通过名称注入依赖时会产生副作用。

例如,这个Kotlin类:

@Configuration
class SampleConfiguration {

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

转换为这种Java表示形式,即编译后的JVM字节码:

@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 的 Actuator 或 @LocalServerPort),同时也能支持使用自定义的 %{…​} 语法的组件,如下例所示:

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

@Bean
fun defaultPropertyConfigurer() = PropertySourcesPlaceholderConfigurer()

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

检查异常

Java和Kotlin的异常处理非常相似,主要区别在于Kotlin将所有异常都视为未经检查的异常(unchecked exceptions)。然而,当使用代理对象(例如带有@Transactional注解的类或方法)时,抛出的经检查的异常(checked exceptions)默认会被包装在一个UndeclaredThrowableException中。

要在Java中那样获得原始异常的抛出,方法应该使用@Throws进行标注,以明确指定所抛出的受检异常(例如@Throws(IOError::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等。

备注

如果未指定@RequestMappingmethod属性,那么所有HTTP方法都会匹配到,而不仅仅是GET方法。

声明点方差 {#declaration-site-variance}\

在用Kotlin编写的Spring应用程序中处理泛型类型时,对于某些用例来说,可能需要理解Kotlin的声明点变体(declaration-site variance)。这种机制允许在声明类型时就定义变体,而在Java中这是不可行的,因为Java仅支持使用点变体(use-site variance)。

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

在使用Java类时,对于泛型类型需要使用Kotlin的out关键字来考虑这一点,例如在将Kotlin类型转换为Java类型的org.springframework.core.convertconverter.Converter时。

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

在转换任何类型的对象时,可以使用带有*的星形投影(star projection)来替代out Any

class ListOfAnyConverter : Converter<List<*>, CustomJavaList<*>> {
// ...
}
备注

Spring Framework目前尚未利用声明站点(declaration-site)的类型信息来注入Bean,请关注spring-framework#22313以跟踪相关进展。

测试

本节介绍了如何结合Kotlin和Spring框架进行测试。推荐的测试框架是jUnit,同时使用Mockk来进行模拟(mocking)操作。

提示

Kotlin 允许你使用反引号(``)来指定有意义的测试函数名。

有关具体的示例,请参见本节后面的 Find all users on HTML page() 测试函数。

备注

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

构造器注入

专门说明部分所述,JUnit Jupiter允许对bean进行构造函数注入,这对于Kotlin来说非常有用,因为这样就可以使用val代替lateinit var。你可以使用[@TestConstructor(autowireMode = AutowireMode.ALL)](https://docs.spring.io/spring-framework/docs/7.0.3/javadoc-api/org/springframework/test/context/TestConstructor.html)来为所有参数启用自动注入功能。

备注

你也可以在junit-platform.properties文件中通过设置spring.testconstructor_autowire.mode = all属性来将默认行为更改为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 生命周期

借助JUnit Jupiter,Kotlin测试类可以使用@TestInstance(TestInstance.Lifecycle.PER_CLASS)注解来实现测试类的单例化,这使得可以在非静态方法上使用@BeforeAll@AfterAll注解,这对于Kotlin来说非常合适。

备注

你也可以在 junit-platform.properties 文件中通过设置 junit.jupiter.testinstance.lifecycle.default = per_class 来将默认行为更改为 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)
}
}
}