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的一些注意事项

  1. Spring Data JPA虽然用法简单但也有一些广受诟病的缺陷,比如性能优化难、用法不灵活、很多魔法操作等,在一些较严肃、可预见维护周期长的工程中慎用
  2. 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-ddlspring.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
  • 多个查询条件可以用AndOr等连接,如findByUsernameAndPassword
  • 模糊查询在条件字段后加Like,如findByUsernameLike
  • 比较查询可以在字段后面加BeforeAfter

上述规则能够覆盖80%的查询操作了,这就是Spring Data JPA的方便之处。

测试访问数据库

下面单元测试代码调用了前面的UserRepositoryfindByBirthdayAfter()方法。

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方案。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。