Spring Projects in Kotlin

本部分提供了一些专门提示和建议,帮助你用 Kotlin 开发 Spring 项目。

Final by Default

默认情况下, all classes and member functions in Kotlin are 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 插件的预配置版本)https://kotlinlang.org/docs/compiler-plugins.html#kotlin-spring-compiler-plugin[,该插件会自动替使用下列注释或元注释的类型开放类及其成员函数:

  • @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 插件。因此,在实践中,您可以编写 Kotlin bean,而无需任何其他 open 关键字,就像在 Java 中一样。

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

Using Immutable Class Instances for Persistence

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

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

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

  • equals() and hashCode()

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

  • componentN() 与声明顺序对应的函数

  • copy() function

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

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

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

公共持久化技术(如 JPA)需要默认构造函数,防止此类设计。幸运的是,对此有一个解决方法https://stackoverflow.com/questions/32038177/kotlin-with-jpa-default-constructor-hell[“default constructor hell”],因为 Kotlin 提供 kotlin-jpa插件,为用 JPA 注解注释的类生成合成的无参数构造函数。

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

截至 Kay 发布列车,Spring Data 支持 Kotlin 不可变类实例并且不需要 kotlin-noarg 插件,如果模块使用 Spring Data 对象映射(例如 MongoDB、Redis、Cassandra 和其他映射)。

Injecting Dependencies

Favor constructor injection

我们的建议是尝试优先使用 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 functions name mangling

带有 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"`, 而不是 "sampleBean",这是针对常规`public` 函数用例的情况。确保在按名称注入此类 bean 时使用混乱的名称,或添加`@JvmName("sampleBean")` 以禁用名称混乱。

Injecting Configuration Properties

在 Java 中,您可以使用注解(例如 @Value("${property}"))注入配置属性。然而,在 Kotlin 中,$ 是一个保留字符,用于https://kotlinlang.org/docs/idioms.html#string-interpolation[字符串插值]。

因此,如果您希望在 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()

Checked Exceptions

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

要像在 Java 中一样获得抛出的原始异常,应使用 @Throws对方法进行注释,以明确指定抛出的已检查异常(例如 @Throws(IOException::class))。

Annotation Array Attributes

Kotlin 注解与 Java 注解非常相似,但数组属性(它们在 Spring 中广泛使用)的行为不同。如https://kotlinlang.org/docs/annotations.html[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 方法。

Declaration-site variance

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

例如,List<Foo> 在 Kotlin 中声明在概念上等同于 java.util.List<? extends Foo>,因为`kotlin.collections.List` 声明为https://kotlinlang.org/api/latest/jvm/stdlib/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 以追踪相关进度。

Testing

本节讨论 Kotlin 和 Spring Framework 结合进行的测试。建议的测试框架是 JUnit 5 连同用于模拟的 Mockk

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

Constructor injection

dedicated section 中所述,JUnit Jupiter(JUnit 5)允许对 Bean 采用构造函数注入,这在 Kotlin 中非常有用,以使用 val 代替 lateinit var。您可以使用 @TestConstructor(autowireMode = AutowireMode.ALL) 为所有参数启用自动装配。

你还可以使用具有 spring.test.constructor.autowire.mode = all 属性的 junit-platform.properties 文件将默认行为更改为 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 Lifecycle

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

还可以使用具有 junit.jupiter.testinstance.lifecycle.default = per_class 属性的 junit-platform.properties 文件将默认行为更改为 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()
  }
}

Specification-like Tests

您可以使用 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)
     }
  }
}