【Spring Data JPA】Spring Data JPA的简单使用
本文主要有以下内容:
- 使用
Spring Data JPA
实现简单的CURD
Spring Data JPA
中save()
方法的坑Spring Data JPA
实现动态查询 + 分页查询
Spring Data JPA是一种规范,定义了对数据库操作的接口,其实现为hibernate
,hibernate
提供了对数据库操作的一些封装,如CURD
。本文的重点内容为上述的后两点。
环境搭建
Demo
环境说明:数据库为 MySQL8.0
,JDK11
。
通过 Idea 创建 Spring Boot 工程,在 pom.xml
文件中分别引入spring-boot-starter-data-jpa
,spring-boot-starter-web
,mysql-connector-j
,lombok
等依赖。
在 application.properties
配置文件中添加下列配置
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/jpacurd?autoReconnect=true&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&zeroDateTimeBehavior=convertToNull&serverTimezone=UTC
spring.datasource.username=yourDbAccount
spring.datasource.password=yourPassword
spring.jpa.hibernate.ddl-auto=update
# 打印SQL语句
spring.jpa.show-sql=true
# 打印 SQL 语句参数
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace
实体创建
创建实体类
@Entity
@Table(name = "device")
@Data
public class DeviceEntity {
@Id
@Column(name = "device_id")
@GeneratedValue(strategy = GenerationType.AUTO)
private Integer deviceId;
@Column(name = "address")
private String address;
@Column(name = "description")
private String description;
@Column(name = "device_name")
private String deviceName;
@Column(name = "protocol")
private Integer protocol;
}
注解说明:
@Entity
: JPA注解,表明这是一个实体类@Table
: JPA注解,绑定数据库中的表@Id
: 数据库表的主键@Column
: 数据库表中生成的字段和name
属性的值一致,可以不写,这样默认和实体类的属性名保持一致,如果有大小写的情况,会自动添加_
,如deviceName
会自动变成device_name
。@Data
:lombok
注解,提供getter和setter等方法,简化代码。
JPA的使用
save方法的坑
创建数据操作层
在创建完实体类后,就可以创建数据操作层接口,以提供操作数据的方法。
Spring Data JPA提供了JpaRepository
,该接口继承CrudRepository
,CrudRepository
定义了下列方法:
1.CurdRepository接口结构
看方法名称就能知道方法的作用,在这不进行过多描述,具体的参看文件源码。
定义DeviceRepository
:
@Repository
public interface DeviceRepository extends JpaRepository<DeviceEntity,Integer>, JpaSpecificationExecutor {
}
代码说明:
JpaRepository
: 继承该接口,这样就不用去重复的写一些curd操作,直接使用封装好的方法就可以。JpaSpecificationExecutor
:继承该接口,是为了实现后面的动态查询时,封装动态查询的条件。如果仅仅只需要简单的查询则不需要继承该接口。
Spring Data JPA
提供了根据方法名称进行 SQL
查询的功能,只要方法名符合其规范,那么就能够不写SQL实现查询功能,如下图
2.通过谓词+实体属性定义查询方法
只不过在这里暂时不需要在DeviceRepository
添加任何代码,在最后会做相关说明。
创建Service
层接口
public interface DeviceServiceInt {
// 新增设备
DeviceEntity addDevice(DeviceEntity newDevice);
// 更新设备
DeviceEntity updateDevice(DeviceEntity updateDevice);
// 更新设备的改良写法
DeviceEntity updateDeviceByCriteriaBuilder(DeviceEntity deviceEntity);
// 动态查询 + 分页查询
Page<DeviceEntity> findDeviceByPage(DeviceEntity deviceEntity, int pageSize, int currentPage);
}
首先实现新增设备和更新设备方法,通过这两个方法演示save()
的缺点,由于 JpaRepository
只提供了一个 save()
来持久化数据,因此两个方法都会最终都会调用save()
,
DeviceServiceInt
的实现类:
@Override
public DeviceEntity addDevice(DeviceEntity newDevice) {
return deviceRepository.save(newDevice);
}
@Override
public DeviceEntity updateDevice(DeviceEntity updateDevice) {
return deviceRepository.save(updateDevice);
}
可以看到,上面个两个方法的实现逻辑是一样的。稍后会做说明。
save()
方法的运行逻辑是,当传入的实体类对象,主键属性有值时,则执行更新操作,否则将把数据添加到数据库中。
创建Controller层接口
在项目中创建JpaController
方法,添加如下代码
@PostMapping("add")
public DeviceEntity addDevice(@RequestBody DeviceEntity newDevice){
return deviceService.addDevice((newDevice));
}
@PostMapping("update")
public DeviceEntity updateDevice(@RequestBody DeviceEntity newDevice){
return deviceService.updateDevice((newDevice));
}
通过postman
工具先后调用新增设备和更新设备,如下图:
新增设备接口调用:
3.新增设备
通过上述代码可以知道:在新增设备成功后,会返回我们传递的对象,并且主键也已回填。save()
的源码注释也做了相关说明:
/**
* Saves a given entity. Use the returned instance for further operations as the save operation might have changed the
* entity instance completely.
*
* @param entity must not be {@literal null}.
* @return the saved entity; will never be {@literal null}.
* @throws IllegalArgumentException in case the given {@literal entity} is {@literal null}.
* @throws OptimisticLockingFailureException when the entity uses optimistic locking and has a version attribute with
* a different value from that found in the persistence store. Also thrown if the entity is assumed to be
* present but does not exist in the database.
*/
<S extends T> S save(S entity);
更新设备接口调用:
在新增设备之后,发现设备地址添加错误,因此我们需要修改设备的地址。按照如下方式调用接口,可以看到返回值发生了变化,如下图:
4.直接使用save()方法来更新设备
出现这个现象的原因是因为save()
是全量更新,因此导致了 description、deviceName
属性的值为null,JPA不知道属性值为null是不更新,还是将值更新为null。
解决更新问题的方式通常会采用先通过主键从数据库查询出实体信息,将实体信息进行复制,然后再进行更新动作,伪代码如下
// 1. 查询实体
DeviceEntity existEntity = deviceRepository.findById(newDevice.getDeviceId);
// 2. 将不需要修改的属性复制到传入的参数中
/* getter and setter */
// 3. 保存至数据库
deviceRepository.save(entity)
这样做可以解决将值更新为null的问题,但是还有其他问题,如再并发条件下线程A和线程B都在执行更新操作:
5.并发更新可能出现的问题
虽然这种情况很少遇见,但是总归是不安全的,在JPA中,还有其他可以解决的方法,通过CriteriaBuilder
对象来构造更新条件,这样就不存在上述问题
@Resource
private EntityManager entityManager;
@Transactional
@Override
public DeviceEntity updateDeviceByCriteriaBuilder(DeviceEntity deviceEntity) {
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaUpdate<DeviceEntity> criteriaUpdate = criteriaBuilder.createCriteriaUpdate(DeviceEntity.class);
Root<DeviceEntity> root = criteriaUpdate.from(DeviceEntity.class);
// 更新address属性
criteriaUpdate.set(root.get("address"), deviceEntity.getAddress());
criteriaUpdate.where(criteriaBuilder.equal(root.get("deviceId"), deviceEntity.getDeviceId()));
if (entityManager.createQuery(criteriaUpdate).executeUpdate() > 0) {
Optional<DeviceEntity> entity = deviceRepository.findById(deviceEntity.getDeviceId());
DeviceEntity device = entity.get();
return device;
}
return new DeviceEntity();
}
EntityManager
在spring boot项目中已经装配好了,直接注入即可,不需要添加额外的配置。这种方式需要搭配@Transactional
注解使用,否则会出现如下异常信息:
- javax.persistence.TransactionRequiredException: Executing an update/delete query
上面的代码只更新了address
属性,如有需要可继续添加其他属性,如criteriaUpdate.set(root.get("deviceName"),deviceEntity.getDeviceName());
这样做更新数据量较少的时候,没有什么问题,在更新数据多的情况下,会使得代码耦合高,且不利于维护。因此我们可以通过传递参数数组+反射的方式来解决,这样就不需要硬编码到代码中。如传入一个HashMap,通过遍历key来得到要更新的属性,再通过get(key)来获取更新的属性的值。
接下来添加一个新的设备信息:
{
"deviceId": 7,
"address": "四川成都高新西区产业园",
"description": "updateDeviceByCriteriaBuilder",
"deviceName": "updateDeviceByCriteriaBuilder",
"protocol": 1
}
然后更新设备的安装地址
6.通过criteriaBuilder.createCriteriaUpdate更新
此时可以看到其他属性没有受到影响。此次更新的影响。
分页查询 + 动态查询
JPA中已经集成了分页查询功能,我们只需要学会如何使用即可,需要使用的对象有:
- PageRequest:构建分页对象,偏移量和查询数量
- Specification:动态查询需要使用的对象
- JpaSpecificationExecutor:支持criteria API查询
- CriteriaBuilder:用于构造查询条件
- Predicate:谓语,CriteriaBuilder构造查询条件的返回值
- Page:封装了查询的结果
PageRequest:实现了Pageable
接口,Pageable
定义了和分页查询有关的方法,如下图:
7.Pageable源码
PageRequest.of()
的源代码如下所示:page从0开始!不是从1开始。
/**
* Creates a new unsorted {@link PageRequest}.
*
* @param page zero-based page index, must not be negative.
* @param size the size of the page to be returned, must be greater than 0.
* @since 2.0
*/
public static PageRequest of(int page, int size) {
return of(page, size, Sort.unsorted());
}
JpaSpecificationExecutor:源码中是这样写的
- Interface to allow execution of Specifications based on the JPA criteria API.
Specification: 领域驱动设计意义上的规范。该接口内部定义了一些类似于SQL中的条件拼接方法 where,and,or,not等
- Specification in the sense of Domain Driven Design.
认知不够不知道该怎么去理解这两个,只知道怎么使用。等以后认知更深了再来详细写写这俩。
Service层实现:实现动态查询 + 分页查询功能
@Override
public Page<DeviceEntity> findDeviceByPage(DeviceEntity deviceEntity, int pageSize, int currentPage) {
// 构建分页对象:currentPage是从0开始计算的,如在网页上看到的第一页内容,则这里应该是0
PageRequest request = PageRequest.of(currentPage, pageSize);
// 使用lambda表达式的方式简化代码
Specification<DeviceEntity> device = (root, query, criteriaBuilder) -> {
// 多字段查询条件拼接,这里查询设备id > 3 , 根据地址信息进行模糊查询的设备
List<Predicate> predicates = new ArrayList<>();
// 条件1
Predicate address = criteriaBuilder.like(root.get("address"), "%" + deviceEntity.getAddress() + "%");
predicates.add(address);
// 条件2 id > 3
Predicate deviceId = criteriaBuilder.ge(root.get("deviceId"), 3);
predicates.add(deviceId);
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
};
return deviceRepository.findAll(device, request);
}
Controller层代码实现:
@GetMapping("getPage")
public List<DeviceEntity> getDeviceByPage(DeviceEntity deviceEntity,int pageSize, int currentPage){
log.info("deviceEntity = {}, pageSize = {}, currentPage = {}",deviceEntity,pageSize,currentPage);
Page<DeviceEntity> page = deviceService.findDeviceByPage(deviceEntity, pageSize, currentPage);
List<DeviceEntity> content = page.getContent();
return content;
}
postman测试结果如下:
8.分页查询测试结果
Page对象还封装了一些其他方法,借用这些方法可以更好的完成分页功能
// 总页数
page.getTotalPages();
// 数据总数
page.getTotalElements();
// 当前页面的pageSize
page.getSize();
在上面传递的参数中,除了传递必要三个参数外,我还传递了protocol
参数,这是因为在分页查询时,我们往往不会传递一个查询条件,往往需要传递多个查询条件,在这种场景如果将查询条件一个一个分开来写同时配合@RequestParam
注解使用,则controller里的方法参数太多,代码不好看,这种情况下就会使用一个实体类来接收参数,如上述方法的签名,这样就可以简化方法参数。可以看到是可以正确接收的。
9.参数传递
简单查询的方法定义
JPA对查询提供了丰富的支持
- 通过Example对象查询
- 自定义查询,通过
@Query
注解配合jpql语句自定义查询,也可以配合@Modifying
注解,实现新增或者更新操作,@Modifying
表示此次操作将会修改数据库。- 通过自定义方法名查询
以自定义查询方法为例,在Repository
中通过自定义方法名查询步骤:
- 一般以find开头表示查询,count开头表示计数,
- 查询字段:和实体类中的属性保持一致
- 连接条件:and、or等。
需要注意的是:实体属性定义需要严格按照规范来,在定义时不要使用下划线,这是因为如果我们的实体对象嵌套了其他实体对象。如Student
对象嵌套一个Address
属性,同时Address
类拥有一个cityCode
属性时,在定义方法时我们就可以定义findByAddressCityCode()
。由于Student类并没有cityCode
属性,此时JPA的算法会split方法名,从右向左,首先分离为code
和addressCity
,然后再实体类中寻找,算法找到具有该头部的属性,它将获取尾部并继续从那里向下构建查询树。如果第一个拆分不匹配,算法会将拆分点向左移动。为了减少这种拆分方式我们可以通过下划线的方式来避免如findByAddress_CityCode()
这样就可以减少这种现象。
以DeviceEntity
为例,我们就可以定义如下查询:
DeviceEntity findByAddress(String address);
DeviceEntity findByDeviceIdGreaterThan(Integer id);
更多查询方法的使用请参看这里