Spring Data JPA
Spring Data JPA项目是Spring对JPA规范的一层封装,它提供了很多高级查询方法,能让我们几乎不写任何JPQL/SQL就能完成查询功能,配合SpringBoot使用时可以显著简化数据访问层代码的编写。本篇笔记我们将基于SpringBoot3.5版本介绍如何配置和使用Spring Data JPA。
学习本篇笔记需要你先掌握JPA规范相关的知识,具体可以参考Java/JavaEE/JPA相关章节,这里不会介绍重复的内容。
Spring Data JPA vs JPA vs Hibernate
关于JPA(Java Persistence API),它是标准JavaEE中的持久化规范,JPA规范定义了JavaEE应用程序中,数据实体和关系型数据库表之间如何映射,以及JavaEE应用程序如何操作数据库表。虽然我们现在几乎不搭建传统方式的Spring + Hibernate工程了,但Hibernate框架是JPA规范的最优秀实现之一,使用JPA时,虽然我们用的Java接口都是JPA规范的,但实际处理ORM的其实还是Hibernate框架,因此就算到了Spring Data JPA这一层,最终调用的还是Java中十分经典的Hibernate,这是三者之间的内在关系。
使用Spring Data JPA的一些注意事项
- Spring Data JPA虽然用法简单但也有一些广受诟病的缺陷,比如性能优化难、用法不灵活、很多魔法操作等,在一些较严肃、可预见维护周期长的工程中慎用
- Hibernate版本迭代非常快,API更是改来改去,Spring Data JPA一般只建议整合到SpringBoot使用,否则可能很难搞清楚版本之间的对应关系
引入Maven依赖
我们这里使用的是SpringBoot3.5和MySQL8.4,引入Spring Data JPA十分简单,直接添加起步依赖和MySQL驱动包即可。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
注意:如果你使用的是低版本MySQL(例如5.7)可能需要手动指定MySQL驱动包的版本号。
添加SpringBoot配置
SpringBoot环境下,我们还需要在application.properties项目配置文件中添加数据库连接信息和其它配置。
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.database=MYSQL
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.show-sql=true
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=update
我们在配置文件中配置了MySQL数据源,以及配置JPA框架使用MySQL和MySQL方言,spring.jpa.show-sql让具体执行的SQL语句在日志中打印出来,这是为了方便我们调试,spring.jpa.generate-ddl和spring.jpa.hibernate.ddl-auto能够根据数据模型自动建表,在开发环境这些是常见做法。
注意:我们这里为了演示方便,直接将密码明文写在了配置文件中,这不是一个安全的做法,生产环境不要这么做。
使用Spring Data JPA实现增删改查
下面我们编写一个例子,通过Spring Data JPA实现最简单的增删改查。
创建实体类
下面代码我们使用JPA注解配置了两个实体,分别是用户User实体和角色Role实体。
User.java
package com.gacfox.demo.model;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serializable;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "t_user")
public class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long userId;
@Column(name = "username", nullable = false, unique = true, length = 50)
private String username;
@Column(name = "password", nullable = false)
private String password;
@Column(name = "birthday")
private Date birthday;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "t_user_role", joinColumns = {@JoinColumn(name = "user_id")}, inverseJoinColumns = {
@JoinColumn(name = "role_id")})
private Set<Role> roleSet = new HashSet<>();
}
Role.java
package com.gacfox.demo.model;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "t_role")
public class Role implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "role_id")
private Long roleId;
@Column(name = "role_name", nullable = false, unique = true, length = 50)
private String roleName;
@ManyToMany(mappedBy = "roleSet", fetch = FetchType.LAZY)
private Set<User> userSet = new HashSet<>();
}
JPA实体必须有public的无参构造函数,其次对于标注了JPA注解的字段还需要提供getter/setter方法。
JpaRepository接口查询
下面代码定义了数据访问层接口。
UserRepository.java
package com.gacfox.demo.repository;
import com.gacfox.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Date;
import java.util.List;
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByBirthdayAfter(Date birthday);
}
注意代码中UserRepository这个接口的定义,它继承了JpaRepository接口,实际上我们不需要写任何实现类,Spring Data JPA会帮我们在运行时生成实现类,我们只需要在我们的Service层中依据UserRepository进行依赖注入并调用相关方法即可。
关于接口中的方法命名,它们看起来比较神奇,实际上Spring Data JPA会通过我们定义的方法名自动生成数据库查询,规则是这样的:
- 所有查询方法名以
findBy开头 - 查询条件写在
findBy后面,名字需要和Java实体类的字段名相对应,如findByBirthday - 多个查询条件可以用
And或Or等连接,如findByUsernameAndPassword - 模糊查询在条件字段后加
Like,如findByUsernameLike - 比较查询可以在字段后面加
Before或After
上述规则能够覆盖80%的查询操作了,这就是Spring Data JPA的方便之处。
测试访问数据库
下面单元测试代码调用了前面的UserRepository的findByBirthdayAfter()方法。
package com.gacfox.demo;
import com.gacfox.demo.model.User;
import com.gacfox.demo.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
@Slf4j
@SpringBootTest
public class UserRepositoryTest {
private final UserRepository userRepository;
@Autowired
public UserRepositoryTest(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Test
void testDao() {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
try {
Date date = simpleDateFormat.parse("1980-01-01");
List<User> userList = userRepository.findByBirthdayAfter(date);
log.info("result count: {}", userList.size());
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}
自定义JpaRepository实现
如果查询条件十分复杂,前面这种写法不能满足的时候,我们也可以写接口的实现类,这就和Hibernate(JPA)类似了,下面例子中我们自己实现了一个接口用于较复杂的数据查询。假设我们在UserRepository中添加如下方法,它用于根据用户是否包含某个角色名来获取用户列表。
List<User> findByRoleName(String roleName);
我们自定义的“实现类”如下。
package com.gacfox.demo.repository;
import com.gacfox.demo.model.User;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import java.util.List;
public class UserRepositoryImpl {
@PersistenceContext
private EntityManager entityManager;
public List<User> findByRoleName(String roleName) {
String jpql = "select distinct u from User u join u.roleSet r where r.roleName = :roleName";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
query.setParameter("roleName", roleName);
return query.getResultList();
}
}
注意这个所谓的实现类的写法,我们没有继承任何接口,只是以接口名Impl命名了我们的实现类,并且实现类和接口在同一个包中,然后定义了一个相同名字的Java方法的实现,如果我们使用implements实现RoleRepository,我们需要同时实现大量其他方法(RoleRepository以及其父接口中定义的所有方法)才能编译通过,因此Spring Data JPA内部进行了一些处理,允许我们不使用implements指定接口,只保持接口和实现类按照约定命名即可。
当然,这种写法有些脱离Java语法的一般常识了,个人认为是一个糟糕的设计,因此除非必要否则强烈不建议用。
使用Optional包装单条记录
对于数据访问层的查询,以查询用户信息为例,一般有类似这几种情况:返回List<User>列表、返回User单条记录但不存在可能返回null、返回整数或布尔类型等。对于返回单条记录的情况,Spring Data JPA建议使用Java8的Optional对象封装,而非直接使用具体对象或null这种写法。JpaRepository内置的findBy方法返回的就是Optional对象。下面例子代码中,我们查询ID为2的数据记录,如果存在则取用户名,没有返回unknown,类似这种查询,Spring Data JPA建议使用Optional对象而非手写if (user != null)逻辑。
package com.gacfox.demo;
import com.gacfox.demo.model.User;
import com.gacfox.demo.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.*;
import java.util.Optional;
@Slf4j
@SpringBootTest
public class UserRepositoryTest {
private final UserRepository userRepository;
@Autowired
public UserRepositoryTest(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Test
void testDao() {
Optional<User> user = userRepository.findById(2L);
String username = user.map(User::getUsername).orElse("unknown");
log.info(username);
}
}
插入的写法
插入使用JpaRepository接口的save()方法即可,例子代码如下。
Role role = new Role();
role.setRoleName("STUDENT");
roleRepository.save(role);
删除的写法
我们执行删除操作时也可能附加一些查询条件,删除的写法和查询差不多,需要以deleteBy开头。
package com.gacfox.demo.repository;
import com.gacfox.demo.model.Role;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RoleRepository extends JpaRepository<Role, Long> {
/**
* 根据角色名删除角色
*
* @param roleName 角色名
* @return 受影响行数
*/
int deleteByRoleName(String roleName);
}
更新的写法
至于数据更新,我们将托管状态的对象修改后使用save()保存为持久态,Java对象的修改就会自动持久化到数据库中了。
QBE查询
QBE(Query By Example)是一类查询方式,它能够直接通过模板“样例”对象从数据库中匹配符合条件的记录形成结果集。使用QBE查询需要Repository接口继承QueryByExampleExecutor,不过JpaRepository已经继承它了,因此我们不需要额外再写什么。
下面是一段QBE查询的例子代码,主要实现了分页和条件查询。
package com.gacfox.demo;
import com.gacfox.demo.model.User;
import com.gacfox.demo.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.*;
@Slf4j
@SpringBootTest
public class UserRepositoryTest {
private final UserRepository userRepository;
@Autowired
public UserRepositoryTest(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Test
void testDao() {
// 构造样例对象,设置需要匹配的条件字段
User probe = new User();
probe.setUsername("Tom");
// 构造ExampleMatcher,定义匹配规则
ExampleMatcher matcher = ExampleMatcher.matching()
// username字段使用模糊匹配(包含)
.withMatcher("username", ExampleMatcher.GenericPropertyMatchers.contains())
// 忽略不需要参与匹配的字段
.withIgnorePaths("userId", "password", "birthday");
// 将样例对象和匹配规则组合为Example
Example<User> example = Example.of(probe, matcher);
// 构造分页参数:第0页,每页10条,按userId升序排序
Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "userId"));
// 执行分页查询
Page<User> page = userRepository.findAll(example, pageable);
log.info("总记录数: {}", page.getTotalElements());
log.info("总页数: {}", page.getTotalPages());
for (User user : page.getContent()) {
log.info("用户: {} 生日: {}", user.getUsername(), user.getBirthday());
}
}
}
Criteria高级动态查询
有些复杂动态查询直接使用JpaRepository和QBE都不能很好的满足我们的需求,而使用JPQL可能又有些麻烦,这时我们可以使用Criteria查询。Spring Data JPA对Criteria操作进行了封装,使用起来还算简单。想要使用Criteria查询的Repository接口需要继承JpaSpecificationExecutor接口,然后在Repository接口的实例上调用findAll()方法时,传入一个Specification对象。
UserRepository.java
package com.gacfox.demo.repository;
import com.gacfox.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
}
QueryParam.java
package com.gacfox.demo.dto;
import lombok.Data;
import java.util.Date;
@Data
public class QueryParam {
/**
* 用户名
*/
private String username;
/**
* 生日起始范围
*/
private Date birthdayFrom;
/**
* 生日结束范围
*/
private Date birthdayTo;
/**
* 角色名
*/
private String roleName;
}
QueryParam是一个用户传入的查询参数封装。
动态查询的写法如下。
package com.gacfox.demo;
import com.gacfox.demo.dto.QueryParam;
import com.gacfox.demo.model.Role;
import com.gacfox.demo.model.User;
import com.gacfox.demo.repository.UserRepository;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.domain.Specification;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.criteria.Predicate;
@Slf4j
@SpringBootTest
public class UserRepositoryTest {
private final UserRepository userRepository;
@Autowired
public UserRepositoryTest(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Test
void testDao() {
// 模拟前端传入的动态查询参数
QueryParam param = new QueryParam();
param.setUsername("Tom");
param.setRoleName("ADMIN");
try {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
param.setBirthdayFrom(simpleDateFormat.parse("1980-01-01"));
} catch (ParseException e) {
throw new RuntimeException(e);
}
// 构造Specification,根据参数是否为空动态拼接查询条件
Specification<User> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
// 用户名模糊查询
if (param.getUsername() != null && !param.getUsername().isEmpty()) {
predicates.add(cb.like(root.get("username"), "%" + param.getUsername() + "%"));
}
// 生日范围查询 - 起始
if (param.getBirthdayFrom() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("birthday"), param.getBirthdayFrom()));
}
// 生日范围查询 - 结束
if (param.getBirthdayTo() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("birthday"), param.getBirthdayTo()));
}
// 关联角色表查询
if (query != null && param.getRoleName() != null && !param.getRoleName().isEmpty()) {
Join<User, Role> roleJoin = root.join("roleSet", JoinType.INNER);
predicates.add(cb.equal(roleJoin.get("roleName"), param.getRoleName()));
query.distinct(true);
}
return cb.and(predicates.toArray(new Predicate[0]));
};
// 执行分页查询
Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "userId"));
Page<User> page = userRepository.findAll(spec, pageable);
log.info("总记录数: {}", page.getTotalElements());
log.info("总页数: {}", page.getTotalPages());
for (User user : page.getContent()) {
log.info("用户: {} 生日: {}", user.getUsername(), user.getBirthday());
}
}
}
总结
Spring Data JPA非常适合快速实现简单、不追求性能的增删改查需求;相反,在SQL查询十分复杂、性能要求高的场景Spring Data JPA就不太适用了(例如报表场景),因为Spring Data JPA基于方法名自动实现的查询难以表达查询参数非常多、非常复杂的情况,而用JPQL/QBE/Criteria又相对复杂,这种场景可能个人还是倾向于使用MyBatis方案。