Kotlin中的Spring项目

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

默认为Final

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

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

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

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

  • @Component

  • @Async

  • @Transactional

  • @Cacheable

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

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

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

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
)
具有单个构造函数的类会自动进行参数自动装配。这就是为什么在上面示例中不需要显式的@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
fun propertyConfigurer() = PropertySourcesPlaceholderConfigurer().apply {
	setPlaceholderPrefix("%{")
}
@LocalServerPort),该代码使用 ${…​}语法,如下例所示:

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

@Bean
fun defaultPropertyConfigurer() = PropertySourcesPlaceholderConfigurer()

受检异常

Java和Kotlin异常处理非常接近,主要区别在于Kotlin将所有异常视为未经检查的异常。然而,当使用代理对象(例如使用@Transactional注解的类或方法)时,抛出的受检异常将默认包装在UndeclaredThrowableException中。

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

@RequestMapping("/toys", method = [RequestMethod.GET])@RequestMapping(path = ["/toys"], method = [RequestMethod.GET])(需要使用命名数组属性指定方括号)。

method属性(最常见的属性),一个替代方法是使用快捷注解,例如 @GetMapping@PostMapping等。

如果未指定@RequestMappingmethod属性,将匹配所有HTTP方法,而不仅仅是GET方法。

声明点变异

声明点变异,它允许在声明类型时定义变异,而这在仅支持使用点变异的Java中是不可能的。

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

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

    // 使用注入的OrderService和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("http://localhost:8181")

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

  @Test
  fun `在HTML页面上查找所有用户`() {
    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("一个计算器")
  inner class Calculator {
     val calculator = SampleCalculator()

     @Test
     fun `应返回将第一个数字加到第二个数字的结果`() {
        val sum = calculator.sum(2, 4)
        assertEquals(6, sum)
     }

     @Test
     fun `应返回将第二个数字从第一个数字中减去的结果`() {
        val subtract = calculator.subtract(4, 2)
        assertEquals(2, subtract)
     }
  }
}