1. Spring Data JPA 中的 CRUD 操作
Spring Data JPA 简化了数据库的 CRUD(创建、读取、更新、删除)操作,通过 Repository
接口的自动生成方法,开发者无需手动编写 SQL 语句即可完成大部分操作。下面详细讲解如何定义 Repository
接口、使用自动生成的 CRUD 方法、以及自定义方法。
1.1 定义 Repository 接口
这个接口的定义是在数据持久层(DAO 层,Repository 层),主要用于自定义一些查询方法(这是这个接口最主要的作用)。接口内部可以通过命名规则定义自定义查询方法,以便在业务逻辑层调用。(1.3中的方法命名规则)。
在 Spring Data JPA 中,你可以通过继承 CrudRepository
或 JpaRepository
接口来定义 Repository
,用于处理特定实体的 CRUD 操作。Spring 会自动生成这些接口中的方法实现。
CrudRepository
:这是最基本的接口,提供基本的增删改查操作,如save()
、findById()
、delete()
和count()
。JpaRepository
:继承自CrudRepository
,在其基础上增加了更多 JPA 特性(如分页和排序等功能),一般情况直接实现这个接口,因为这个接口中包含了增删改查操作。
public interface UserRepository extends JpaRepository<User, Long> {
// 自定义查询方法可以在这里定义
}
解释 JpaRepository<User, Long>
:
User
是实体类的类型。Long
是实体类的主键类型。 这两个参数是泛型,具体化了接口中的操作,告诉 JPA 你要管理哪个实体类以及实体类的主键是什么类型。
1.2 常见 CRUD 方法
这些方法的调用是在业务逻辑层(Service 层)中,以下的常见CRUD方法是你不用在那个接口中去定义也能调用的方法,如果想调用自定义的查询方法,需要在上面的接口中按照方法命名规则去定义,然后才能在业务逻辑层中调用。
当你定义了 Repository
接口后,Spring Data JPA 会自动为你生成常见的 CRUD 方法,你可以直接调用这些方法来进行操作。
在 Spring 中,你不需要手动实例化
Repository
。通过依赖注入(@Autowired
注解),Spring 会自动将UserRepository
注入到你的服务类中,管理其生命周期。你可以直接在服务中使用userRepository
的方法:@Service public class UserService { @Autowired private UserRepository userRepository; public void saveUser(User user) { userRepository.save(user); } }
以下的CRUD方法是调用的时候都要调要先依赖注入userRepository对象,然后再调用该对象的以下这些方法
1.2.1 save()
用于保存或更新实体。如果实体对象没有主键,会执行保存(插入);如果有主键且已经存在,则执行更新。
User user = new User();
user.setName("John");
userRepository.save(user); // 插入新用户
1.2.2 findById()
根据主键查找实体类对象。findById()
方法返回一个 Optional<User>
,Optional
是 Java 8 引入的类,用于防止空指针异常。
关于 Optional
:Optional
是为了安全地处理可能为空的值。你需要通过 userOptional.get()
来获取实际的 User
对象。使用 Optional
可以避免直接处理 null
,从而减少空指针异常。
Optional<User> userOptional = userRepository.findById(1L);
if (userOptional.isPresent()) {
User user = userOptional.get();
System.out.println(user.getName());
}
这里实体类User的主键就是 Long类型的ID,通过ID来查找用户。
1.2.3 findAll()
返回所有实体对象的列表,相当于执行 SELECT * FROM
语句。
List<User> users = userRepository.findAll();
1.2.4 delete()
根据实体对象或主键删除数据。
userRepository.deleteById(1L); // 根据 ID 删除用户
1.2.5 count()
返回数据库中实体的总数。
long userCount = userRepository.count();
这些方法的实现都由 Spring Data JPA 自动生成,你只需要调用它们,无需手动实现。
1.3 自定义 Repository 接口
除了使用 Spring Data JPA 提供的内置方法之外,你还可以通过定义自定义查询方法来扩展 Repository
接口。Spring Data JPA 支持根据方法名称推断 SQL 语句,这样无需编写复杂的 JPQL 或 SQL。
- 你可以在 Spring Data JPA 中将返回值设置为
Optional
类型。这在你希望处理空值的场景中特别有用,因为它可以避免直接返回null
,提供更明确的空值处理机制。- 可以使用
Optional
提供的各种方法(如isPresent()
,ifPresent()
,orElse()
,orElseThrow()
等)来处理查询结果。
例如,根据用户的电子邮件查找用户:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
Spring Data JPA 会根据方法名自动生成查询,并返回一个
Optional<User>
对象。调用该方法时,你可以使用Optional
提供的各种方法(如isPresent()
,ifPresent()
,orElse()
,orElseThrow()
等)来处理查询结果。使用
Optional
的一个好处是,你明确表明方法的返回值可能为空,而不是通过返回null
让调用方需要额外的空值检查。
Spring Data JPA 会根据方法名 findByEmail
自动生成相应的查询逻辑,相当于执行如下 SQL 语句:
SELECT * FROM user WHERE email = ?;
findByName(String name)
的自动实现机制:Spring Data JPA 提供了一种基于方法名解析查询的机制。你只需要在接口中定义符合命名规则的方法(如findByName
),Spring 会根据方法名自动生成相应的 SQL 查询。比如findByName
会自动生成类似于SELECT * FROM user WHERE name = ?
的查询逻辑。需要注意的是:方法名中的属性名称要与定义的实体类中的名称一致(而不是数据库的字段名称),并且可以不用关注大小写,一般属性首字母大写。
你只需定义方法,不需要手动实现,Spring 会在运行时根据方法名推断查询条件。Spring Data JPA 的方法命名规则非常强大,允许你通过简洁的命名实现复杂的查询。
以下的查询可通过命名自动实现
1. 基础查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field] | 根据字段查询实体对象。 | List<User> findByUsername(String username); |
countBy[Field] | 根据字段统计符合条件的记录数量。 | long countByEmail(String email); |
existsBy[Field] | 检查某个字段条件的记录是否存在。 | boolean existsByUsername(String username); |
deleteBy[Field] | 根据字段删除符合条件的记录。 | void deleteByEmail(String email); |
2. 条件组合查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field]And[Field] | 根据多个字段的条件查询,使用 AND 连接。 | List<User> findByAgeAndUsername(int age, String username); |
findBy[Field]Or[Field] | 根据多个字段的条件查询,使用 OR 连接。 | List<User> findByAgeOrUsername(int age, String username); |
3. 排序查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field]OrderBy[Field] | 根据字段查询并按另一个字段排序。 | List<User> findByAgeOrderByUsernameAsc(int age); |
findBy[Field]OrderBy[Field]Desc | 根据字段查询并按另一个字段降序排序。 | List<User> findByAgeOrderByUsernameDesc(int age); |
findAllBy[Field]OrderBy[Field] | 查询符合条件的所有记录,并按指定字段排序。 | List<User> findAllByAgeOrderByUsernameAsc(int age); |
4. 模糊查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field]Like | 根据字段进行模糊查询。 | List<User> findByUsernameLike(String username); |
findBy[Field]StartingWith | 查询字段以指定字符串开头的记录。 | List<User> findByUsernameStartingWith(String prefix); |
findBy[Field]EndingWith | 查询字段以指定字符串结尾的记录。 | List<User> findByUsernameEndingWith(String suffix); |
findBy[Field]Containing | 查询字段包含指定子字符串的记录。 | List<User> findByUsernameContaining(String substring); |
5. 空值查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field]IsNull | 查询字段值为 null 的记录。 | List<User> findByUsernameIsNull(); |
findBy[Field]IsNotNull | 查询字段值不为 null 的记录。 | List<User> findByUsernameIsNotNull(); |
6. 范围查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field]Between | 查询字段值在指定范围内的记录。 | List<User> findByAgeBetween(int startAge, int endAge); |
findBy[Field]GreaterThan | 查询字段值大于指定值的记录。 | List<User> findByAgeGreaterThan(int age); |
findBy[Field]LessThan | 查询字段值小于指定值的记录。 | List<User> findByAgeLessThan(int age); |
findBy[Field]GreaterThanEqual | 查询字段值大于或等于指定值的记录。 | List<User> findByAgeGreaterThanEqual(int age); |
findBy[Field]LessThanEqual | 查询字段值小于或等于指定值的记录。 | List<User> findByAgeLessThanEqual(int age); |
findAllBy[Field]Between | 查询字段值在指定范围内的记录。 | List<User> findAllByAgeBetween(int startAge, int endAge); |
findAllBy[Field]GreaterThan | 查询字段值大于指定值的记录。 | List<User> findAllByAgeGreaterThan(int age); |
findAllBy[Field]LessThan | 查询字段值小于指定值的记录。 | List<User> findAllByAgeLessThan(int age); |
findAllBy[Field]GreaterThanEqual | 查询字段值大于或等于指定值的记录。 | List<User> findAllByAgeGreaterThanEqual(int age); |
findAllBy[Field]LessThanEqual | 查询字段值小于或等于指定值的记录。 | List<User> findAllByAgeLessThanEqual(int age); |
7. 集合查询
查询方法 | 描述 | 示例 |
---|---|---|
findBy[Field]In | 查询字段值在某个集合内的记录。 | List<User> findByAgeIn(List<Integer> ages); |
findAllBy[Field]In | 查询字段值在某个集合内的记录。 | List<User> findAllByAgeIn(List<Integer> ages); |
8. 通用查询方法
查询方法 | 描述 | 示例 |
---|---|---|
findAllBy[Field] | 查询符合条件的所有记录。 | List<User> findAllByAge(int age); |
findAllBy[Field]And[Field] | 根据多个字段查询,多个条件使用 AND 连接。 | List<User> findAllByAgeAndUsername(int age, String username); |
findAllBy[Field]Or[Field] | 根据多个字段查询,多个条件使用 OR 连接。 | List<User> findAllByAgeOrUsername(int age, String username); |
1.4 关于查询方法的层层调用
1.4.1 Repository 层(持久层)
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByName(String name);
}
Repository
层定义了与数据库的直接交互方法。- 例如,
UserRepository
定义findByName()
方法来查询用户。
1.4.2 Service 层(业务逻辑层)
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User findUserByName(String name) {
return userRepository.findByName(name).orElseThrow(() -> new RuntimeException("User not found"));
}
}
Service
层会调用Repository
层提供的方法来处理业务逻辑。它将数据访问层和业务逻辑分离,以确保代码的可维护性。
1.4.3 Controller 层(表现层)
@RestController
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/users")
public User getUserByName(@RequestParam String name) {
return userService.findUserByName(name);
}
}
Controller
层负责处理用户的请求(如 REST API 请求),它会调用Service
层来执行业务逻辑。
这是个层层调用的状态,Repository层定义了,然后在Rervice层定义的方法中调用,然后再在Controller层定义的方法中调用Service层定义的方法。(这里要注意三个层中的方法名很相似但不一样)
2. 实体映射与关系建模
实体映射与关系建模是 JPA 中的核心概念,帮助开发者将 Java 对象与关系数据库中的表进行关联,并定义对象之间的关系。
2.1 基本注解
2.1.1 @Entity
-
作用:标记一个 Java 类为 JPA 实体类,表示它将映射到数据库中的表。
-
使用:每个实体类都需要加上
@Entity
注解,才能被 JPA 识别为持久化实体。
@Entity
public class User {
@Id
private Long id;
private String name;
}
2.1.2 @Id
-
作用:标记实体类中的字段为主键。
-
使用:每个实体类必须有一个主键字段,用
@Id
注解标识。
@Id
private Long id;
2.1.3 @GeneratedValue
- 作用:用于定义主键的生成策略。
- 常见的生成策略:
IDENTITY
:数据库使用自增列生成主键(适用于 MySQL 等支持自增列的数据库)。SEQUENCE
:使用数据库序列生成主键(适用于支持序列的数据库如 PostgreSQL)。AUTO
:JPA 自动选择适合当前数据库的生成策略。
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
2.1.4 @Table
-
作用:指定实体类对应的数据库表名称。默认情况下,实体类名就是表名,但可以通过
@Table
注解指定不同的表名。
@Entity
@Table(name = "users")
public class User {
@Id
private Long id;
private String name;
}
2.2 字段映射
2.2.1 @Column
-
作用:映射实体类的字段到数据库表的列,可以指定列名、长度、是否可为空等。
@Column(name = "user_name", length = 50, nullable = false)
private String name;
2.2.2 @JoinColumn
@JoinColumn
就是用来描述 “当前实体对应的表中,哪个列是用来作为外键,映射到另一个表的主键或某个指定列”。
例如,你有一个 Employee
实体关联到 Department
实体:
@Entity
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "dept_id", referencedColumnName = "id")
private Department department;
// ...
}
@JoinColumn(name = "dept_id")
表示在employee
表中,会有一个列名叫做dept_id
,它是一个外键列。referencedColumnName = "id"
表示这条外键列(dept_id
)对应到department
表的id
列(也就是Department
实体的主键)。
(1)不加
referencedColumnName
时的默认值大多数情况下,
referencedColumnName
可以省略不写。因为当我们只写了@JoinColumn(name = "dept_id")
时,JPA 默认会将外键关联到Department
的主键列(即id
)。所以如果你的Department
实体的主键字段名就是id
,那就够用了,常见写法是:@ManyToOne @JoinColumn(name = "dept_id") private Department department;
(2)referencedColumnName
的使用场景只有当被关联实体的主键列名不叫
id
,或者你想关联的并不是主键列(比如你想绑定到某个唯一字段,比如dept_code
),这时候才需要显式写referencedColumnName
,例如:@ManyToOne @JoinColumn(name = "dept_code_fk", referencedColumnName = "dept_code") private Department department;
这样就会让
employee
表中的dept_code_fk
列去对应department
表里的dept_code
列。
2.2.3 @Transient
-
作用:忽略不需要持久化的字段,加上
@Transient
注解的字段不会映射到数据库中。
@Transient
private String tempData;
2.2.4 @Temporal
@Temporal
注解用于将 Java 中的 java.util.Date
或 java.util.Calendar
类型映射到数据库中的特定时间类型。它可以将 Java 的日期时间类型分解为 DATE
、TIME
或 TIMESTAMP
类型。
- 作用:主要用于精确映射日期和时间字段。数据库存储时,不同的类型可以反映出不同的精度。
DATE
:仅存储日期部分(年、月、日),不存储时间。TIME
:仅存储时间部分(时、分、秒),不存储日期。TIMESTAMP
:存储完整的日期和时间。
@Temporal(TemporalType.DATE)
private Date birthDate;
这段代码将Java中特有的Date类型属性在映射到数据库中的字段时进行了处理,如果是TemporalType.DATE的情况,在数据库中只会存储birthDate的日期,而不会存储具体的时间(Java中的Date类型属性包含了日期与精确时间,在这里进行了切割处理)。
2.2.5 @Enumerated()
当枚举类型作为数据库表的字段时,使用这个注释。
@Enumerated(EnumType.STRING)
注解用于指示 JPA 如何将 Java 枚举类型存储到数据库中。EnumType.STRING
表示将枚举的名称(即枚举常量的字符串表示)存储到数据库中,而不是使用它的数字值。
-
EnumType.STRING
:将枚举的名称(如USER
,ADMIN
)存储到数据库中。例如,如果枚举类Role
定义为:public enum Role { USER, ADMIN, GUEST; }
那么在数据库中存储的是
USER
,ADMIN
,GUEST
这样的字符串值。 -
EnumType.ORDINAL
(默认值):将枚举的索引值(数字,如0
表示USER
,1
表示ADMIN
)存储到数据库中。这样做的风险是,如果枚举的顺序发生变化,数据库中的值可能会不一致。
2.3 关系映射
关系映射处理实体类之间的关联关系,包括一对一、一对多、多对多等关系,通常通过外键实现。
2.3.1 一对一(@OneToOne
)
-
作用:映射一对一的实体关系。通常在数据库中通过外键或共享主键来实现一对一关系。
@OneToOne
@JoinColumn(name = "profile_id")
private UserProfile profile;
假设这段代码在User类中,User类映射到了User表,而在User类中具有一个UserProfile类的对象作为其属性,现在就相当于将User表与UserProfile表进行了一对一关联,每个User表中的记录都与一个UserProfile表中的记录相关联。
至于这里的@JoinColumn(name = "profile_id"),其实与@Column注解一样,就是将所注解的属性映射为数据库中的一个字段,区别是,@JoinColumn注解标记的是一个对象属性,这个对象是属性没办法作为一个字段存入User表中,只能将其映射为一个名为profile_id的字段
2.3.2 一对多/多对一(@OneToMany
, @ManyToOne
)
-
@OneToMany
:一个实体可以关联多个其他实体。例如,一个用户可以有多个订单。 -
@ManyToOne
:多个实体可以关联同一个实体。例如,多个订单可以关联同一个用户。 -
mappedBy
:定义了关系的维护方,mappedBy
后面跟的是在对方实体中表示关系的属性名,而不是数据库表中的外键列名。
User
类:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<Order> orders;
}
mappedBy = "user"
:这里的"user"
指的是Order
类中的user
属性。它告诉 JPA,这个一对多关系的外键是由Order
类中的user
属性来管理的。
- 当我在User表中使用@OneToMany(mappedBy ="user" )对orders进行注释的时候,表Order中会存储一个(User类型的对象属性,这个属性的名字叫做user,但是它在数据库中的字段名称是根据Order表中的 @JoinColumn(name = "user_id")注释,所以这个字段在Order表中的名称是user_id)外键,这个外键会指向User表的主键id。
- 所以我在User表中使用@OneToMany(mappedBy ="user"),实际上是让Order表中多出来一个外键字段指向User表的主键字段,User表中是不存储相关的字段的。
意思就是在User表中不存在外键列指向Order表中的主键列,只有Order表中存在外键列指向User表的主键列,这里mappedBy = "user"的user是在Order类中存在的一个属性名称(这个属性在Order表中对应的字段的名称是user_id),下面的Order类中可以看到,有一个 private User user属性。
cascade = CascadeType.ALL
的含义:cascade
选项在 JPA 中定义了操作(如保存、更新、删除)在父实体与其关联的子实体之间的传播行为。当在一个实体的属性上使用@OneToMany
注解时,cascade = CascadeType.ALL
的意思是:对该父实体的所有操作(如保存、删除、更新等),都会自动传播到与其关联的所有子实体。
Order
类:
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "user_id") // 指定外键列为 user_id
private User user;
}
- 在
Order
类中,@ManyToOne
注解和@JoinColumn(name = "user_id")
表示user
属性将被映射为数据库表中的user_id
外键列,这个列会保存关联的User
的主键值。
2.3.3 多对多(@ManyToMany
)
-
作用:
@JoinTable
主要用于多对多(@ManyToMany
)的关系。因为在多对多的关系中,通常会有一个独立的中间表来管理两个实体之间的关联关系,这个表通常包含两个外键,分别指向两个实体的主键。 -
使用场景:当两个实体之间存在多对多的关系时,需要一个中间表来保存两个实体的主键关联。
@JoinTable
用于定义这个中间表的名称,以及中间表中的外键列。 -
例子:
@Entity
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "student_course", // 中间表的名称
joinColumns = @JoinColumn(name = "student_id"), // 当前实体(Student)的外键列
inverseJoinColumns = @JoinColumn(name = "course_id") // 对方实体(Course)的外键列
)
private List<Course> courses;
}
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "courses")
private List<Student> students;
}
在这个例子中,@JoinTable 用于定义 student_course 中间表,它包含两个外键列:student_id(指向 Student 实体的主键)和 course_id(指向 Course 实体的主键)。
两个表因为多对多的关系,所以两个表中按理来说都要有都具有自己的主键列与指向对方主键列的外键列 ,所以这里就创建了一个中间表,这个表中的每一条记录都包含两个表的外键列字段,通过这一张表将两个表中的记录相关联。
- JPA 注解(例如 @ManyToMany, @JoinTable 等)必须直接放在你希望建立关联关系的集合或属性上。由于需要建立 Student 实体和 Course 实体之间的关联,因此在 Student 类中,@ManyToMany 和 @JoinTable 注解被放置在 private List<Course> courses 属性上,以定义它们之间的多对多关系。
- 同样地,在 Course 类中,由于 Course 实体和 Student 实体的关系通过 Course 类中的 private List<Student> students 属性关联起来,因此 @ManyToMany(mappedBy = "courses") 注解放置在 private List<Student> students 属性上,以表示关系由 Student 类维护。
示例如下:
-
Student
表:id name 1 Alice 2 Bob -
Course
表:id name 101 Math 102 Science -
student_course
中间表:student_id course_id 1 101 1 102 2 101
- 多对多关系的拥有方(Student类):当你对具有中间表 @JoinTable注解的类(Student类)的对象(student)进行添加、删除、修改所关联的另一个类的对象(course)时,JPA 会自动维护中间表,对中间表进行修改。
- 多对多关系的被拥有方(Course类):但当你对具有@ManyToMany(mappedBy = "courses")注解的类(Course类)的对象进行添加、删除、修改所关联的另一个类的对象(student)时,修改完之后,JPA不会对中间表进行修改,这种操作不会影响数据库中的关联记录(类似于无效操作)。
示例如下:
从 Student
侧(拥有方)进行操作
// 创建一个学生和两个课程
Student student = new Student();
student1.setName("Alice");
Course course1 = new Course();
course1.setName("Math");
Course course2 = new Course();
course2.setName("Science");
// 将课程添加到学生的课程列表
student1.getCourses().add(course1);
student1.getCourses().add(course2);
// 保存学生,JPA 会自动更新 student_course 中间表
studentRepository.save(student);
student1
将会关联course1
和course2
,JPA 会在中间表student_course
中插入两条记录,分别对应student1
和course1
、student1
和course2
的关系。
从 Course
侧(被拥有方)进行操作
Course course = courseRepository.findById(1L).get();
Student student = new Student();
student.setName("Bob");
// 将学生添加到课程
course.getStudents().add(student);
// 保存课程,但不会更新中间表
courseRepository.save(course);
- 虽然添加了学生,但中间表不会更新,因为
Course
是被拥有方,必须从Student
侧操作才能影响中间表。
在使用 JPA(或 Spring Data JPA)时,如果你配置了自动建表(例如
spring.jpa.hibernate.ddl-auto=create
或update
),而且你的实体中使用了@ManyToMany
没有自定义中间表名,Hibernate 会在启动时自动创建这张中间表(名字通常是根据两个实体类名拼接而成,例如entityA_entityB
),并且只包含用来存储外键的两列。
- 只要你打开了自动建表功能(或自己手动执行生成的 DDL 脚本),Hibernate/JPA 就会为
@ManyToMany
关系自动在数据库创建出中间表。- 如果你关闭了自动建表(
ddl-auto=none
或者validate
),就需要手动建表,否则 JPA 运行时无法插入/查询该表。纯
@ManyToMany
+@JoinTable
最适合“仅仅需要两个外键列”这种简单场景;如果你想在中间表里额外保存别的列,就需要把中间表映射成一个实体类,从而获得对该表所有列的完整操作能力。
2.3.4 级联操作和抓取策略
1. CascadeType
(1) 级联操作的重要性
必要性:当实体类之间存在关联关系时,如果不设置级联操作,保存一个实体类时,另一个关联的实体类可能尚未持久化(即未保存到数据库),其主键尚未生成,这会导致外键无法引用主键,进而引发错误(如
TransientPropertyValueException
或外键约束失败)。目的:通过设置级联操作,可以在保存、删除或更新某个实体时,自动对关联的实体执行相应的操作,从而保证数据完整性和一致性。
(2) 级联设置的原则
设置级联的端:
在经常操作的实体类上设置级联。这样,当操作该实体类时,其关联的实体类会一同被持久化或更新。
级联操作应设置在 关系的维护端(即维护外键的实体类)上,因为只有维护端负责管理外键字段。
使用场景:
多对一(
@ManyToOne
):通常在多的一端操作较多,因此级联多设置在@ManyToOne
中。一对多(
@OneToMany
):若业务逻辑中“一”的一端是主操作对象,则可以在@OneToMany
中设置级联。多对多(
@ManyToMany
):可以根据业务操作需求,选择一端或双端设置级联。
(3) 不设置级联的风险
如果未设置级联操作,当保存一个实体类时,关联的实体类未持久化,就会出现以下问题:
主键未生成:关联实体尚未保存到数据库,导致其主键为空。
- 外键无法引用:维护外键的实体无法设置正确的外键引用,触发数据库约束异常或 JPA 持久化错误。
- 手动操作复杂:开发者必须手动确保所有关联实体在主实体操作前已保存,增加代码复杂性和维护成本。
(4)避免双向级联死循环:
- 如果同时在
@ManyToOne
和@OneToMany
上设置了级联,可能导致死循环。- 例如,
A
和B
互相级联保存,JPA 会陷入无限递归。(5)不设置 CascadeType 的默认行为
- 在 JPA 或者 Spring Data JPA 中,如果你在实体关联关系(例如
@ManyToOne
或@OneToMany
)上没有显式地设置cascade
属性,则 默认不会启用任何级联操作(也就是cascade = { }
,相当于一个空数组)。- 影响:当你对某一端实体执行
save
,remove
,merge
,refresh
等操作时,不会自动对关联的实体执行同样的操作。- 常见现象:
- 不自动保存:如果你保存了一个子实体,但这个子实体所关联的父实体还未持久化到数据库,就会导致外键无法正常引用,甚至抛出异常(
TransientObjectException
或者类似“对象未持久化”错误)。这时你需要手动先保存父实体,再保存子实体,或者显式添加CascadeType.PERSIST
。- 不自动删除:删除子实体时并不会删除它关联的父实体,也不会删除该父实体下的其他子实体。
- 不自动更新:如果父实体是脱管(detached)状态,而你只保存子实体时,父实体不会自动被合并到持久化上下文。
-
作用:用于配置级联操作,意味着对父实体的操作会影响其关联的子实体。例如,如果删除了一个用户,级联操作可以删除与之关联的所有订单。
-
常用的
CascadeType
值:ALL
:应用所有级联操作。PERSIST
:当保存父实体时,保存其关联的实体。REMOVE
:当删除父实体时,删除其关联的实体。MERGE
:当合并父实体时,合并其关联的实体。
这里的父实体指的是设置了cascade值的那一端是父实体,而没有设置注解的cascade值的那一端就是关联的实体,以下示例中@OneToMany的这一端为父实体。
示例:
@OneToMany(cascade = CascadeType.ALL)
private List<Order> orders;
2. FetchType
(抓取策略)
-
作用:控制如何加载实体之间的关系。
FetchType.LAZY
和FetchType.EAGER
是最常用的两种抓取策略。LAZY
:延迟加载。当访问关联实体时才会进行加载。EAGER
:急加载。在加载父实体时,立即加载其关联的所有实体。
示例:
@OneToMany(fetch = FetchType.LAZY) private List<Order> orders;
默认的抓取策略是与注解类型挂钩的
@ManyToOne
和@OneToOne
- 默认是
FetchType.EAGER
。- 意味着当你加载这个实体时,立即也会加载(或联表查询)关联的那个实体。
@OneToMany
和@ManyToMany
- 默认是
FetchType.LAZY
。- 意味着当你加载这个实体时,不会立即加载关联的集合;只有当你第一次访问该集合时(例如
department.getEmployees().size()
),才会触发懒加载查询。
2.4 两个实体类双向关联、互相应用所带来的问题
2.4.1 数据库操作异常
(1) 死循环导致的持久化失败
问题场景:
- 在双向关联中(例如
User
中有List<Resource>
,而Resource
中又持有User uploader
),如果 JPA 在保存或更新时需要序列化对象,可能会形成无限递归循环。 - 当 JPA 尝试级联保存
User
时,发现User
里的resources
列表要被保存,于是去保存每个Resource
;而Resource
又维护了一个User uploader
字段,再次触发保存User
,循环往复。
现象与错误:
- 内存溢出 (
OutOfMemoryError
):大量重复实体被不断加载进内存。 - 持久化异常:可能抛出
MultipleBagFetchException
或其他持久化错误(更多出现在查询抓取时)。
解决思路:
-
尽量避免“双向级联”
- 若两边都设置了
cascade = CascadeType.ALL
并且双方均为多对多或一对多,极易出现双向循环级联。 - 可以只在“一方”设置级联,另一方只做关系维护(
mappedBy
),不配置级联。
- 若两边都设置了
-
合理区分维护端与被维护端
- 在 JPA 中,一般由拥有外键的一端作为维护端(
@JoinColumn
),另一端通过mappedBy
表示被维护端。 - 通常只在维护端设置级联操作(如
cascade = CascadeType.ALL
),被维护端不设置,避免双重级联。
- 在 JPA 中,一般由拥有外键的一端作为维护端(
-
设置单向关联或者弱化双向依赖
- 如果业务场景不严格要求双向访问,可以使用 单向关联 取代双向关联。
- 例如,只在
Resource
中持有User uploader
,而User
不再持有List<Resource>
;通过查询Resource
来获取用户上传的资源。
-
手动维护关联而非自动级联
- 在复杂场景中(如大批量插入数据、复杂事务),可考虑去掉级联,让开发者显式地先保存
Resource
,再把Resource
关联到User
。 - 这样可以避免 JPA 为了级联保存而触发死循环。
- 在复杂场景中(如大批量插入数据、复杂事务),可考虑去掉级联,让开发者显式地先保存
2.4.2 JSON 序列化问题
(1) 无限递归导致的栈溢出
问题场景:
- 使用 Jackson 或 Gson 序列化时,
User
->Resource
->User
的无限循环导致StackOverflowError
。 - 常见于 Spring Boot 的
@RestController
返回 JSON 时,或者消息队列中对象序列化。
现象与错误:
com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
解决思路:
-
使用 Jackson 注解 (
@JsonIgnore
,@JsonManagedReference
,@JsonBackReference
)@JsonIgnore
:在不需要被序列化的字段上加此注解,避免进入递归。- 作用:忽略某个字段,使其在序列化或反序列化时被跳过。
-
public class Resource { @ManyToOne @JsonIgnore // 避免序列化 uploader -> user -> resource -> uploader 循环 private User uploader; }
@JsonManagedReference
/@JsonBackReference
:配合使用,把一方标记为“管理端”(Managed),另一方标记为“后端引用”(Back),解决双向引用的循环问题。- 作用:被
@JsonManagedReference
标记的实体类在被序列化是会被完整序列化(里面包含的另一个实体类的对象也被包含在其中同样序列化),被@JsonBackReference
标记的实体类在被序列化时不会序列化另一个实体类的对象(JSON中不会包含另一个实体类的对象) -
public class User { @OneToMany(mappedBy = "uploader") @JsonManagedReference private List<Resource> resources; } public class Resource { @ManyToOne @JsonBackReference private User uploader; }
-
使用 Lombok 的
@ToString.Exclude
或自定义toString()
- Lombok 的
@Data
会默认生成toString()
,导致无限递归。 - 可以对双向引用的字段添加
@ToString.Exclude
:@Data public class Resource { @ToString.Exclude @ManyToOne private User uploader; }
- 或者手动编写
toString()
方法,仅包含主键或简单字段,不包含会递归的集合或实体引用。
- Lombok 的
-
引入 DTO(Data Transfer Object)或 VO(View Object)
- 不要直接返回实体对象到前端,而是将实体转换成 DTO,在 DTO 中只保留需要的数据字段,从而避免双向关联对象被序列化。
- 常见于
MapStruct
、BeanUtils.copyProperties()
或手动组装的方式。
(2)不一致的数据视图
问题场景:
- 双向关联时,可能一次查询获取的
User
与Resource
出现数据不一致(比如Resource
里有个尚未更新的User
),或被序列化多次。 - JSON 序列化后,返回给前端的结构深度嵌套或产生冲突字段。
解决思路:
- 延迟加载(Lazy Loading)+ DTO:让双向引用的关联字段使用
FetchType.LAZY
并尽量用 DTO 做数据映射。 - 保持数据一致性:在更新关联关系之前,确保对象处于一致的持久化上下文(同一个
Session
或同一个EntityManager
),否则会出现实体“分裂”问题。
2.4.3 删除操作引发的外键冲突
(1) 删除主实体时无法自动删除关联数据
问题场景:
- 删除
User
时若有级联删除,会去删除其resources
。但Resource
又持有User
,可能因为外键约束还没解开而引发冲突。
解决思路:
-
配置正确的
orphanRemoval
与级联类型- 在
@OneToMany
上使用orphanRemoval = true
,可以让 JPA 级联删除那些脱离父实体的子实体。 - 示例:
@OneToMany(mappedBy = "uploader", cascade = CascadeType.ALL, orphanRemoval = true) private List<Resource> resources;
- 这样在移除
Resource
与User
的关系时,JPA 会自动删除对应的Resource
记录。
- 在
-
在数据库层面配置
on delete cascade
- 如果外键约束允许,可以在数据库外键设置级联删除(
ON DELETE CASCADE
),这样当删除User
时数据库自动删除相关Resource
。 - 但需要谨慎使用,以免误删数据。
- 如果外键约束允许,可以在数据库外键设置级联删除(
-
手动删除策略
- 先解除关联再删除。即先将
Resource
的uploader
字段设为null
并保存,使它不再引用User
,然后删除User
。 - 避免同时在双方实体上配置
CascadeType.REMOVE
,以防重复删除循环。
- 先解除关联再删除。即先将
(2) 重复删除问题
问题场景:
- 当同时在
User
与Resource
两边都设置了cascade = CascadeType.REMOVE
,删除User
会级联删除Resource
,而删除Resource
又会回头删除User
,导致死循环或多次删除。
解决思路:
-
只在一端设置
CascadeType.REMOVE
- 一般只在“主控端”设置级联删除。
- 比如:保留
User
上的级联删除,去掉Resource
里对User
的级联配置。
-
明确业务含义
- 级联删除意味着删除主实体就删除所有关联实体,是否符合作业务需求?
- 如果只是逻辑删除或停用,可以考虑加一个
status
字段而非物理删除。
2.4.4 数据加载性能问题
(1) N+1 查询问题
问题场景:
- 当加载
User
列表时,JPA 对每个User
都单独查询其resources
,导致大量 SQL 查询(N+1)。 - 双向关联会放大这种问题,因为还要加载反向关联的
User
。
解决思路:
调整抓取策略 (FetchType.LAZY / EAGER)
- 对大集合或不常用的关联改成
LAZY
:@OneToMany(mappedBy = "uploader", fetch = FetchType.LAZY) private List<Resource> resources;
- 避免默认的
EAGER
抓取,防止无意中加载大量关联数据。
使用 JPQL 或者 EntityGraph
- 当需要一次性获取
User
及其resources
,可以写 JPQL 带JOIN FETCH
:SELECT u FROM User u JOIN FETCH u.resources WHERE ...
- 或者使用
@NamedEntityGraph
/EntityGraph
动态控制关联抓取,减少冗余查询。
第二级缓存或批量查询
- 配置二级缓存(如 Ehcache)可缓解频繁查询问题。
- Hibernate 提供批量抓取(batch fetch)的配置,可一次加载全部关联对象,减少 N+1 查询。
(2) 多重加载引发内存消耗
问题场景:
- 懒加载字段在某些 JSON 序列化或日志输出的场景中被意外触发,大量数据被载入内存。
解决思路:
- 禁用或延迟不必要的初始化
- 在需要的场景下使用手动加载(
Hibernate.initialize(...)
),在不需要时让其保持懒加载状态以避免占用内存。
- 在需要的场景下使用手动加载(
- 使用 DTO
- 只选取必要字段,减少实体直接返回给前端或其他模块时对关联实体的过度加载。
2.4.5 为什么还要用@OneToMany
, @ManyToOne
注解,尽管存在上述问题?
-
适用场景
- 领域模型要求:在复杂的领域模型中,对象之间通常存在着丰富的关系。如果不使用关联注解,就只能用简单的字段(例如存储 id 的字段)来手动维护关系,容易导致业务逻辑分散和代码臃肿。ORM 的映射机制可以让你直接通过对象导航(如
resource.getUploader()
)来获取关联数据。 - 业务操作便捷:当你需要根据某个用户查找其所有资源,或者根据资源快速回溯到对应用户时,双向关联提供了很大的便利。尽管双向关联可能引入循环引用等问题,但在合理设计和配置(例如只在一端设置级联、使用
mappedBy
、调整抓取策略等)的前提下,这种设计能大大简化业务代码。
- 领域模型要求:在复杂的领域模型中,对象之间通常存在着丰富的关系。如果不使用关联注解,就只能用简单的字段(例如存储 id 的字段)来手动维护关系,容易导致业务逻辑分散和代码臃肿。ORM 的映射机制可以让你直接通过对象导航(如
-
针对问题的解决方案
正如你提到的那些问题(如持久化时的死循环、JSON 序列化无限递归、删除操作导致的外键冲突、N+1 查询等),在实际开发中有几种常用的应对方式:
- 单向关联优先:如果不需要从两端都访问关联数据,可以只配置单向关联,这样就能避免双向引用导致的循环问题。例如只在
Resource
中维护User uploader
,而在User
中不设置List<Resource>
属性。 - 配置合适的级联:在双向关联中,只在主控端设置级联(如在
User
中设置@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
),而在被控端(Resource
)不设置级联,避免双重级联引起的循环保存或删除。 - 使用 JSON 序列化控制注解:使用
@JsonIgnore
、@JsonManagedReference
/@JsonBackReference
或 DTO 模式来解决序列化时无限递归的问题。 - 合理设置抓取策略:将容易引起 N+1 查询的关联设置为
FetchType.LAZY
,在需要时通过JOIN FETCH
或 EntityGraph 进行批量加载,避免不必要的数据库查询。 - DTO 映射:避免直接将实体返回给前端。通过 DTO 将实体转换后,只包含必要的字段,也可以避免序列化时的循环引用问题。
- 单向关联优先:如果不需要从两端都访问关联数据,可以只配置单向关联,这样就能避免双向引用导致的循环问题。例如只在
-
何时使用关联注解而非“字段关联”
- 当你的数据模型需要明确描述对象间关系时,使用关联注解能够让 ORM 自动管理外键、级联操作和联合查询。
- 当数据量不大、关系较简单或者查询要求不高时,单向关联或使用简单字段(如仅保存 id)可能也能满足需求;但这种方式往往需要自己手动管理关联关系,增加了开发负担和错误风险。
- 当需要利用 JPA 提供的缓存、事务和懒加载等特性时,使用正确的实体关联(带有注解)能够更好地整合这些机制。
2.5 使用 @Embeddable
和 @Embedded
2.5.1 @Embeddable
-
作用:将类定义为可嵌入的类。这个类不会独立存在,它的字段会被嵌入到宿主实体中。
@Embeddable public class Address { private String street; private String city; }
2.5.2 @Embedded
-
作用:在实体中嵌入
@Embeddable
类的实例,将其字段作为宿主实体的一部分。@Entity public class User { @Id private Long id; @Embedded private Address address; }
解释:
@Embedded
的作用是将嵌入类的字段映射到主实体表中,而不是作为独立的表。例如,Address
类的字段会直接映射到User
表的列中。就是为主实体表增添一些字段。
2.6 配置数据库自动更新(DDL):
Spring Data JPA 可以通过配置来自动生成数据库表结构(如果没有表的话)以及同步数据库和实体类之间的结构。这个功能是通过 Hibernate 实现的,你可以通过 application.properties
或 application.yml
配置它。
在 application.properties
文件中,你可以使用以下配置来控制数据库结构的自动生成:
spring.jpa.hibernate.ddl-auto=update
这里需要强调一点,update只会自动添加缺少的字段,但不会自动删除自定义的实体类中没有的字段。
这个配置的作用如下:
none
:不自动执行任何操作,数据库和实体类之间不会同步。update
:自动更新数据库表结构,使之与实体类匹配(会添加缺少的字段,但不会删除多余的字段)。create
:每次应用启动时都会删除并重新创建数据库表。create-drop
:与create
类似,但在会话关闭时删除表。validate
:验证数据库中的表结构是否与实体类匹配(如果不匹配会抛出异常)。
建议:在开发阶段,可以使用 update
或 create
,但在生产环境中,通常使用 validate
或不配置这个选项,并手动进行数据库迁移。
3. Spring Data JPA 的查询机制
Spring Data JPA 提供了多种查询方式,涵盖了从简单的基于方法名称的查询,到复杂的 JPQL 和原生 SQL 查询。通过灵活的查询机制,开发者可以轻松地获取数据,同时保持与对象模型的集成。
- 以下对于查询方法的定义都是在实现Repository接口的类中进行。
public interface UserRepository extends JpaRepository<User, Long> { // 自定义查询方法可以在这里定义 }
- 以下对于查询方法的定义基本上没有函数体,要么方法名成来定义查询方法,要么靠注解中的语句来定义查询方法。
3.1 基于方法名称的查询
基于方法名称的查询是一种非常便捷的方式,Spring Data JPA 可以通过遵循特定命名规则的接口方法,自动生成相应的 SQL 查询。
1. 方法名称的约定与规则
Spring Data JPA 允许开发者通过定义符合命名规则的方法来生成查询,无需手动编写 SQL。根据方法的命名,Spring Data JPA 自动生成查询逻辑。
-
常见方法命名模式:
findBy[Field]
:根据指定字段查询,如findByName()
。findBy[Field]And[Field]
:根据多个字段查询,如findByNameAndAge()
。findBy[Field]Between
:根据字段的范围查询,如findByAgeBetween()
。
示例:
List<User> findByName(String name);
List<User> findByAgeBetween(int startAge, int endAge);
List<User> findByAddress_City(String city);
这种基于基于方法名称的查询方法的参数必须是数据库中的字段(用于筛选记录)当然还有第二种情况,如下:
Spring Data JPA 特别支持
Sort
和Pageable
参数,因为它们用于控制查询结果的表现形式,而不是直接用于构建查询条件。以下是它们的作用:
Sort
:定义结果的排序规则(按哪一列排序,升序或降序)。Pageable
:定义分页行为(查询哪一页、每页多少条记录等)。它们的存在并不会影响生成的查询条件,只是在查询结果被返回时进行处理。因此,Spring Data JPA 允许
Sort
和Pageable
作为额外的参数来增强查询的灵活性。
2. 方法名称与自动生成的 SQL
findByName(String name)
:自动生成的 SQL 类似于SELECT * FROM user WHERE name = ?
。findByAgeBetween(int startAge, int endAge)
:自动生成的 SQL 类似于SELECT * FROM user WHERE age BETWEEN ? AND ?
。findByAddress_City(String city)
:自动生成的 SQL 类似于SELECT * FROM user WHERE address_city = ?
。
3. 方法名称的灵活性
Spring Data JPA 的命名约定十分灵活,支持条件组合(And
、Or
)、比较运算符(LessThan
、GreaterThan
)、排序等操作。例如:
List<User> findByAgeGreaterThan(int age);
List<User> findByNameOrAge(String name, int age);
以上所有的 List<User> findByName(String name);都是定义了一个方法,这些方法的返回值是一个以User为节点的线性表,括号内的参数名称要与数据库表中的字段名一致才行,这些方法在Repository层中定义了之后,再被Service层调用。
3.2 JPQL(Java Persistence Query Language)
JPQL 是 JPA 的查询语言,语法与 SQL 类似,但它操作的是实体类及其属性,而不是数据库的表和列。JPQL 提供了一种面向对象的查询方式,允许开发者使用实体类和关系模型进行查询。
3.2.1 基本 JPQL 语法
@Query("SELECT <实体别名> FROM <实体类名> <实体别名> WHERE <条件表达式包含命名参数>")
返回类型 方法名(参数类型 参数名1, 参数类型 参数名2);
3.2.2 JPQL中使用命名参数
- 命名参数:以
:
开头,如:name
,使用@Param
注解绑定参数。
使用 命名参数 的 JPQL 示例:
@Query("SELECT u FROM User u WHERE u.name = :name AND u.age > :age")
List<User> findByNameAndAge(@Param("name") String name, @Param("age") int age);
WHERE u.name = :name AND u.age > :age
:查询条件使用了命名参数。:name
是绑定到String name
参数,:age
是绑定到int age
参数。
3.2.3 面向对象的特性
JPQL 提供了类似 SQL 的功能,包括 JOIN
、GROUP BY
、ORDER BY
等,但它操作的是对象及其关系。
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u JOIN u.orders o WHERE o.status = :status")
List<User> findUsersByOrderStatus(@Param("status") String status);
}
@Query
注解:使用 JPQL 定义自定义查询。SELECT u FROM User u JOIN u.orders o
:u
是User
实体的别名。u.orders
表示User
实体中的orders
属性,这个属性代表的是User
和Order
之间的关联关系(即用户的订单)。JOIN
表示通过对象关系进行连接。o
是orders
的别名,用于在查询中引用订单的属性。
WHERE o.status = :status
:筛选条件,查询订单状态为指定值的用户。
3.2.4 查询结果映射到 DTO
有时你可能不希望返回完整的实体对象,而是只需要一些特定的字段。你可以通过自定义 DTO(数据传输对象)来接收查询结果。
在 JPQL 中,处理 DTO 的方法是使用 new
关键字来调用 DTO 的构造函数。JPQL 会将查询的结果映射到 DTO 对象中。这个方法非常方便,因为 JPA 会自动将结果映射到 DTO 对象,而不需要手动提取和赋值。
-
定义 DTO 类:
public class UserDTO { private String name; private int age; // 构造函数 public UserDTO(String name, int age) { this.name = name; this.age = age; } // getters and setters }
-
使用 JPQL 映射到 DTO:
@Query("SELECT new com.example.dto.UserDTO(u.name, u.age) FROM User u WHERE u.status = :status") List<UserDTO> findUserDTOsByStatus(@Param("status") String status);
-
说明:
new com.example.dto.UserDTO(u.name, u.age)
:使用new
关键字调用 DTO 的构造函数,将查询结果中的u.name
和u.age
传递给构造函数。- DTO 的构造函数需要匹配查询结果中列的类型和顺序。
JPQL 处理 DTO 的特点
- 自动映射:JPQL 自动将查询结果映射到 DTO,通过调用 DTO 的构造函数创建 DTO 对象。
- 简洁:只需在查询中使用
new
关键字指定 DTO 的构造函数,JPA 会自动处理映射。 - 适用场景:适合需要返回部分字段或自定义数据结构的场景,避免返回整个实体。
3.3 原生 SQL 查询
原生 SQL 查询允许你直接编写数据库特定的 SQL 语句。这种方式适合在 JPQL 无法满足需求的情况下,例如需要执行复杂的数据库查询或使用数据库特定的功能时。
3.3.1 基本原生SQL 语法
@Query(value = "SELECT <列名1>, <列名2> FROM <表名> WHERE <条件表达式包含命名参数>", nativeQuery = true)
List<Object[]> 方法名(@Param("参数名1") 参数类型 参数名1, @Param("参数名2") 参数类型 参数名2);
- 这里value的作用就是标记这个为原生SQL查询。
- 查询得到的结果会返回到以数组Object为节点的线性表中,这里一个节点的数组Object就代表了一行,这个数组中的Object[1]、Object[2]就代表了你查询到的记录的相应字段。
3.3.2 原生SQL中使用位置参数
-
位置参数:使用
?1
,?2
等来占位,表示参数的顺序。
使用 位置参数 的 JPQL 示例:
@Query("SELECT u FROM User u WHERE u.name = ?1 AND u.age > ?2")
List<User> findByNameAndAge(String name, int age);
WHERE u.name = ?1 AND u.age > ?2
:这是查询条件。?1
是第一个位置参数,对应方法参数String name
;?2
是第二个位置参数,对应方法参数int age
。
3.3.3 原生 SQL 查询结果的处理
查询结果会自动映射到方法的返回类型。例如,如果返回类型是 List<Object[]>
,那么查询结果的每一行都会被映射为 Object[]
数组,数组中的每个元素对应查询语句中的列。
@Query(value = "SELECT u.name, u.age FROM users u WHERE u.status = :status", nativeQuery = true)
List<Object[]> findUserDataByStatus(@Param("status") String status);
public List<UserDTO> findUserDTOsByStatus(String status) {
List<Object[]> results = userRepository.findUserDataByStatus(status);
List<UserDTO> dtos = new ArrayList<>();
for (Object[] result : results) {
String name = (String) result[0];
int age = (int) result[1];
dtos.add(new UserDTO(name, age));
}
return dtos;
}
- 在原生 SQL 查询中,返回的
List<Object[]>
列表中,每个Object[]
数组代表一行记录,数组中的每个元素代表查询结果中的一个列值。 - 开发者需要通过数组下标(如
result[0]
,result[1]
)来访问特定列的值。 - 这种方法灵活,但需要手动处理数据,将其映射到 DTO 或其他数据对象中。
3.4 JPQL与原生SQL的区别
3.4.1 操作对象的不同
-
SQL:SQL 查询语句中使用的是数据库表名和字段名。SQL 直接操作表和列,查询结果是表中的数据行。
-
JPQL:JPQL 查询语句中不能使用表名和字段名,而是使用实体类名和属性名。JPQL 操作的是 Java 实体类及其属性,查询结果是实体类对象。
示例:
- SQL:
SELECT * FROM users WHERE name = 'John';
- JPQL:
SELECT u FROM User u WHERE u.name = 'John';
在 JPQL 中,
users
表名被替换为User
实体类名,name
列名被替换为name
属性。 - SQL:
3.4.2 连接操作的不同
-
SQL:在 SQL 中,连接操作通过外键字段显式进行,
JOIN
后直接使用的是表名。例如,通过外键连接两个表时,必须指定表名并使用外键进行连接。 -
JPQL:在 JPQL 中,不能直接在
JOIN
后使用表名。连接操作是基于实体类的关联属性来进行的,使用实体类的属性而不是表名来实现连接。连接的目标是实体类关联的属性,而不是表本身。示例:
- SQL:
SELECT u.*, o.* FROM users u JOIN orders o ON u.id = o.user_id;
- JPQL:
SELECT u FROM User u JOIN u.orders o WHERE :id = o.user_id;
在 JPQL 中,
users
和orders
表名被替换为User
实体类和其orders
属性。连接是通过u.orders
实体属性进行的,而不是通过外键字段。 - SQL:
3.4.3 参数化查询的不同
-
SQL:SQL 查询中使用位置参数(
?
)进行参数化查询,参数按顺序绑定。SQL 只支持位置参数,无法直接使用命名参数。 -
JPQL:JPQL 支持命名参数(如
:paramName
)。命名参数在 JPQL 中更加灵活,可以通过名称绑定具体的参数值。示例:
- SQL:
SELECT * FROM users WHERE name = ? AND age > ?;
- JPQL:
SELECT u FROM User u WHERE u.name = :name AND u.age > :age;
- SQL:
总的来说,JPQL可以将查询到的结果直接映射为DTO对象,还是更为方便。
4. 分页和排序
在 Spring Data JPA 中,分页查询是处理大数据集时的常见需求。它可以让你按照页数逐步获取数据,而不是一次性获取所有数据。分页查询通常与排序结合使用,以确保数据按预期顺序返回。Spring Data JPA 提供了灵活的分页和排序机制,主要使用 Pageable
、PageRequest
和 Sort
类。
4.1 分页查询
4.1.1 使用 Pageable
和 PageRequest
进行分页查询
Pageable
是分页查询的抽象接口,包含了分页信息(如页码、每页大小、排序等)。PageRequest
是 Pageable
的实现类,提供了具体的分页参数生成功能。
示例代码:
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByStatus(String status, Pageable pageable);
}
在使用这个方法时,你需要传入一个 Pageable
对象,通常使用 PageRequest.of()
来生成。
@GetMapping("/users")
public Page<UserDTO> getUsersByStatus(@RequestParam String status,
@RequestParam int page,
@RequestParam int size) {
Pageable pageable = PageRequest.of(page, size); // 创建 Pageable 对象
return userService.getUsersByStatus(status, pageable); // 传递 Pageable 参数
}
详细解释:
Pageable pageable
作为参数时的传参:- 通过
PageRequest.of(page, size)
创建一个Pageable
实例。 page
是页码,从 0 开始,size
是每页的记录数。将Pageable
作为参数传递给方法,以控制分页行为。size
的作用是决定每页的数据量,即每页显示多少条记录。PageRequest.of(page, size)
会按照这个size
的值将数据库的记录分成多页。然后,page
参数用来指定要获取第几页的数据。
- 通过
这里的getUsersByStatus()方法是讲Page类对象作为返回类型,也可以将Slice类对象作为返回对象,下面会讲解二者的区别。
4.1.2 Page
和 Slice
的对比
Page
示例代码:
Page<User> userPage = userRepository.findByStatus(status, pageable);
List<User> users = userPage.getContent(); // 获取当前页数据
long totalElements = userPage.getTotalElements(); // 获取总记录数
boolean hasNextPage = userPage.hasNext(); // 是否有下一页
- 功能:
Page
是一个完整的分页结果,包含当前页的数据以及分页元数据信息,如总页数、总记录数、是否有下一页等。 - 使用场景:适用于需要完整分页信息的场景,例如需要知道总页数或总记录数的情况。
- 开销:因为
Page
需要计算总记录数,所以在查询大数据集时性能开销较大,适合需要精确分页控制的场景。
Slice
示例代码:
Slice<User> userSlice = userRepository.findByStatus(status, pageable);
List<User> users = userSlice.getContent(); // 获取当前页数据
boolean hasNextSlice = userSlice.hasNext(); // 是否有下一页
- 功能:
Slice
提供了部分分页功能,主要用于判断是否有下一页,而不计算总记录数或总页数。Slice
返回的数据和Page
类似,但性能开销较小,因为不需要执行count
查询。 - 使用场景:适用于 "加载更多" 或滚动加载的场景,适合不需要总记录数的情况。
- 开销:
Slice
不计算总记录数,性能比Page
高,在处理大数据集时更加高效。
getContent()
:返回当前页的数据列表,通常为List<User>
。Page
:提供完整的分页信息(当前页数据、总记录数、总页数、是否有下一页),适合需要精确分页控制的场景。Slice
:只提供部分分页信息(当前页数据、是否有下一页),适合性能敏感、需要 "加载更多" 风格分页的场景。
4.1.3 自定义分页参数
在实际应用中,你可以根据需求自定义分页参数(如页码、每页大小)。通常使用 @RequestParam
来接收这些分页参数,并传递给 PageRequest
。
示例代码:
@GetMapping("/users")
public Page<UserDTO> getUsersByStatus(@RequestParam String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size); // 自定义分页参数
return userService.getUsersByStatus(status, pageable);
}
@RequestParam(defaultValue = "0")
:当客户端不传递分页参数时,使用默认值进行分页查询。默认情况下,第一个页是 0,每页返回 10 条记录。
4.2 排序查询
排序查询是通过指定字段或多个字段对查询结果进行排序,Spring Data JPA 使用 Sort
对查询结果进行排序
Sort
是 Spring Data JPA 提供的用于查询排序的类,允许通过指定属性名和排序方向(升序或降序)来对查询结果排序。
示例代码:
public List<User> findByStatus(String status, Sort sort) {
return userRepository.findByStatus(status, sort);
}
在控制层中,可以使用 Sort
来指定排序规则:
@GetMapping("/users")
public List<UserDTO> getUsersByStatus(@RequestParam String status,
@RequestParam String sortBy,
@RequestParam String direction) {
Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy);
return userService.getUsersByStatus(status, sort);
}
status
:查询条件,用于过滤用户的状态(例如active
、inactive
等)。此参数与数据库中status
字段对应,用于确定应该获取哪些用户。sortBy
:排序字段,用于指定查询结果按照哪个字段进行排序(例如name
、age
等)。这个参数对应于数据库中的字段,用于定义按哪个字段进行排序。direction
:排序方向,用于指定排序的方式。可能的值有"asc"
(升序)或"desc"
(降序)。该参数用于控制查询结果的排列顺序。
1.
Sort.by()
:创建Sort
对象,指定排序方向(升序或降序)和排序字段。以下解释Sort.by()这个方法中的两个参数
2.解释参数 Sort.Direction.fromString(direction)
direction
:客户端传递的字符串,如"asc"
或"desc"
。Sort.Direction.fromString(direction)
:将字符串"asc"
转换为枚举值Sort.Direction.ASC
,或将"desc"
转换为Sort.Direction.DESC
。这里规定的第一个参数必须是枚举类的Sort.Direction.ASC(升序)或Sort.Direction.DESC(降序)。
3.解释参数 sortBy
sortBy
:指定排序字段,例如name
或age
。
4.3 实现分页查询与排序结合
Spring Data JPA 允许通过 PageRequest.of()
方法创建分页请求时同时指定排序规则。这个方法不仅接收页码和每页大小,还可以接受 Sort
对象来定义排序方式。
示例:结合分页和排序的查询
假设我们有一个 User
实体类,我们希望按照用户的 name
升序排列,同时进行分页查询。你可以使用以下方式实现分页和排序的结合:
public Page<User> findUsersByStatus(String status, Pageable pageable);
在控制层中,我们可以这样传递分页和排序信息:
@GetMapping("/users")
public Page<UserDTO> getUsersByStatus(@RequestParam String status,
@RequestParam int page,
@RequestParam int size,
@RequestParam String sortBy,
@RequestParam String direction) {
// 使用 PageRequest 创建分页和排序对象
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.fromString(direction), sortBy));
return userService.getUsersByStatus(status, pageable); // 执行分页和排序查询
}
解释代码中的细节:
-
PageRequest.of(page, size, Sort.by(...))
:page
:表示要查询的页码(从 0 开始)。size
:表示每页返回的记录数。Sort.by(Sort.Direction.fromString(direction), sortBy)
:定义排序规则,其中sortBy
是要排序的字段名,direction
是排序的方向(升序ASC
或降序DESC
)。
相当于就是为
PageRequest.of()多加了一个参数Sort。
5. 错误处理与调试
在 Spring Data JPA 和 Hibernate 开发中,错误处理和调试是保证代码健壮性和提高性能的重要环节。通过有效的错误处理可以避免系统崩溃或不一致性问题,而调试和日志记录可以帮助开发者更好地理解查询的行为、性能瓶颈以及其他潜在的问题。
5.1 常见错误处理
在 JPA 使用中,会遇到一些常见的异常或错误。了解这些异常的原因和处理方式,可以有效地提高系统的健壮性和稳定性。
5.1.1 LazyInitializationException
-
场景:
LazyInitializationException
通常发生在使用 延迟加载 (Lazy Loading) 时。如果你试图访问一个延迟加载的关联数据(如@OneToMany
或@ManyToOne
的关联对象),而EntityManager
已经关闭,Hibernate 就无法再初始化该对象,因此抛出该异常。原因:在
EntityManager
关闭后,尝试访问与数据库连接相关的延迟加载数据。解决方法:
- 调整加载策略:将关联的加载策略从
LAZY
改为EAGER
,这样数据会在查询时立即加载,而不会等到关联对象被访问时才加载。 - 显式加载:在事务内通过
fetch join
或调用实体的get
方法来手动加载关联数据。
示例:调整加载策略
@OneToMany(fetch = FetchType.EAGER) private List<Order> orders;
- 调整加载策略:将关联的加载策略从
5.1.2 OptimisticLockException
-
场景:
OptimisticLockException
通常在并发更新时发生。使用 乐观锁 (Optimistic Locking) 时,多个事务可能同时修改同一条记录,而乐观锁通过比较版本号(@Version
注解字段)来检测冲突。如果版本号不匹配,则会抛出该异常。原因:两个并发事务修改了同一个实体对象,提交时版本号不匹配,导致乐观锁检查失败。
解决方法:
- 捕获异常并重试:在代码中捕获
OptimisticLockException
,并根据业务逻辑决定是否重新尝试事务。 - 合理设计并发逻辑:确保并发事务对相同数据的修改尽量减少,或者通过事务管理重试机制处理并发失败。
示例:使用版本控制
@Version private Long version;
- 捕获异常并重试:在代码中捕获
5.1.3 处理查询结果为空的情况
-
场景:当查询结果为空时,通常需要进行相应处理,避免空指针异常或者业务逻辑异常。
常见解决方法:
- 返回
Optional
:Spring Data JPA 提供了Optional
类型的方法返回值,可以有效地处理查询结果为空的情况。通过Optional
的方法如isPresent()
或orElse()
进行安全访问。 - 异常处理:通过抛出自定义异常,如
EntityNotFoundException
,来标记查询结果为空的情况。
示例:使用
Optional
处理空查询Optional<User> user = userRepository.findById(userId); return user.orElseThrow(() -> new EntityNotFoundException("User not found"));
- 返回
5.2 配置 Spring Data JPA 的 SQL 日志输出
为了查看 Hibernate 生成的 SQL 查询以及它的执行过程,可以通过配置日志输出,记录 SQL 查询和参数。这对调试查询错误、优化查询性能都非常有帮助。
如何启用 SQL 日志:
-
application.properties 文件中配置:
示例:启用 SQL 日志输出
# 显示生成的 SQL 语句 spring.jpa.show-sql=true # 格式化 SQL 输出,便于阅读 spring.jpa.properties.hibernate.format_sql=true # 输出 SQL 参数值 logging.level.org.hibernate.type.descriptor.sql=TRACE
详细说明:
spring.jpa.show-sql=true
:开启 SQL 输出,可以在控制台看到执行的 SQL 语句。spring.jpa.properties.hibernate.format_sql=true
:格式化 SQL 输出,使得长查询语句更容易阅读。logging.level.org.hibernate.type.descriptor.sql=TRACE
:打印 SQL 语句中的参数值(默认情况下,SQL 参数不会显示)。
6. 使用多数据源
在某些应用中,可能需要连接多个数据库(例如,主数据库和只读数据库,或不同的业务模块使用不同的数据库)。
6.1 多数据源配置流程
在多数据源的JPA配置中,你需要:
- 配置多个
DataSource
:每个数据库对应一个DataSource
。 - 为每个数据源创建
EntityManagerFactory
:每个DataSource
需要各自的EntityManagerFactory
,用于管理持久化上下文(实体映射和JPA操作)。 - 为每个数据源创建
TransactionManager
:每个EntityManagerFactory
需要一个对应的TransactionManager
来管理事务。 - 为每个数据库设置连接信息:在
application.properties
文件中,我们需要分别为两个数据源设置不同的数据库连接信息:
6.1.1 配置两个DataSource
在MultiDataSourceConfig.java
中,我们首先配置两个数据源:主数据源(primaryDataSource
)和从数据源(secondaryDataSource
)。
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
@Configuration
public class MultiDataSourceConfig {
// 主数据源配置
@Primary
@Bean(name = "primaryDataSource")
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return new HikariDataSource();
}
// 从数据源配置
@Bean(name = "secondaryDataSource")
@ConfigurationProperties(prefix = "spring.datasource.secondary")
public DataSource secondaryDataSource() {
return new HikariDataSource();
}
}
6.1.2 配置EntityManagerFactory
和TransactionManager
接下来,我们为每个DataSource
分别配置EntityManagerFactory
和TransactionManager
。
主数据源的EntityManagerFactory
和TransactionManager
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.example.primary.repository", // 主数据源对应的Repository包
entityManagerFactoryRef = "primaryEntityManagerFactory",
transactionManagerRef = "primaryTransactionManager"
)
public class PrimaryDataSourceConfig {
@Primary
@Bean(name = "primaryEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("primaryDataSource") DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages("com.example.primary.entity") // 实体类的包名
.persistenceUnit("primary") // JPA的持久化单元名称
.build();
}
@Primary
@Bean(name = "primaryTransactionManager")
public PlatformTransactionManager primaryTransactionManager(
@Qualifier("primaryEntityManagerFactory") EntityManagerFactory primaryEntityManagerFactory) {
return new JpaTransactionManager(primaryEntityManagerFactory);
}
}
从数据源的EntityManagerFactory
和TransactionManager
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "com.example.secondary.repository", // 从数据源对应的Repository包
entityManagerFactoryRef = "secondaryEntityManagerFactory",
transactionManagerRef = "secondaryTransactionManager"
)
public class SecondaryDataSourceConfig {
@Bean(name = "secondaryEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean secondaryEntityManagerFactory(
EntityManagerFactoryBuilder builder, @Qualifier("secondaryDataSource") DataSource dataSource) {
return builder
.dataSource(dataSource)
.packages("com.example.secondary.entity") // 实体类的包名
.persistenceUnit("secondary") // JPA的持久化单元名称
.build();
}
@Bean(name = "secondaryTransactionManager")
public PlatformTransactionManager secondaryTransactionManager(
@Qualifier("secondaryEntityManagerFactory") EntityManagerFactory secondaryEntityManagerFactory) {
return new JpaTransactionManager(secondaryEntityManagerFactory);
}
}
6.1.3 application.properties
配置
在 application.properties
文件中,我们需要分别为两个数据源设置不同的数据库连接信息:
# 主数据源配置
spring.datasource.primary.url=jdbc:mysql://localhost:3306/primarydb
spring.datasource.primary.username=root
spring.datasource.primary.password=primarypassword
spring.datasource.primary.driver-class-name=com.mysql.cj.jdbc.Driver
# 从数据源配置
spring.datasource.secondary.url=jdbc:mysql://localhost:3306/secondarydb
spring.datasource.secondary.username=read_user
spring.datasource.secondary.password=read_password
spring.datasource.secondary.driver-class-name=com.mysql.cj.jdbc.Driver
6.2 多数据源的使用
以下是如何在服务类中使用不同的JpaRepository
来操作不同的数据源:
import com.example.primary.repository.PrimaryRepository;
import com.example.secondary.repository.SecondaryRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MultiDataSourceService {
@Autowired
private PrimaryRepository primaryRepository;
@Autowired
private SecondaryRepository secondaryRepository;
@Transactional("primaryTransactionManager") // 使用主数据源的事务管理器
public void usePrimaryDataSource() {
primaryRepository.findAll(); // 操作主数据源
}
@Transactional("secondaryTransactionManager") // 使用从数据源的事务管理器
public void useSecondaryDataSource() {
secondaryRepository.findAll(); // 操作从数据源
}
}