Spring Boot + JPA 最佳实践

(一)前言

随着 Java 技术和微服务技术逐渐广泛应用,Spring Cloud、Spring Boot 逐渐成为 Java 开发的主流框架,ORM 框架也因此得到重视。
纵观目前主流的 ORM 框架,MyBatis 以灵活著称,但是需要维护复杂的配置,并且不是 Spring 官方的天然全家桶,还得做额外的配置工作;Hibernate 以 HQL 和关系映射著称,但使用起来并不灵活。
Spring Data JPA 是 Spring 基于 ORM 框架、JPA 规范的基础上封装的一套 JPA 应用框架,可使开发者用极简的代码即可实现对数据的访问和操作。它提供了包括增删改查等在内的常用功能,且易于扩展,学习并使用 Spring Data JPA 可以极大提高开发效率。开发者只需自定义仓储(Repository)接口并继承 JpaRepository 接口即可实现基本的 CRUD 功能。由此可以看出,Spring Data JPA 吸取了 MyBatis 和 Hibernate 的优点,其底层以 Hibernate 为封装,对外提供了灵活的使用接口,又非常符合面向对象和 REST 的风格,所以如今越来越多的公司对开发者的技术栈要求由传统的 SSH、SSM 框架逐步变为熟悉 Spring Boot、Spring Cloud、Spring Data 等 Spring 全家桶。

(二)案例实现

2.1 数据库表设计

本文数据库的表设计来自我之前的博客:趣味MySQL:查询NBA球员的冠军总数,一共有三张表:球员表 player,球队表 team,球员球队关系表 player_team。其中,player 和 team 表存在多对多关系,一名球员可能在不同年份为多支球队效力过,一支球队在历史上也拥有多名球员。如:詹姆斯在 2003 - 2010 / 2014 - 2018 年为骑士队效力,2010 - 2014年为迈阿密热火效力;湖人队在历史上拥有过科比、1997 - 2004年的奥尼尔等诸多巨星。表之间的关系架构图如下:
数据库表架构

2.2 application 配置

这里可以在 application.yml 文件中开启 JPA SQL 语句的打印配置,以方便代码调试。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/nba?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    show-sql: true
logging:
  level:
    com.jake.demo.jpa.repository: debug
  file: log/jpa-demo.log

2.3 实体类

首先根据数据库表之间的关系创建 JPA Entity,完成代码和数据库之间的 ORM。

@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "player")
public class Player {

    @Id
    @JsonIgnore
    private Integer pid;

    private String name;

    private String position;

}
@Data
@Entity
@Table(name = "team")
public class Team {

    @Id
    @JsonIgnore
    private Integer tid;

    private String name;

    private String city;

}

本文中,由于 @OneToMany 的默认获取类型(FetchType)为懒加载(LAZY),而设置为 EAGER 后,又会导致递归查询数据库。比如 Player 对象中包含 PlayerTeam 集合,PlayerTeam 集合元素中又包含 Player 对象,Player 对象中又再次包含 PlayerTeam 集合,以此循环递归对数据库做查询,产生大量 SQL 语句。所以,本文不进行主表到中间表的一对多配置。

@Data
@Entity
@Table(name = "player_team",
        uniqueConstraints = {@UniqueConstraint(columnNames = {"year", "pid", "tid"})})
public class PlayerTeam {

    @Id
    @JsonIgnore
    private Integer ptid;

    private Integer year;

    @ManyToOne
    @JoinColumn(name = "pid")
    private Player player;

    @ManyToOne
    @JoinColumn(name = "tid")
    private Team team;

}

根据 MySQL 中间关系表 player_team 的索引设置唯一约束 uniqueConstraints;当类中字段和表中列命名不同或不遵从下划线 - 小驼峰转换规则时,使用@JoinColumn为多对一关系所在字段设置好数据库表所在列。

2.4 Repository 接口

public interface PlayerRepository extends JpaRepository<Player, Integer> {

    @Query("select max(p.pid) from Player p")
    Integer findMaxId();
}

由于数据库主键没有设置为自增,所以此处自定义了一个找出数值最大主键的方法,以方便单元测试时设置新插入对象的主键为MAX(ID) + 1
中间表 PlayerTeam 的 Repository 接口只需直接继承 JpaRepository 即可,无需自定义方法。

public interface PlayerTeamRepository extends JpaRepository<PlayerTeam, Integer> {
}

2.5 单元测试

@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class PlayerRepositoryTest {

    @Autowired
    private PlayerRepository playerRepository;

    @Test
    public void findAll() {
        List<Player> allPlayers = playerRepository.findAll();
        log.info("all players list: {}", allPlayers);
        assertNotNull(allPlayers);
    }

    @Test
    public void findById() {
        Player player = playerRepository.findById(1).orElse(null);
        log.info("player with id 1: {}", player);
        assertNotNull(player);
    }

    @Test
    public void findByConditions() {
        Player player = new Player();
        player.setName("Kobe");
        player.setPosition("Guard");
        ExampleMatcher matcher = ExampleMatcher.matching()
                .withMatcher("name", ExampleMatcher.GenericPropertyMatchers.startsWith())
                .withMatcher("position", ExampleMatcher.GenericPropertyMatchers.contains())
                .withIgnorePaths("pid");
        Example<Player> example = Example.of(player, matcher);
        List<Player> matchedPlayers = playerRepository.findAll(example);
        log.info("player named Kobe: {}", matchedPlayers);
        assertNotNull(matchedPlayers);
    }

    @Test
    public void save() {
        Integer maxId = playerRepository.findMaxId();
        Player player = new Player(maxId + 1, "Yao Ming", "Center");
        Player savedPlayer = playerRepository.save(player);
        log.info("the saved player: {}", savedPlayer);
        assertNotNull(savedPlayer);
    }

    @Test
    public void update() {
        Integer maxId = playerRepository.findMaxId();
        Player player = playerRepository.findById(maxId).orElse(null);
        assertNotNull(player);
        player.setName("Yi Jianlian");
        player.setPosition("Power Forward");
        Player updatedPlayer = playerRepository.save(player);
        log.info("the updated player: {}", updatedPlayer);
        assertNotNull(updatedPlayer);
    }

    @Test
    public void delete() {
        Integer maxId = playerRepository.findMaxId();
        assertNotNull(maxId);
        playerRepository.deleteById(maxId);
    }
}

针对 PlayerRepository 的 CRUD 方法,分别写了一个测试方法与之对应,最终打印结果与预期完全一致,相对简单。在做条件匹配时可以使用 ExampleMatcher,该类包含了字符串查询必备的精确、模糊、大小写匹配等。但这里仅仅是对单表 player 的 CRUD;为了验证多表的 JPA Persistence 注解 @ManyToOne 是否可用,对 PlayerTeamRepository 接口也进行单元测试:

@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class PlayerTeamRepositoryTest {

    @Autowired
    private PlayerTeamRepository playerTeamRepository;

    @Test
    public void findAll() {
        List<PlayerTeam> allPlayerTeams = playerTeamRepository.findAll();
        log.info("allPlayerTeams list: {}", allPlayerTeams);
        assertNotNull(allPlayerTeams);
    }
}

最终 PlayerTeamRepository 接口 findAll 单元测试方法的打印结果如下:

Hibernate: select playerteam0_.ptid as ptid1_2_, playerteam0_.pid as pid3_2_, playerteam0_.tid as tid4_2_, playerteam0_.year as year2_2_ from player_team playerteam0_
Hibernate: select player0_.pid as pid1_1_0_, player0_.name as name2_1_0_, player0_.position as position3_1_0_ from player player0_ where player0_.pid=?
Hibernate: select team0_.tid as tid1_3_0_, team0_.city as city2_3_0_, team0_.name as name3_3_0_ from team team0_ where team0_.tid=?
Hibernate: select player0_.pid as pid1_1_0_, player0_.name as name2_1_0_, player0_.position as position3_1_0_ from player player0_ where player0_.pid=?
Hibernate: select team0_.tid as tid1_3_0_, team0_.city as city2_3_0_, team0_.name as name3_3_0_ from team team0_ where team0_.tid=?
Hibernate: select player0_.pid as pid1_1_0_, player0_.name as name2_1_0_, player0_.position as position3_1_0_ from player player0_ where player0_.pid=?
Hibernate: select player0_.pid as pid1_1_0_, player0_.name as name2_1_0_, player0_.position as position3_1_0_ from player player0_ where player0_.pid=?
Hibernate: select team0_.tid as tid1_3_0_, team0_.city as city2_3_0_, team0_.name as name3_3_0_ from team team0_ where team0_.tid=?
Hibernate: select player0_.pid as pid1_1_0_, player0_.name as name2_1_0_, player0_.position as position3_1_0_ from player player0_ where player0_.pid=?
Hibernate: select team0_.tid as tid1_3_0_, team0_.city as city2_3_0_, team0_.name as name3_3_0_ from team team0_ where team0_.tid=?
Hibernate: select team0_.tid as tid1_3_0_, team0_.city as city2_3_0_, team0_.name as name3_3_0_ from team team0_ where team0_.tid=?
2019-10-07 17:17:24.971  INFO 15156 --- [           main] c.j.d.j.r.PlayerTeamRepositoryTest       : allPlayerTeams list: [PlayerTeam(ptid=15, year=1996, player=Player(pid=3, name=Michael Jordan, position=Shooting Guard), team=Team(tid=2, name=Bulls, city=Chicago)), PlayerTeam(ptid=4, year=1998, player=Player(pid=3, name=Michael Jordan, position=Shooting Guard), team=Team(tid=2, name=Bulls, city=Chicago)), PlayerTeam(ptid=1, year=2000, player=Player(pid=1, name=Kobe Bryant, position=Shooting Guard), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=8, year=2000, player=Player(pid=4, name=Shaquille O'Neal, position=Center), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=6, year=2001, player=Player(pid=1, name=Kobe Bryant, position=Shooting Guard), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=9, year=2001, player=Player(pid=4, name=Shaquille O'Neal, position=Center), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=16, year=2001, player=Player(pid=13, name=Allen Iverson, position=Shooting Guard), team=Team(tid=25, name=76ers, city=Philadelphia)), PlayerTeam(ptid=7, year=2002, player=Player(pid=1, name=Kobe Bryant, position=Shooting Guard), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=5, year=2002, player=Player(pid=4, name=Shaquille O'Neal, position=Center), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=11, year=2009, player=Player(pid=1, name=Kobe Bryant, position=Shooting Guard), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=13, year=2009, player=Player(pid=2, name=Lebron James, position=Small Forward), team=Team(tid=4, name=Cavaliers, city=Cleveland)), PlayerTeam(ptid=12, year=2010, player=Player(pid=1, name=Kobe Bryant, position=Shooting Guard), team=Team(tid=1, name=Lakers, city=Los Angeles)), PlayerTeam(ptid=14, year=2010, player=Player(pid=2, name=Lebron James, position=Small Forward), team=Team(tid=4, name=Cavaliers, city=Cleveland)), PlayerTeam(ptid=10, year=2012, player=Player(pid=2, name=Lebron James, position=Small Forward), team=Team(tid=3, name=Heat, city=Miami)), PlayerTeam(ptid=3, year=2013, player=Player(pid=2, name=Lebron James, position=Small Forward), team=Team(tid=3, name=Heat, city=Miami)), PlayerTeam(ptid=2, year=2016, player=Player(pid=2, name=Lebron James, position=Small Forward), team=Team(tid=4, name=Cavaliers, city=Cleveland))]

一共执行了 11 条 SQL 语句,其中 1 条是对查出 player_team 表中所有数据,5 条是查出 player 表数据,另 5 条是查出 team 表数据。
这是因为 @ManyToOne 的默认 FetchTypeEAGER,所以需要会对 player 和 team 表数据进行查询 player_team 对 pid 和 tid 进行去重后的计数都是 5 条:

select count(*) count_pid from (select distinct(pid) from player_team) dp;
select count(*) count_tid from (select distinct(tid) from player_team) dp;

需要根据这 5 条 pid 和 5 条 tid 分别去 player 和 team 表中分别查询并封装 Player 和 Team 对象。

(三)参考文章

  1. Introduction of Spring Data Family
  2. ORM 实例教程 - 阮一峰
  3. ORM & JPA & Spring Data JPA 之间的关系
  4. SpingDataJPA之ExampleMatcher实例查询
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值