用 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 时最难追踪的错误。

kotlinspring-bootjparest-apibackend