本文主要介绍了 Kotlin 如何使用 Spring Data JPA 来操作 PostgreSQL 数据库。

Spring Data JPA 介绍

Spring Data JPA 是一个流行的 Java (Kotlin) ORM 库,也是 Spring 大家庭的一员。JPA 力图通过面向对象(Object Oriented)的思想来操作数据库,以减少实现数据访问层(DAO)的工作量。我们只需定义存储库接口,Spring 就会自动提供实现。

JPA 基本使用

在使用 JPA 之前,首先要进行相关信息的配置。修改 application.properties 文件

# postgres
spring.datasource.url=jdbc:postgresql://localhost:5432/bookstore
spring.datasource.username=postgres
spring.datasource.password=1234
spring.datasource.driver-class-name=org.postgresql.Driver
# jpa
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

postgres 部分定义了数据库的地址,用户名和密码,以及数据库的驱动。而 JPA 部分则定义了 JPA 运行时的一些行为,比如在启动时如何更新表结构等。

我们首先定义实体类。创建文件 User.kt

@Entity
@Table(name = "\"user\"")
class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @Column(name = "username", nullable = false)
    var username: String

    @Column(name = "password", nullable = false)
    var password: String
}

@Entity 注解表明这是一个实体类,而 @Table 标注表名。注意表名 user 的前后加了转义后的双引号,是因为 user 是 PostgreSQL 里的保留字,这样才能成功建表。

表的字段首先是主键 id,用 @Id 注解表明。@GeneratedValue 表明了主键的生成策略,这里选择 IDENTITY,即由数据库生成自增主键。@Colume 注解表明字段名以及该字段是否可为 null。后面的两个字段 username 和 password 也类似,这样就定义了一个有三个字段的表。

idusernamepassword
1abc123456

接下来我们定义数据访问接口。创建文件 UserRepository.kt

interface UserRepository : JpaRepository<User, Long> {
    // 根据用户名查询
    fun findByUsername(username: String): User?

    // 根据用户名计数
    fun countByUsername(username: String): Long
}

这样就定义好了两个查询方法,分别是根据用户名来查询用户和根据用户名来计数。除此之外,JPA 还默认生成了 findById() 方法,只不过它返回的结果并不是我们定义的 User 类,而是一个 Optional 对象,需要进一步的操作才能取出对象。

上面介绍了“增删改查”中的查,JPA 的 “增、改”操作都是由 save() 方法实现,“删”是由 delete() 方法实现。下面举个例子来说明如何使用 JPA 来操作数据库。

// 根据用户名查询
val user1 = userRepository.findByUsername("abc")
// 修改密码
user1.password = "654321"
userRepository.save(user1)
// 根据主键查询
val user2 = userRepository.findById("def").orElse(null)
// 删除用户
userRepository.delete(user2)
// 根据用户名计数
val count = userRepository.countByUsername("ghi")

JPA 关系映射

上面介绍了 JPA 的基本用法,下面介绍如何用 JPA 实现数据表之间的映射关系,即一对一,一对多和多对多关系。

@OneToOne

一对一是最简单的映射关系。例如一个人只有一张身份证,而一张身份证对应一个人。一对一关系可以由任何一方来维护。

// 公民表
@Entity
@Table(name = "Person")
class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @OneToOne
    var idCard: IdCard? = null
}

// 身份证表
@Entity
@Table(name = "IdCard")
class IdCard {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @OneToOne(mappedBy = "idCard")
    var person? = null
}

mappedBy 表明这个一对一关系由 Person 表维护。

@OneToMany

一对多关系也很常见,例如书籍和出版社的关系。每本书籍只有一个出版社,而一个出版社对应很多本书籍。一对多关系通常由多的那一方来维护。

// 书籍表
@Entity
@Table(name = "Book")
class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @ManyToOne
    var press: Press? = null
}

// 出版社表
@Entity
@Table(name = "Press")
class Press {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @OneToMany(mappedBy = "press")
    var books: MutableSet<Book> = mutableSetOf()
}

这样 Press 的主键会作为一个字段插入到 Book 表中。Book 表就像这样

idpress_id
12

而如果我们指定让一的一方维护关系的话,就需要一个中间表来存储关系,增大了系统的复杂度。

@ManyToMany

书籍和作者的关系是一种典型的多对多关系。每本书籍可以有多个作者,而每个作者也可以对应多本书籍。多对多关系可以由双方的任一方来维护。多对多关系需要一个中间表来存储。

// 书籍表
@Entity
@Table(name = "book")
class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTabler
        name = "book_author",
        joinColumns = [JoinColumn(name = "book_id")],
        inverseJoinColumns = [JoinColumn(name = "author_id")]
    ) var authors: MutableSet<Author> = mutableSetOf(),
}

// 作者表
@Entity
@Table(name = "author")
class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @ManyToMany(mappedBy = "authors")
    var books: MutableSet<Book> = mutableSetOf(),
}

这样就会创建一个名为 “book_author” 的中间表来存储 Book 和 Author 的关系,表的内容为 Book 和 Author 的主键。

book_idauthor_id
35

@ElementCollection

下面以“商品-订单”关系来介绍一种相对复杂的映射关系。在订单中有多种商品,而每个商品又有着不同的数量,这时可以通过 @ElementCollecionMapKeyJoinColumn 来实现。

// 商品表
@Entity
@Table(name = "product")
class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null
}

//订单表
@Entity
@Table(name = "\"order\"")
class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @ElementCollection
    @CollectionTable(
        name = "order_product",
        joinColumns = [JoinColumn(name = "product_id")]
    )
    @Column(name = "product_amount")
    @MapKeyJoinColumn(name = "order_id")
    var products: MutableMap<product, Int> = mutableMapOf()
}

这样也会创建一个名为 “order_product” 的中间表来存储订单中各类商品的数量关系,表的内容为

order_idproduct_idproduct_amount
100120503
100120772

添加索引

我们可以使用 @Table 注解来添加索引。如

@Entity
@Table(
    name = "book",
    indexes = [
        Index(name = "idx_name", columnList = "name", unique = false),
        Index(name = "uni_isbn", columnList = "isbn", unique = true),
    ]
)
class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @Column(name = "name", nullable = false)
    var name: String,

    @Column(name = "isbn", nullable = false)
    var isbn: String,
}

上面为 Book 表添加了两条索引,分别是对于书籍名字的普通索引和对于 ISBN 编号的唯一索引。通过索引可以大大提高数据的查询速度。

开启审计

JPA 的审计功能可以方便地记录数据项的创建时间和最后修改时间,只需要在实体类上添加 @EntityListeners 注解。

@Entity
@Table(name = "\"user\"")
@EntityListeners(AuditingEntityListener::class)
class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null

    @CreatedDate
    @Column(name = "created_time", nullable = false)
    var createdTime: LocalDateTime? = null

    @LastModifiedDate
    @Column(name = "modified_time", nullable = false)
    var modifiedTime: LocalDateTime? = null
}

实体类继承

如果我们想要所有的实体类都继承自一个基类的话,可以使用 MappedSuperclass 注解。

被该注解标注的类不会在数据库中创建单独的表,但该类所拥有的属性都将映射到其子类的数据库表的字段中。

例如

// 基类
@MappedSuperclass
class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    var id: Long? = null
}

// 用户表
@Entity
@Table(name = "\"user\"")
class User: BaseEntity() {
    @Column(name = "username", nullable = false)
    var username: String

    @Column(name = "password", nullable = false)
    var password: String
}

这样 User 表就有三个字段,分别是 idusername 以及 password

Kotlin All-open 插件

由于 Spring AOP 的设计,所有 Spring 相关的类都需要是 open 的。我们可以使用 all-open 插件来方便地解决这一问题。

all-open 插件默认支持以下注解:

  • @Component
  • @Async
  • @Transactional
  • @Cacheable
  • @SpringBootTest

由于 @Component@Configuration@Controller@RestController@Service 以及 @Repository 的元注解,所以以上注解也都默认为 open。

所以,我们只需要在插件中添加 JPA 相关注解就可以了。

修改 build.gradle.kts 文件

allOpen {
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Entity")
}