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 外掛程式的預先組態版本),可自動為使用下列其中一個註解註解或 meta-annotation 的類型開啟類別及其成員函數

  • @Component

  • @Async

  • @Transactional

  • @Cacheable

Meta-annotation 支援表示使用 @Configuration@Controller@RestController@Service@Repository 註解的類型會自動開啟,因為這些註解使用 @Component 進行 meta-annotation。

某些涉及 Proxy 的使用案例以及 Kotlin 編譯器自動產生 final 方法需要特別注意。例如,具有屬性的 Kotlin 類別將產生相關的 final getter 與 setter。為了能夠代理相關方法,應該優先使用類型層級的 @Component 註解,而不是方法層級的 @Bean,以便讓 kotlin-spring 外掛程式開啟這些方法。一個典型的使用案例是 @Scope 及其常用的 @RequestScope 特化。

start.spring.io 預設啟用 kotlin-spring 外掛程式。因此,實際上,您可以像在 Java 中一樣編寫 Kotlin Bean,而無需任何額外的 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 註解註解的類別產生合成的 no-arg 建構子。

如果您需要為其他持久化技術利用這種機制,您可以組態 kotlin-noarg 外掛程式。

從 Kay 版本列車開始,如果模組使用 Spring Data 物件對應(例如 MongoDB、Redis、Cassandra 與其他),Spring Data 支援 Kotlin 不可變類別實例,並且不需要 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 函數在編譯為 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 將所有例外視為未檢查的例外。但是,當使用 Proxy 物件(例如使用 @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 中是不可能的。

例如,在 Kotlin 中宣告 List<Foo> 在概念上等同於 java.util.List<? extends Foo>,因為 kotlin.collections.List 宣告為 interface List<out E> : kotlin.collections.Collection<E>

在使用 Java 類別時,例如在編寫從 Kotlin 型別到 Java 型別的 org.springframework.core.convert.converter.Converter 時,需要透過在泛型型別上使用 out Kotlin 關鍵字來考慮這一點。

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 以及用於 Mock 的 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://127.0.0.1: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)
     }
  }
}