用 Kotlin 和 Spring Boot 开发后端
1. 概览
Kotlin 是 Spring 官方支持的编程语言。使用 Kotlin,你可以编写更少的模板代码,在编译时就有 null safety,并拥有用于 DTO 的 data class。本文介绍如何使用 Kotlin 和 Spring Boot 3.x 构建一个管理订单(Order)的 REST API,包括 JPA entity、type-safe 配置和测试。读完本文,你将能够独立初始化一个 Kotlin 后端项目,并避免从 Java 迁移时最常见的错误。
2. Maven 项目设置
2.1. 依赖
除了熟悉的 starter,Kotlin 项目还需要额外的三个依赖。kotlin-stdlib 是 Kotlin 标准库。kotlin-reflect 被 Spring 用来读取 Kotlin 元数据。jackson-module-kotlin 帮助 Jackson 理解 data class 和参数的默认值。
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</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-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
你不需要为 Kotlin artifact 指定版本。Spring Boot parent 已经管理了 kotlin.version。Parent 还会根据 java.version 自动设置 kotlin.compiler.jvmTarget,所以生成的字节码将针对 Java 21。
2.2. 配置 kotlin-maven-plugin
这部分最为重要。在 Kotlin 中,class 和 method 默认是 final。而 Spring 需要为 @Configuration、@Transactional、@Async 等创建代理(CGLIB),因此这些 class 必须是 open 的。Hibernate 需要无参构造函数。两个编译器插件 allopen 和 noarg 解决了这两个问题:
<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>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
<plugin>all-open</plugin>
</compilerPlugins>
<pluginOptions>
<option>all-open:annotation=jakarta.persistence.Entity</option>
<option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
<option>all-open:annotation=jakarta.persistence.Embeddable</option>
</pluginOptions>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
各部分的含义:
spring:自动打开具有@Component、@Configuration、@Transactional、@Async、@Cacheable以及派生注解(如@Service、@RestController)的 class。jpa:为具有@Entity、@Embeddable、@MappedSuperclass的 class 生成无参构造函数。all-open搭配三个选项:打开 entity 以便 Hibernate 能创建 lazy loading 代理。spring插件不处理这部分。-Xjsr305=strict:Kotlin 将 Spring 的 nullability 注解(@Nullable、@NonNullApi)视为真实的 Kotlin 类型。返回@Nullable的 Spring API 将变成T?,你必须处理 null。
3. 应用启动类
package dev.nonla.shop
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication
fun main(args: Array<String>) {
runApplication<ShopApplication>(*args)
}
runApplication<ShopApplication> 是一个有具体化泛型的扩展函数。它等价于 SpringApplication.run(ShopApplication::class.java, *args)。main 函数位于顶级,不需要 companion object。@ConfigurationPropertiesScan 将在第 7 节使用。
4. JPA Entity 和 Repository
package dev.nonla.shop.order
import jakarta.persistence.*
import java.math.BigDecimal
enum class OrderStatus { NEW, PAID, CANCELLED }
@Entity
@Table(name = "orders")
class Order(
var customerName: String,
var amount: BigDecimal,
@Enumerated(EnumType.STRING)
var status: OrderStatus = OrderStatus.NEW,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
)
需要注意几点:
- 所有字段都在 primary constructor 中声明。你不需要编写 getter/setter。
id的类型是Long?,默认值是null,因为 entity 只有在保存后才有 id。- Hibernate 所需的无参构造函数由
jpa插件生成。你在 Kotlin 代码中看不到它。 - 这是普通
class,不是data class。原因在第 9 节解释。
Repository 的编写和 Java 一样简洁:
package dev.nonla.shop.order
import org.springframework.data.jpa.repository.JpaRepository
interface OrderRepository : JpaRepository<Order, Long> {
fun findByStatus(status: OrderStatus): List<Order>
}
5. DTO、data class 和 null safety
DTO 是使用 data class 的最合适地方。Kotlin 自动生成 equals、hashCode、toString 和 copy。
package dev.nonla.shop.order
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import java.math.BigDecimal
data class CreateOrderRequest(
@field:NotBlank
val customerName: String,
@field:Positive
val amount: BigDecimal,
)
data class OrderResponse(
val id: Long,
val customerName: String,
val amount: BigDecimal,
val status: OrderStatus,
)
fun Order.toResponse() = OrderResponse(
id = requireNotNull(id) { "Order 还未被保存" },
customerName = customerName,
amount = amount,
status = status,
)
必须写 @field:NotBlank 而不是 @NotBlank。如果没有使用位置目标 field:,Kotlin 会将注解附加到 constructor 参数。Bean Validation 将看不到该注解,验证会悄无声息地失败。
扩展函数 Order.toResponse() 让 entity 不依赖于 DTO,但仍可以像方法一样调用:order.toResponse()。如果 id 是 null,requireNotNull 会抛出 IllegalArgumentException。之后,编译器将 id 视为 Long 而不是 Long?。
6. Service 和 Controller
6.1. Service
package dev.nonla.shop.order
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class OrderService(private val orderRepository: OrderRepository) {
@Transactional
fun create(request: CreateOrderRequest): OrderResponse {
val order = Order(
customerName = request.customerName.trim(),
amount = request.amount,
)
return orderRepository.save(order).toResponse()
}
fun findById(id: Long): OrderResponse? =
orderRepository.findByIdOrNull(id)?.toResponse()
}
Bean 通过 primary constructor 注入。对于只有一个构造函数的 class,你不需要 @Autowired。字段是 private val,所以不能重新分配。
findByIdOrNull 是 Spring Data 提供的 Kotlin 扩展。它返回 Order? 而不是 Optional<Order>。结合 ?. 操作符,findById 函数可以压缩到一行。返回类型 OrderResponse? 也清楚地表明调用者结果可能不存在。
得益于 spring 插件,OrderService 自动打开。Spring 能为 @Transactional 创建代理,你不需要写 open class。
6.2. Controller
package dev.nonla.shop.order
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(@Valid @RequestBody request: CreateOrderRequest): OrderResponse =
orderService.create(request)
@GetMapping("/{id}")
fun get(@PathVariable id: Long): ResponseEntity<OrderResponse> =
orderService.findById(id)
?.let { ResponseEntity.ok(it) }
?: ResponseEntity.notFound().build()
}
Elvis 操作符 ?: 替代 if (x != null) … else …:有订单则返回 200,否则返回 404。你可以用 curl 试试:
curl -s -X POST localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{"customerName":"Nguyễn Văn An","amount":250000}'
{"id":1,"customerName":"Nguyễn Văn An","amount":250000,"status":"NEW"}
如果请求完全缺少 customerName,Jackson(配合 jackson-module-kotlin)会在反序列化时立即报错,因为 non-null 参数没有值。Spring 会返回 400 Bad Request,甚至在验证之前。如果发送 "customerName": " ",请求通过反序列化阶段但被 @field:NotBlank 拦截,你也会收到 400。
7. 用 @ConfigurationProperties 实现 type-safe 配置
payment.provider=vnpay
payment.timeout=3s
package dev.nonla.shop.payment
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
@ConfigurationProperties(prefix = "payment")
data class PaymentProperties(
val provider: String,
val timeout: Duration = Duration.ofSeconds(5),
val retryCount: Int = 2,
)
从 Spring Boot 3 开始,具有单个构造函数的 class 将通过 constructor 进行绑定。你不需要 @ConstructorBinding。properties 文件中没有的属性(这里是 retryCount)使用 Kotlin 的默认值。因为是 val,配置对象在启动后是不可变的。
像普通 bean 一样注入到 service 中:
@Service
class PaymentService(private val props: PaymentProperties) {
fun describe() = "Thanh toán qua ${props.provider}, timeout ${props.timeout.seconds}s"
}
Thanh toán qua vnpay, timeout 3s
如果缺少 payment.provider(没有默认值),应用将无法启动。这正是你想要的行为:配置错误应该在部署时立即暴露,而不是等到运行时。
8. 编写测试
spring-boot-starter-test 已包含 JUnit 5 和 AssertJ。在 Kotlin 中,你可以用反引号内的描述性句子作为测试名称:
package dev.nonla.shop.order
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.math.BigDecimal
@DataJpaTest
@Import(OrderService::class)
class OrderServiceTest(@Autowired private val orderService: OrderService) {
@Test
fun `创建新订单,状态为 NEW 且客户名称已被修剪`() {
val response = orderService.create(
CreateOrderRequest(customerName = " Trần Thị Bình ", amount = BigDecimal("99000"))
)
assertThat(response.id).isPositive()
assertThat(response.customerName).isEqualTo("Trần Thị Bình")
assertThat(response.status).isEqualTo(OrderStatus.NEW)
}
@Test
fun `当找不到订单时返回 null`() {
assertThat(orderService.findById(9_999)).isNull()
}
}
@DataJpaTest 创建一个小型上下文,仅包含 JPA 和 H2 内存数据库。@Import 将 OrderService 添加到该上下文中。测试类也使用 constructor 注入:在测试中,JUnit 5 需要在参数上使用 @Autowired 来指示从 Spring 获取值。每个测试在事务中运行,并在完成时回滚,所以数据不会泄漏到其他测试。
OrderServiceTest > 创建新订单,状态为 NEW 且客户名称已被修剪() PASSED
OrderServiceTest > 当找不到订单时返回 null() PASSED
9. 常见错误
为 entity 使用 data class。 equals/hashCode 根据所有字段自动生成,包括可能为 null 的 id 和 lazy 关系。结果是 entity 在保存后会“改变哈希值”,破坏 HashSet。toString() 可能会触发 lazy loading 或在双向关系中导致无限递归。
注意: 仅对 DTO 和配置使用
data class。对于 entity,使用普通class。如果需要,基于id自己编写equals/hashCode。
忘记 spring 或 all-open 插件。 缺少插件,Spring 会报告类似 @Configuration class may not be final 的错误。更糟的是,final method 上的 @Transactional 会被无声地忽略。对于 entity,缺少 all-open 会导致 Hibernate 无法创建代理,LAZY 关系会悄无声息地变成 EAGER。
验证注解上缺少 field:。 验证不会运行,也不会报告错误。始终为无效数据情况编写测试以捕获此错误。
使用 lateinit var 进行注入。 这虽然可行,但你会失去不变性,而且容易遇到 UninitializedPropertyAccessException。优先使用带 private val 的 constructor 注入。
忘记 -Xjsr305=strict。 没有这个标志,Spring API 的返回类型是 platform type(T!)。编译器不会强制检查 null,NullPointerException 仍然可能像在 Java 中一样发生。
何时不应该切换到 Kotlin? 如果你的团队中还没有人熟悉 Kotlin,而且项目需要快速交付,学习成本(coroutine、extension、scope function)可能超过收益。Kotlin 代码的编译速度也比 Java 慢一点。此外,一些依赖反射的库(如 Lombok 或 MapStruct 在某些配置下)与 Kotlin 的配合不好。在这种情况下,你可以逐步引入 Kotlin:用 Kotlin 编写新的测试或模块,同时保留旧的 Java 代码。
10. 总结
你已经用 Kotlin 构建了一个 Spring Boot 3 后端,包括:
- 使用
spring、jpa、all-open和-Xjsr305=strict配置kotlin-maven-plugin; - JPA entity 使用普通
class,DTO 使用带@field:的data class; - service 和 controller 利用 null safety、
findByIdOrNull和 Elvis 操作符; - 通过
data class上的@ConfigurationProperties实现不可变配置; - 使用 JUnit 5 和 AssertJ 进行测试,用反引号中的描述性句子作为测试名称。
你已有的大部分 Spring 知识仍然适用。需要记住的是切换到 Kotlin 时的三个区别:class 默认是 final,注解需要 use-site target,以及 nullable 类型必须显式声明。掌握这三点,你将避免从 Java 迁移到 Kotlin 时最难追踪的错误。