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實例查詢
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章