Skip to content

fantasy0v0/hibernate-practice

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

50 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spring Data JPA 使用经验总结

说明

Hibernate非常适合业务明确、稳定、规范(例如:遵循数据库范式)的项目, 我非常推荐你在这类项目中使用Hibernate

请大家根据自己项目的实际情况选择合适的框架, 不要为了使用而使用, 导致产生不必要的麻烦和误解

本项目使用Kotlin作为主要语言, 但如果与Java的版本有较大差异时, 我也会单独写一份Java版的样例供大家参考

涉及的版本

项目 版本 备注
Java 21
Kotlin 1.9.21
Spring Boot 3.2.0
Hibernate 6.3.1.Final
PostgreSQL 16 (为了更贴合实际, 所以替换成了PG)

前置工作

依赖

建议引入以下依赖, 该依赖会自动生成实体类的一些可能会用到的对象和常量, 方便在后面的条件查询时使用

Hibernate 6及以上

<dependency>
  <groupId>org.hibernate.orm</groupId>
  <artifactId>hibernate-jpamodelgen</artifactId>
</dependency>

Hibernate 6以下

<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-jpamodelgen</artifactId>
</dependency>

实体类的定义

Java

在引入Lombok的前提下, 可以参考以下方式

/**
 * 不直接使用@Data的原因是, Lombok自动生成的方法会在调用时触发懒加载, 例如toString会打印实体类中所有属性的值
 */
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@DynamicUpdate
public class Student {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private long id;

  @Column(nullable = false)
  private String name;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(foreignKey = ForeignKey(value = ConstraintMode.NO_CONSTRAINT))
  private Clazz clazz;
  
  @CreationTimestamp
  @Column(insertable = false)
  private LocalDateTime createdAt;
}

创建一个实体对象, 利用Builder模式, 可以只填写非空字段

var po = Student.builder().name("xxx").build();

Kotlin

Kotlin需要配合NoArg、AllOpen插件才能正常使用, 可以参考本项目的配置

@Entity
@DynamicUpdate
class Student(
  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  var id: Long = 0,

  @Column(nullable = false)
  var name: String,
  
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(foreignKey = ForeignKey(value = ConstraintMode.NO_CONSTRAINT))
  var clazz: Clazz,
  
  @CreationTimestamp
  @Column(insertable = false)
  var createdAt: LocalDateTime = LocalDateTime.now()
)

创建一个实体对象, 利用Kotlin的特性, 可以只填写非空字段

val student = Student(name = "xxx")

维护好@ManyToOne

如果想方便的联表查询, 必须维护

@OneToMany, 可有可无, 不是特别有用

Repository

建议新建一个Repository接口来继承JpaRepository、JpaSpecificationExecutor, 这样每个实体仓库都只需要继承一个接口即可, 具体参考项目中的AbstractRepository接口。

部分需求的解决方案

单表查询

在定义好对应的实体类和Repository接口后, 只需要调用Repository的findById方法即可实现单表查询

// 用于不确定记录是否存在的场景
studentRepository.findByIdOrNull
// 用于确认记录肯定存在时, 仅仅是需要一个引用时使用
studentRepository.getReferenceById

多表关联

涉及多表查询时, 请先维护好实体们的@ManyToOne

在维护好多对一关系后, 只需像下面一样即可实现多表关联查询

val student = studentRepository.getReferenceById(1L)
// 查询学生所在的班级名称
println(student.clazz.name)
// 该方法在开启懒加载时会导致产生2次查询的问题, 后面的章节会介绍

具体可以到ManyToOneTest#test1中进行试用和调试

简单条件查询

该章节将为大家介绍如何在Spring Data Jpa中如何进行简单条件查询

根据Id查询

如果我们需要根据id查询, 可以使用我们之前编写好的Repository接口的getReferenceById方法查询

根据id以外的字段查询

光有根据Id查询是不能满足日常的开发工作的, 我们通常还会需要根据其他字段进行查询

此时我们可以使用Spring Data Jpa提供的Query Methods来快速编写一些简单的查询方法

interface StudentRepository: AbstractRepository<Student, Long> {

  /**
   * 根据名称查询
   */
  fun findAllByName(name: String): List<Student>
}

调用该方法, 会为我们自动生成如下的HQL语句

select s from Student s where s.name = ?1

不太想用Query Methods, 有没有直观一点的方法?

如果你也有这样的烦恼的, 可以尝试一下@Query注解, 它支持我们直接编写HQL或SQL

interface StudentRepository: AbstractRepository<Student, Long> {

  /**
   * 根据名称查询
   */
  @Query("""
    select s from Student s where s.name = ?1
  """)
  fun findAllByNameWithQuery(name: String): List<Student>

  /**
   * 根据名称查询, 使用原生sql语句
   */
  @Query("""
    select * from student where name = ?1
  """, nativeQuery = true)
  fun findAllByNameWithNativeQuery(name: String): List<Student>
}

如何调用原生的函数

JPA默认支持的函数不多, 可以参考该章节 知道支持的函数列表

对于原生函数的调用, 可以通过以下方法来实现

select s from Student s where function('to_char', s.createdAt, 'yyyy-MM-dd HH:mm:ss') 

详情可见单元测试EntityManagerTest#testNativeFunction

❗注意事项

❗如何做到动态更新

默认情况下, JPA每次更新都会set所有的非主键字段, 但有些时候我们只需要更新部分字段, 该如何实现呢?

使用@DynamicUpdate注解, 有了该注解的实体类, 在进行更新操作时, 只会更新有数据变更的列

❗阻止某些列参与更新

有些时候, 我们希望就算某些属性发送了变更, 也不要更新到数据库中, 此时只需要在@Column的参数声明updatable = false即可

❗ManyToOne如何不使用外键

默认情况下, ManyToOne会自动生成一条外键, 部分公司或开发人员可能更倾向于使用没有外键的方式

我们可以通过使用JoinColumn注解取消外键的生成

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(foreignKey = ForeignKey(value = ConstraintMode.NO_CONSTRAINT))
var clazz: Clazz

❗为什么我没有调用更新方法, 最终却更新到数据库中?

在手动开启事务的情况下(open-in-view的不算), Jpa会提交你对实体类做的任何修改(尽管你没有调用更新方法).

/**
 * 在这个例子中的最后, 
 * 我们修改了clazz的name, 
 * 尽管我们没有进行任何的更新和提交操作, 
 * jpa还是替我们提交了对clazz的修改
 */
@Transactional
@GetMapping("/test")
open fun test(): String {
  val clazz = clazzHelper.create()
  clazz.name = "modify"
  return String.format("%s %s", clazz.id, clazz.name)
}

据我了解目前无法对JPA的这种行为进行限制, 不过如果我们换一种思路, 实体类就是数据库中记录的引用, 更新实体类就是在更新表记录, 这样是否更加容易接受呢?

所以, 最好不要将实体类用于其他用途, 只作为数据库记录的载体而使用。

不启用open-in-view时的注意事项

在涉及懒加载操作时, 需要主动开启事务

复杂条件查询

看过之前章节的人应该会发现, 简单条件查询很难满足实际开发需求, 我们可以通过接下来的内容来了解如何在Spring Data Jpa中进行复杂条件查询

可以尝试了解一下HQL,

它比Spring Data Jpa提供的方法更加灵活

接下来为大家介绍一些复杂的查询案例, 看看是否能解决你的需求

Repository中的方法如何返回非当前实体的对象?

日常开发中经常会进行一些联表查询, 只返回一个实体对象是远远不能满足需求的

此时需要另外定义一个类来接收这多个实体

// 类定义
class StudentClassDto(
  val student: Student,
  val clazz: Clazz) {
}

// Repository中的方法可以这样写
@Query("""
    select 
    new io.fantasy0v0.po.student.dto.StudentClassDto(s, c)
    from Student s left join Clazz c on c = s.clazz
  """)
fun findAll(): List<StudentClassDto>

详情可见单元测试DtoTest#test_1

@Query对应的代码版

val cb = entityManager.criteriaBuilder
val cq = cb.createQuery(StudentClassDto::class.java)
val root = cq.from(Student::class.java)
cq.multiselect(
  root,
  root[Student_.clazz]
)
cq.where(cb.equal(root[Student_.id], student.id))
val query = entityManager.createQuery(cq)
query.firstResult = 0
query.maxResults = 1
Assertions.assertEquals(student.id, query.singleResult.student.id)
Assertions.assertEquals(student.clazz.id, query.singleResult.clazz.id)

详情可见单元测试SpecificationTest#testDto

我们可能并不需要一个实体类?

尽量直接通过HQL或@Query将实体转换成DTO或者VO, 而不是直接操作实体

使用@Query进行动态条件查询

@Query("""
  Select s from Student s left join fetch Clazz c on c = s.clazz
  Where
    (?1 is null or c.id = ?1)
  and
    (?2 is null or s.id = ?2)
""")
fun findAll_2(clazzId: Long?, studentId: Long?): List<Student>

使用Specification进行动态条件查询

TODO 目前发现的问题

  • @Query不能和Specification同时使用
  • Specification只能返回Entity
  • 如果结果依赖了懒加载字段, 在查询时需要手动标记一下需要fetch的字段

TODO

利用@Subselect注解解决Specification的局限性

利用@Subselect注解来解决复杂多表查询, 同时还解决了动态条件的问题

如果不存在动态条件那可以直接使用@Query注解

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Immutable
@Subselect("""
  SELECT
    s.id, s.name, c.id as clazz_id, c.name as clazz_name
  from student s left join clazz c on s.clazz_id = c.id 
  """)
@Synchronize({"student", "clazz"})
public class StudentView {

  @Id
  private long id;

  @Column
  private String name;

  @ManyToOne(fetch = FetchType.LAZY, optional = false)
  private Clazz1 clazz;

  @Column
  private String clazzName;
}

由于我们这个类对应的并非数据库的表, 所以我们需要取消和增加一些注解来表明它无法进行更新操作

需要取消的注解:

  • @Setter
  • @Builder

需要增加的注解:

  • @Immutable 表明该Entity只读
  • @Subselect 关联的查询语句
  • @Synchronize 自动flush指定表, 避免无法查询到对应的数据

经验总结

Spring Data Jpa的findById可能并不好用

findById可以帮我们快速获取一个实体类, 但是我们的实体类中如果有懒加载字段, 并且我们还需要使用这个懒加载字段时, 就会产生* 2次查询*

使用以下的hql可以帮助我们在获取实体类体的同时,获取它的懒加载字段的实体, 并且只产生1次查询

select s from Student s join fetch Clazz where s.id = 1 

如果不触发懒加载的情况下, 获得关联对象编号

从Hibernate 6开始, 如果只获取id, 不会触发懒加载

详情请查看LazyTest#getClazzId方法的代码及日志

val optional = studentRepository.findById(studentId)
assertTrue(optional.isPresent)
val student = optional.get()
// 只获取id不会触发
log.debug("clazz id: {}", student.clazz.id)
// 触发懒加载, 由于没有事物, 所以导致报错
log.debug("clazz name: {}", student.clazz.name)

旧版本

Session session = (Session) this.entityManager.unwrap(Session.class);
Long id = session.getIdentifier(entity);

About

关于Hibernate、Spring-Data-JPA 使用经验总结

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published