使用Kotlin开发Spring Boot应用程序
最近把KeyOA从Java前移到了Kotlin进行开发,下面说一下需要注意的事项以及一些Kotlin的语法。
在Spring Boot中引入Kotlin
// build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.0.1"
id("io.spring.dependency-management") version "1.1.0"
kotlin("jvm") version "1.8.0"
kotlin("plugin.spring") version "1.8.0"
kotlin("plugin.jpa") version "1.8.0"
}
// 让Spring支持Kotlin的Null Safety
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs += "-Xjsr305=strict"
}
}
// build.gradle.kts,负责引入依赖
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-mustache")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") // Jackson Kotlin支持
implementation("org.jetbrains.kotlin:kotlin-reflect") // Kotlin反射
runtimeOnly("org.springframework.boot:spring-boot-devtools")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
<!-- pom.xml -->
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<compilerPlugins>
<plugin>jpa</plugin>
<plugin>spring</plugin>
</compilerPlugins>
<args>
<arg>-Xjsr305=strict</arg>
</args>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
<!-- pom.xml,依赖部分 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mustache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
应用的Main Class:
package com.jydjal.oa
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class OaApplication
fun main(args: Array<String>) {
runApplication<OaApplication>(*args) {
setBannerMode(Banner.Mode.OFF) // 关闭Spring Boot Banner,可选
}
}
OOP&依赖注入
Kotlin中的类,成员变量和构造函数:
class Person(
val name: String,
val age: Int, // 前两个是构造函数参数,同时也是类的成员变量
height: Int, // 构造函数的普通参数,后面逗号可以不省略
) {
val height: Int = height // 成员变量,但是未在构造函数中
}
在Kotlin中使用@Autowired注解实现依赖注入,这个注解可以放在:Constructor、Method、Parameter、Field、AnnotationType
@Service
class EmployeeService{
// 逻辑代码略
}
@Controller
class EmployeeController(@Autowired val service: EmployeeService) {
// 逻辑代码略
}
// 如果需要注入的成员变量过多,则可以把注解放在构造方法上,这是需要在构造方法前添加constructor关键字
@Controller
class EmployeeController @Autowired constructor(val service: EmployeeService) {
// 逻辑代码略
}
日志(以Slf4j为例)
在Java中,如果需要使用日志可以使用Lombok的@Slf4j注解,但是在Kotlin中,这个注解失效了,因此需要手动获取logger。
在这里我使用扩展方法来实现,然后解释原因:
// 获取logger的函数,这里使用Slf4j
fun <T : Any> T.logger(): Lazy<Logger> {
// 使logger的名字始终和最外层的类一致,即使是在companion object中初始化属性
val ofClass = this.javaClass
val clazz = ofClass.enclosingClass?.takeIf {
ofClass.enclosingClass.kotlin.companionObject?.java == ofClass
} ?: ofClass
return lazy { LoggerFactory.getLogger(clazz) }
}
// 使用logger函数
class TestClass{
val logger1 by logger()
// 或者使用companion object
companion object {
val logger2 by logger()
}
fun testLog() {
logger1.info("Name: logger1, Level: info.")
logger2.debug("Name: logger2, Level: debug.")
}
}
现在给出原因,首先是把logger作为类的成员变量获取:
class TestClass{
val logger = LoggerFactory.getLogger(TestClass::class.java)
fun testLog() = logger.info("This is a test log.") // 这是Kotlin中的单表达式函数,参见附录的Kotlin Idioms
}
但是这样会有两个弊端:1:每个logger都需要手动编写代码实现,比较麻烦;2:每个类的实例都会有一个logger变量(虽然日志框架会有logger缓存机制,影响不大)。
针对第一个问题,我们可以使用扩展方法实现:
fun <T: Any> T.logger(): Logger {
return LoggerFactory.getLogger(T::class.java)
}
针对第二个问题,可以考虑使用companion object:
class TestClass{
companion object {
val logger = LoggerFactory.getLogger(TestClass::class.java)
}
fun testLog() = logger.info("This is a test log.")
}
但是在companion object中的logger获取的类名不正确,变成了TestClass.Companion,因此需要让companion中的logger也能获取外部类的名称,于是就有了上面获取外部类的操作。
// 使logger的名字始终和最外层的类一致,即使是在companion object中初始化属性
val ofClass = this.javaClass
val clazz = ofClass.enclosingClass?.takeIf {
ofClass.enclosingClass.kotlin.companionObject?.java == ofClass
} ?: ofClass
最后使用Lazy,让logger在使用时才进行计算,就得到了上面的方法。
其实还有其他的方法可以获取logger的实例,详见参考链接第三条StackOverflow上的回答。
测试
我们将使用Spring Boot Starter Test完成教学,包含JUnit5、MockMvc和AssertJ三部分。
JUnit5
@SpringBootTest
class TestEmployee {
@Test
fun `Test employee setter`() { // 在Kotlin中,测试方法名称可以用反引号(数字1左边那个)括起来作为测试的名称
val employee: Employee? = Employee("张三", "男")
assertThat(employee).isNotNull()
}
}
关于JUnit5的测试实例生命周期:在JUnit5中支持使用@BeforeAll和@AfterAll注解在测试开始前和结束后做一些额外的工作,这需要被标记上注解的方法要是静态方法,在Kotlin中这需要通过companion object实现,这样做非常麻烦。但是JUnit5可以让测试在每个类上实例化,这就需要我们手动修改JUnit5的测试生命周期:
# src/test/resources/junit-platform.properties
junit.jupiter.testinstance.lifecycle.default = per_class
这样@BeforeAll和@AfterAll注解就可以加在普通方法上,实现起来也就容易了许多。
MockMvc
这里直接给出实例:
@SpringBootTest
@AutoConfigureMockMvc
class LoginTest @Autowired constructor(
private val mockMvc: MockMvc,
private val mapper: ObjectMapper) {
private lateinit var token: String
fun testLoginSuccess() {
val loginDto = mapOf("account" to "admin", "password" to "1234qwer")
mockMvc.post("/login") {
contentType = MediaType.APPLICATION_JSON
content = mapper.writeValueToString(loginDto)
header("isAdmin", true)
}.andExpect{
jsonPath("$.message") { value("OK") }
jsonPath("$.data") { exists() }
}.andDo{
print()
handle{
val content = it.response.contentAsString
token = mapper.readValue(content,
object: TypeReference<JsonResponse<String>>() {}).data
}
}
}
}
具体用法见参考链接中的MockMvc Kotlin Dsl。
AssertJ
这部分使用Kotlin和使用Java没有太大区别,有区别的地方按照IDE提示修改即可,主要有两点:
- assertThat(something).as(”Description”):IDE会提示在as上扩上反引号
- 有些地方比如assertThat(”string”).isNotEmpty():IDE会提示将isNotEmpty的括号去掉,改成属性访问的形式
数据库实体定义
首先引入依赖
// build.gradle.kts
plugins {
...
kotlin("plugin.allopen") version "1.8.0"
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.Embeddable")
annotation("jakarta.persistence.MappedSuperclass")
}
<!-- pom.xml -->
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<configuration>
...
<compilerPlugins>
...
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
<option>all-open:annotation=jakarta.persistence.Entity</option>
<option>all-open:annotation=jakarta.persistence.Embeddable</option>
<option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
</pluginOptions>
</configuration>
</plugin>
实体类定义:
@Entity
class Article(
var title: String,
var headline: String,
var content: String,
@ManyToOne var author: User,
var slug: String = title.toSlug(),
var addedAt: LocalDateTime = LocalDateTime.now(),
@Id @GeneratedValue var id: Long? = null)
@Entity
class User(
var login: String,
var firstname: String,
var lastname: String,
var description: String? = null,
@Id @GeneratedValue var id: Long? = null)
注意:因为Spring Data Jpa无法处理不可变的类型,因此在这里成员变量需要是可变的;如果你使用其他的框架比如Spring Data Jdbc、Spring Data MongoDB等,则可以把var改成val。
同时建议在每个实体类上使用Generated Id(@Id @GeneratedValue),原因如下:Kotlin properties do not override Java-style getters and setters。
开发杂项
变量的相等判断
在Kotlin中,“ == ”相当于Java中的equals()方法,只判断值是否相等;“ === ”相当于Java中的“ == ”,同时判断地址是否一致。
异常处理
Kotlin支持Java异常的大部分写法,但有部分不一致:
// 抛出一个异常
throw Exception("This is an exception.")
// 捕获一个异常
try {
...
} catch (e: Exception) {
...
} finally {
...
}
// try表达式
val a: Int? = try { input.toInt() } catch (e: NumberFormatException) { null }
// throws的Kotlin写法,等价于 void function() throws Exception {}
@Throws(Exception::class)
fun function() {}
同时Kotlin还有一个叫Nothing的类型,用来表示永远不可能有返回结果,即总会返回异常,详见参考链接中的Kotlin Nothing Type。
匿名类和内部类
// 匿名类的使用,摘自Kotlin Documentation
window.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { /*...*/ }
override fun mouseEntered(e: MouseEvent) { /*...*/ }
})
// 内部类的使用,同Java一样,内部类也可以访问外部类的数据
class OuterClass{
var num: Int = 1
inner class InnerClass{
fun foo() = num
}
}
内部类还有this的作用域问题,详见参考链接中的Kotlin This。
常量定义
在Java中定义常量需要放在类中进行:
// Constant.java
class Constant {
public static final String str = "This is a constant string.";
}
在Kotlin中的常量可以直接写在文件中,例如:
// Constant.kt
const val str: String = "This is a constant string"
// 使用常量
// UseConstant.kt
import Constant.str
println(str)
常用数据结构及相关方法
Kotlin中支持的数据结构如下图所示:
其中MutableList、MutableSet、MutableMap支持向其中添加或删除元素,List、Set、Map则不支持。
Kotlin的List支持的常用操作:
val numbers = listOf(1, 2, 3, 4, 5)
println(numbers.indexOf(2))
println(numbers.lastIndexOf(4))
println(numbers.indexOfFirst{ it > 3 })
println(numbers.indexOfLast{ it < 5 })
println(numbers.binarySearch(3)) // 使用二分查找
Kotlin的Set支持交并补的操作:
val numbers = setOf("one", "two", "three")
println(numbers intersect setOf("two", "one")) // 交
println(numbers union setOf("four", "five")) // 并
println(numbers subtract setOf("three", "four")) // 补
值得注意的是,Kotlin的Map支持使用方括号的形式访问元素:
val map: Map<String, int> = mutableMapOf("one" to 1, "two" to 2) // 此方法创建LinkedHashMap,保存记录顺序,此外还有不保存记录顺序的HashMap
map["three"] = 3 // 等价于map.push("three", 3),Java和Kotlin同理
println(map["one"]) // 1
map -= "one" // 等价于map.remove("one")
在Kotlin中,filter、map、groupBy等操作也有相应的支持,详见参考链接中的Kotlin Idioms和Kotlin Collections。
类型判断、类型转换和空值判断
Kotlin支持可空类型,举例如下:
var str: String? = null // 使用Type?来定义一个可空类型
val str2: String = null // 报错
str = "123"
val str3 = str!! // 使用!!将可空类型转成不可空类型,如果此变量值为null那么就会报错
val len = str?.length // 如果str不为null,则返回它的长度,否则返回null
str?.let{ println(str) } // 如果str不为null,则进行函数调用,否则不执行
Kotlin的类型判断和类型转换:
val b: Int? = a as? Int // 尝试类型转换,如果不成功就返回null
val b: Int? = a as Int // 尝试类型转换,如果不成功就报错
// Kotlin类型判断
if (obj is String) { println(obj.length) } // 这里会自动转换类型
if (obj !is String) {} // 等价于if (!(obj is String))
Kotlin中有一个名为Any的类型,表示任何类型,但是Java中的Object对应Kotlin中的Any?,因为Java中的Object可以是null。
函数和扩展方法
Kotlin的函数支持默认参数和按名传参:
fun test(a: Int? = null, str: String = "") { ... }
test(str = "123123")
如果Kotlin的函数比较简短,那么可以变成一个单表达式函数:
fun test(): Int = 42
Kotlin支持扩展函数,从而更加方便地为已有的类型添加功能:
// 定义方法:fun Type.funcName() { this.xxxxx },扩展函数同样支持参数列表
fun String.spaceToCamelCase() {
...
this.xxx() // 使用this访问对象
...
}
// 使用扩展函数
"Convert this to camelcase".spaceToCamelCase()
参考链接
- Getting Started | Building web applications with Spring Boot and Kotlin
- Kotlin Idioms
- https://stackoverflow.com/a/34462577/17271495
- Is it possible to use Lombok with Kotlin? - Stack Overflow
- MockMvc Kotlin Dsl
- Kotlin Nothing Type
- Kotlin This
- Kotlin Collections
- Kotlin Docs | Kotlin Documentation (kotlinlang.org)