Kotlin 中的 Spring 專案

本節提供了一些在 Kotlin 中開發 Spring 專案的特定提示和建議。

預設為 Final

預設情況下,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

元註解支援意味著用 @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)

你可以選擇新增 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 只讀(如果可能,且非 null)屬性,如下例所示

@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 函式帶有 internal 可見性修飾符時,在編譯為 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 actuator 或 @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 等。

如果未指定 @RequestMappingmethod 屬性,則將匹配所有 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 類時,需要在泛型型別上使用 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 以及用於 Mocking 的 Mockk

如果你正在使用 Spring Boot,請參閱此相關文件

建構函式注入

專用部分所述,JUnit Jupiter (JUnit 5) 允許對 Bean 進行建構函式注入,這在 Kotlin 中非常有用,以便使用 val 而不是 lateinit var。你可以使用 @TestConstructor(autowireMode = AutowireMode.ALL) 來啟用所有引數的自動裝配。

你也可以在 junit-platform.properties 檔案中透過設定 spring.test.constructor.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 生命週期

Kotlin 允許你在反引號 (`) 之間指定有意義的測試函式名稱。使用 JUnit Jupiter (JUnit 5),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("https://: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)
		}
	}
}