(一)前言
隨着 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
的默認 FetchType
爲 EAGER
,所以需要會對 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 對象。