Features Kotlin Tutorials

将 Spring Data JDBC 与 Kotlin 搭配使用

Read this post in other languages:

这篇博文由我与 Thorben Janssen 共同撰写,Thorben 拥有 20 余年的 JPA 和 Hibernate 经验,并且是“Hibernate Tips: More than 70 Solutions to Common Hibernate Problems”和 JPA 简报的作者。

Spring Data JDBC 提供了一套简单、可预测的持久性模型。它侧重于聚合根、基于构造函数的映射,以及清晰的数据读写规则。如果您喜欢处理显式数据流,并希望完全掌控 SQL,那么 Spring Data JDBC 可能是您项目的完美框架。

并且借助 Kotlin,一切变得轻松得多。Kotlin 侧重于不可变性、null 安全和简洁的数据结构,这与 Spring Data JDBC 的设计理念高度一致。在本文中,您将了解到如何使用 Kotlin 来建模聚合、存储和检索数据、使用 value 对象、处理子实体,以及定义自定义查询。

Kotlin 在基于 JDBC 的持久性方面的优势

在查看具体示例之前,了解 Kotlin 如此适合这种编程模型的原因将对您有所帮助。

Data 类确保聚合的简洁性。它们定义构造函数形参,实现 equals()hashCode()toString() 方法,并鼓励使用不可变状态。Spring Data JDBC 为基于构造函数的映射提供强大支持,并能轻松处理不可变聚合,从而显著减少样板代码。

Kotlin 的类型系统也减少了很多常见错误。为 null 性是显式的,因此您可以立即看出哪些字段可能不包含值。由于不会将 null 值静默转换为空字符串或默认基元,基于构造函数的映射可靠性得到提升。

利用轻量级 value 类,您无需引入冗余代码即可表达领域概念。电子邮件地址、客户编号或价格会成为您模型中的一等概念。当然,Spring Data JDBC 可以对其进行映射,而无需任何额外的样板代码或映射注解。

由于 data 类与构造函数映射完美适配,Kotlin 还简化了自定义查询投影。默认形参、命名形参与集合操作让聚合更新变得简单直接。

所有这些功能,让 Spring Data JDBC 与 Kotlin 自然而然地契合。

使用 Kotlin 定义聚合根

聚合是领域驱动设计 (DDD) 概念中引入的一种模式。它由一个或多个实体组成,这些实体在进行数据库读写时会作为一个单元来处理。聚合根是聚合的主要对象。引用聚合或从数据库中获取聚合时,会对聚合根进行寻址。 

我们先介绍一个仅包含聚合根的简单聚合。每当您决定持久化或更新实例时,每个实例都会以记录形式存储在您的数据库中。没有隐藏状态,也没有代理。

以下 data 类表示一个人。@Table 注解是可选的,但它明确将该类标记为实体。这有助于 IntelliJ IDEA 在构建持久性层时为您提供最合适的工具。

@Id 注解将字段标记为对象的标识符。@Sequence 注解是可选的,它告诉 Spring Data 在持久化新对象时从数据库序列中检索值。由于这是您在代码中创建新的 Person 对象之后发生的,ID 字段必须可为 null。

如果您愿意,可以将所有其他字段定义为不可为 null。

@Table
data class Person(
    @Id
    @Sequence(sequence = "person_seq")
    val id: Long? = null,
    val firstName: String,
    val lastName: String
)

如您在代码段中所见,您无需提供任何额外的映射注解。默认情况下,Spring Data JDBC 会将类映射到同名的数据库表,并将每个字段映射到同名的列。您可以为类添加 @Table 注解,为字段添加 @Column 注解,以更改此映射。但大多数团队试图避免这样做,以确保其实体易于阅读和理解。

默认情况下,Spring Data JDBC 使用主构造函数来创建与水合实体实例。如果您希望 Spring Data JDBC 使用其他构造函数,也可以为该构造函数添加 @PersistenceCreator 注解。这与 Kotlin 的 data 类非常契合,因为所有非主键字段都可以是不可变、必选的,并且具有默认值。这能帮助您避免出现未初始化的属性,也无需像使用 Spring Data JPA 以及其他持久性框架时那样必须提供无实参构造方法。

定义聚合后,您需要创建仓库来管理它。

创建仓库和定义查询

Spring Data JDBC 使用仓库接口定义数据访问操作。定义仓库最简单的方式是扩展 Spring Data JDBC 的 CrudRepository

interface PersonRepository : CrudRepository<Person, Long> {}

这会为您提供包括 save()findById()deleteById() 在内的基本方法。

您也可以使用 Spring Data JDBC 的派生查询方法添加自己的查询。这些方法的名称描述了 Spring Data JDBC 应执行的查询。框架会解析方法名、创建相应的 SQL 语句,并映射查询结果。

如果您需要对执行的查询语句进行更多控制,可以定义一个方法,为其添加 @Query 注解,并提供您自己的 SQL 语句。Spring Data JDBC 将完成剩下的工作!

以下是一些示例。

interface PersonRepository : CrudRepository<Person, Long> {
    fun findByLastName(lastName: String): List<Person>

    @Query("select * from Person p where p.last_name = :lastName")
    fun getByLastName(lastName: String): List<Person>
}

您也可以返回 Kotlin data 类投影。如果您要读取特定列,而非整个聚合,或者要将数据转换为其他结构,这种方式非常实用。

下面的 PersonName data 类和 findPersonNameById 仓库方法展示了一个典型示例。 

data class PersonName(
    val id: Long,
    val name: String
)

interface PersonRepository : CrudRepository<Person, Long> {
    @Query("select p.id, p.first_name || ' ' || p.last_name as name FROM Person p where p.id = :id")
    fun findPersonNameById(id: Long): PersonName
}

Spring Data JDBC 会执行已定义的查询,并将查询结果映射到 PersonName 类的构造函数形参。这样可以确保投影代码简洁,同时无需进行手动映射。

2025-12-09T21:59:12.572+01:00 DEBUG 7484 --- [SDJWithKotlin] [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [select p.id, p.first_name || ' ' || p.last_name as name FROM Person p where p.id = ?]
2025-12-09T21:59:12.595+01:00  INFO 7484 --- [SDJWithKotlin] [           main] c.t.j.k.s.SpringDataJdbcKotlinTests      : PersonName(id=401, name=Jane Smith)

持久化并加载聚合

使用仓库简单直接。每次调用都会直接与数据库交互, 无需进行状态跟踪或隐式更新。这使行为易于理解,并允许您完全控制执行的语句。

@Service
@Transactional
class PersonService(private val personRepository: PersonRepository) {
    fun createNewPerson(firstName: String, lastName: String): Person {
        // add additional validations and/or logic ...
        return personRepository.save(
            Person(
                firstName = firstName,
                lastName = lastName
            )
        )
    }

    fun updateLastName(id: Long, lastName: String): Person {
        val person = personRepository.findById(id).orElseThrow()
        val updated = person.copy(lastName = lastName)
        return personRepository.save(updated)
    }
}

当您调用 save() 方法时,Spring Data JDBC 提供的唯一逻辑是检查标识符是否为 null。如果标识符为 null,则插入记录; 如果不为 null,则执行更新。由于所有字段都是不可变的,您一直在使用完整且一致的对象。

在聚合中使用 Kotlin 值对象

真实的聚合通常包含一些应当具有独立类型的值。使用 Kotlin 时,您可以使用 value 类对这些值进行建模,Spring Data JDBC 直接支持这些类。

如果 value 类仅封装一个值,您应为其添加 @JvmInline 注解。这会激活 Kotlin 特定的优化,在运行时用内联值替换包装器类,从而消除包装器类带来的性能开销。

@JvmInline
value class Email(val value: String)

data class Person(
    @Id
    @Sequence(sequence = "person_seq")
    val id: Long? = null,
    val firstName: String,
    val lastName: String,
    val email: Email
)

如您所见,Kotlin 的 valuedata 类可以提高代码的易读性和编写速度。 

使用 Java 实现相同的功能则需要编写更多代码,额外添加映射注解,并使用 Spring Data JDBC 的嵌入式实体概念。

@Table
public class Person {

    @Id
    @Sequence(sequence = "person_seq")
    private Long id;

    private String firstName;

    private String lastName;

    @Embedded.Nullable
    private Email email;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public Email getEmail() {
        return email;
    }

    public void setEmail(Email email) {
        this.email = email;
    }
}

public class Email {

    private String email;

    public Email(String email) {
        this.email = email;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

Java 不支持 value 类。为了尝试弥补这一点,Spring Data JDBC 引入了嵌入式实体的概念。 

通过创建包含一组属性的 Java 类来定义嵌入式实体。在本例中,创建的是包含其 email 属性的 Email 类。要将 Email 类用作属性类型,您必须为其添加 @Embedded 注解。Spring Data JDBC 随后会应用与演示 Kotlin value 类的示例中相同的映射方式。它将 email 字段映射到同名的数据库列,从而让您能在所有查询中使用它。

如此看来,使用 Java 时,为了实现与使用 Kotlin 时简单 value 类相同的效果,您需要编写更多代码,还要使用嵌入式实体。但实际上会更麻烦一些。使用 Kotlin 时,您可以为简单的 value 类添加 @JvmInline 注解,并获得前文所述的优化效果。但使用 Java 时则行不通。因此,嵌入式类映射不仅需要编写更多的代码,还会带来更高的性能开销。

现在,我们回到基于 Kotlin 的示例。

Spring Data JDBC 会基于值对象的封装值对其进行映射,无需任何额外的映射注解。

您甚至可以在派生查询或自定义查询中将 value 类用作形参类型。

interface PersonRepository : CrudRepository<Person, Long> {
    fun findByEmail(email: Email): Person?
}
2025-12-09T22:03:49.340+01:00 DEBUG 14665 --- [SDJWithKotlin] [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT "person"."id" AS "id", "person"."email" AS "email", "person"."last_name" AS "last_name", "person"."first_name" AS "first_name" FROM "person" WHERE "person"."email" = ?]
2025-12-09T22:03:49.363+01:00  INFO 14665 --- [SDJWithKotlin] [           main] c.t.j.k.s.SpringDataJdbcKotlinTests      : Person(id=401, firstName=Jane, lastName=Smith, email=Email(value=a@b.com))

一对多关系建模

聚合可以包含子实体的集合。Spring Data JDBC 会将这些实体类型分别存储在单独的表中。 

data class Company(
    @Id
    var id: Long,
    val name: String,
    val employees: List<Employee>
)

data class Employee(
    @Id
    var id: Long,
    var name: String
)

读取聚合时,Spring Data JDBC 始终会获取包含所有子实体的完整聚合。它会按同样的方式处理所有写操作。在持久化或更新聚合时,Spring Data JDBC 会将整个聚合及其所有实体写入数据库。这与 Kotlin 的不可变列表类型非常契合,但在定义聚合时需要多加留意,以免出现性能问题

事务和实际考量因素

Spring Data JDBC 与 Spring 的事务管理相集成。事务边界能够确保以一致的方式应用聚合内的所有写操作。

Kotlin 可以减少很多典型陷阱。属性必须初始化,为 null 性一目了然,不可变数据可以帮助避免意外的副作用。更新聚合时,您需要创建具有正确状态的新实例,而不是修改现有实例。这样可以确保持久性层的可预测性和易维护性。

结论

Spring Data JDBC 通过清晰、简洁的方式实现关系型持久性。您使用的聚合以完整的单位进行读写,且您始终知晓执行的语句。Kotlin 通过不可变数据结构、value 类、为 null 性规则以及简洁的语法支持这种样式。

如果您细心设计聚合,并将每个实例视为其状态的完整快照,便可构建出易于理解和维护的应用程序。Spring Data JDBC 与 Kotlin 的组合所构成的持久性技术栈在应用程序规模不断扩大的同时仍能保持简洁性。

要详细了解使用 Kotlin 的持久性开发,可以阅读本系列的前两篇文章:   

作者简介

Thorben Janssen

本博文英文原作者:

Teodor Irkhin

Teodor Irkhin

Discover more