1. 程式人生 > >一文搞定 Spring Data JPA

一文搞定 Spring Data JPA

Spring Data JPA 是在 JPA 規範的基礎上進行進一步封裝的產物,和之前的 JDBC、slf4j 這些一樣,只定義了一系列的介面。具體在使用的過程中,一般接入的是 Hibernate 的實現,那麼具體的 Spring Data JPA 可以看做是一個面向物件的 ORM。雖然後端實現是 Hibernate,但是實際配置和使用比 Hibernate 簡單不少,可以快速上手。如果業務不太複雜,個人覺得是要比 Mybatis 更簡單好用。 本文就簡單列一下具體的知識點,詳細的用法可以見參考文獻中的部落格。本文具體會涉及到 JPA 的一般用法、事務以及對應 Hibernate 需要掌握的點。 ## 基本使用 1. **建立專案,選擇相應的依賴。一般不直接用 mysql 驅動,而選擇連線池。** ```xml org.springframework.boot spring-boot-starter-data-jpa mysql mysql-connector-java runtime com.alibaba druid-spring-boot-starter 1.1.18 ``` 2. **配置全域性 yml 檔案。** ```yaml spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://172.21.30.61:3306/gpucluster?serverTimezone=Hongkong&characterEncoding=utf-8&useSSL=false username: password: jpa: hibernate: ddl-auto: update open-in-view: false properties: hibernate: dialect: org.hibernate.dialect.MySQL57Dialect show_sql: false format_sql: true logging: level: root: info # 是否需要開啟 sql 引數日誌 org.springframework.orm.jpa: DEBUG org.springframework.transaction: DEBUG org.hibernate.engine.QueryParameters: debug org.hibernate.engine.query.HQLQueryPlan: debug org.hibernate.type.descriptor.sql.BasicBinder: trace ``` - `hibernate.ddl-auto: update ` 實體類中的修改會同步到資料庫表結構中,慎用。 - `show_sql` 可開啟 hibernate 生成的 sql,方便除錯。 - `logging` 下的幾個引數用於顯示 sql 的引數。 3. **建立實體類並新增 JPA 註解** ```java @Entity @Table(name = "user") @Data public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private Integer age; private String address; private LocalDateTime createTime; private LocalDateTime updateTime; } ``` 4. **建立對應的 Repository** 實現 JpaRepository 介面,生成基本的 CRUD 操作樣板程式碼。並且可根據 Spring Data JPA 自帶的 Query Lookup Strategies 建立簡單的查詢操作,在 IDEA 中輸入 `findBy ` 等會有提示。 ```java public interface IUserRepository extends JpaRepository { List findByName(String name); List findByAgeAndCreateTimeBetween(Integer age, LocalDateTime createTime, LocalDateTime createTime2); } ``` ## 查詢 ### 預設方法 Repository 繼承了 `JpaRepository` 後會有一系列基本的預設 CRUD 方法,例如: ```java List findAll(); Page findAll(Pageable pageable); T getOne(ID id); T S save(T entity); void deleteById(ID id); ``` ### 宣告式查詢 Repository 繼承了 `JpaRepository` 後,可在介面中定義一系列方法,它們一般以 `findBy`、`countBy`、`deleteBy`、`existsBy` 等開頭,如果使用 IDEA,輸入以下關鍵字後會有響應的提示。例如: ```java public interface IUserRepository extends JpaRepository{ User findByUsername(String username); Integer countByDept(String dept); } ``` 對於一些單表多欄位查詢,使用這種方式就非常舒服了,而且完全 oop 思想,不需要思考具體的 SQL 怎麼寫。但有個問題,欄位多了之後方法名會很長,呼叫的時候會比較難受,這個時候可以利用 jdk8 的特性將它縮短,當然這種情況也可以直接用 `@Query` 寫 HQL 或 SQL 解決。 ```java User findFirstByEmailContainsIgnoreCaseAndField1NotNullAndField2NotNull(final String email); default User getByEmail(final String email) { return findFirstByEmailContainsIgnoreCaseAndField1NotNullAndField2NotNull(email); } ``` > 常見的操作可見 [附錄 - 支援的方法關鍵詞](### 支援的方法關鍵詞) ### 使用註解和 SQL ```java @Transactional(readOnly = true) public interface UserRepository extends JpaRepository { @Query(nativeQuery = true, value = "select * from user where tel = ?1") List getUser(String tel); @Modifying @Transactional @Query("delete from User u where u.active = false") void deleteInactiveUsers(); } ``` 1. `@Query` 中可寫 HQL 和 SQL,如果是 SQL,則 `nativeQuery = true`。 ### 複雜查詢 Specification ```java // 複雜查詢,建立 Specification private Page getOrderInfoListByConditions(String tel, int pageSize, int pageNo, String beginTime, String endTime) { Specification specification = new Specification() { @Override public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { List predicate = new ArrayList<>(); if (!Strings.isNullOrEmpty(beginTime)) { predicate.add(cb.greaterThanOrEqualTo(root.get("createTime"), DateUtils.getDateFromTimestamp(beginTime))); } if (!Strings.isNullOrEmpty(endTime)) { predicate.add(cb.lessThanOrEqualTo(root.get("createTime"), DateUtils.getDateFromTimestamp(endTime))); } if (!Strings.isNullOrEmpty(tel)) { predicate.add(cb.equal(root.get("userTel"), tel)); } return cb.and(predicate.toArray(new Predicate[predicate.size()])); } }; Sort sort = new Sort(Sort.Direction.DESC, "createTime"); Pageable pageable = new PageRequest(pageNo - 1, pageSize, sort); return orderInfoRepository.findAllEntities(specification, pageable); } ``` ### 子查詢 Specification specification = (root, criteriaQuery, criteriaBuilder) -> { Subquery subQuery = criteriaQuery.subquery(String.class); Root from = subQuery.from(User.class); subQuery.select(from.get("userId")).where(criteriaBuilder.equal(from.get("username"), "mqy6289")); return criteriaBuilder.and(root.get("userId").in(subQuery)); }; return userProjectRepository.findAll(specification); ## 刪除和修改 - 刪除 1. 直接使用預設的 `deleteById()`。 2. 使用申明式查詢建立對應的刪除方法 `deleteByXxx`。 3. 使用 SQL\HQL 註解刪除 - 新增和修改 呼叫 save 方法,如果是修改的需要先查出相應的物件,再修改相應的屬性。 ## 事務 Spring Boot 預設整合事務,所以無須手動開啟使用 `@EnableTransactionManagement` 註解,就可以用 `@Transactional` 註解進行事務管理。需要使用時,可以查具體的引數。 `@Transactional` 註解的使用,具體可參考:[透徹的掌握 Spring 中 @transactional 的使用](https://www.ibm.com/developerworks/cn/java/j-master-spring-transactional-use/index.html)。 談談幾點用法上的總結: 1. 持久層方法上繼承 `JpaRepository`,對應實現類 `SimpleJpaRepository` 中包含 `@Transactional(readOnly = true)` 註解,因此**預設持久層中的 CRUD 方法均添加了事務**。 2. 申明式事務更常用的是在 service 層中的方法上,一般會呼叫多個 Repository 來完成一項業務邏輯,過程中可能會對多張資料表進行操作,出現異常一般需要級聯回滾。一般操作,直接在 Serivce 層方法中新增 `@Transactional` 即可,預設使用資料的隔離級別,預設所有 Repository 方法加入 Service 層中的事務。 3. `@Transactional` 註解中最核心的兩個引數是 `propagation` 和 `isolation`。前者用於控制事務的傳播行為,指定小事務加入大事務還是所有事務均單獨執行等;後者用於控制事務的隔離級別,預設和 MySQL 保持一致,為不可重複讀。我們也可以通過這個欄位手動修改單個事務的隔離級別。具體的應用場景可見我另一篇部落格 [談談事務的隔離性及在開發中的應用](https://www.cnblogs.com/Sinte-Beuve/p/13260254.html)。 4. 同一個 service 層中的方法呼叫,**如果添加了 `@Transactional` 會啟動 hibernate 的一級快取**,相同的查詢多次執行會進行 Session 層的快取,否則,多次相同的查詢作為事務獨立執行,則無法快取。 5. 如果你使用了關係註解,在懶載入的過程中一般都會遇到過 `LazyInitializationException` 這個問題,可通過新增 `@Transactional`,將 session 託管給 Spring Transaction 解決。 6. 只讀事務的使用。可在 service 層中全域性配置只讀事務 `@Transactional(readOnly =true)`,對於具有讀寫的事務可在對應方法中覆蓋即可。在只讀事務無法進行寫入操作,這樣在事務提交前,hibernate 就會跳過 dirty check,並且 Spring 和 JDBC 會有多種的優化,使得查詢更有效率。 ## JPA Audit JPA 自帶的 Audit 可以通過 AOP 的形式注入,在持久化操作的過程中新增建立和更新的時間等資訊。具體使用方法: 1. 申明實體類,需要在類上加上註解 @EntityListeners(AuditingEntityListener.class)。 2. 在 Application 啟動類中加上註解 @EnableJpaAuditing 3. 在需要的欄位上加上 @CreatedDate、@CreatedBy、@LastModifiedDate、@LastModifiedBy 等註解。 **如果只需要更新建立和更新的時間是不需要額外的配置的。** ## 資料庫關係 如果需要進行級聯查詢,可用 JPA 的 @OneToMany、@ManyToMany 和 @OneToOne 來修飾,當然,碰到出現一對多等情況的時候,可以手動將多的一方的資料去查詢出來填充進去。 由於資料庫設計的不同,註解在使用上也會存在不同。這裡舉一個 OneToMany 的例子。 倉庫和貨物是一對多關係,並且在設計上,Goods 表中包含 Repository 的外來鍵,則在 Repository 添加註解,Goods 上不需要。 ```java @Entity public class Repository{ @OneToMany(cascade = {CascadeType.ALL}) @JoinColumn(name = "repo_id") private List list; } public class Goods{ } ``` 具體可參考:[@OneToMany、@ManyToOne 以及 @ManyToMany 講解(五)](https://my.oschina.net/liangbo/blog/92301) JPA 的這幾個註解和 Hibernate 的關聯度比較大,而且一般適合於 code first 的形式,也就是說先有實體類後生成資料庫。在這裡我並不建議沒有學習過 Hibernate 直接上手 Spring Data JPA 的人去使用這些註解,因為一旦加上關係註解後,從查詢的角度雖然方便了,但是涉及到一些級聯的操作,例如刪除、修改等操作,容易採坑。需要額外去了解 Hibernate 的快取重新整理機制。 ## 多資料來源 預設單資料來源的情況下,我們只需要將自己的 Repository 實現 JpaRepository 介面即可,通過 Spring Boot 的 Auto Configuration 會自動幫我們注入所需的 Bean,例如 `LocalContainerEntityManagerFactoryBean`、`EntityManager `、`DataSource`。 但是在多資料來源的情況下,就需要根據配置檔案去條件化建立這些 Bean 了。 1. **配置檔案新增多個數據源資訊** ```yaml spring: datasource: hangzhou: # datasource1 type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://172.17.11.72:3306/gpucluster?serverTimezone=Hongkong&characterEncoding=utf-8&useSSL=false username: root password: 123456 shanghai: # datasource2 type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://172.21.30.61:3306/gpucluster?serverTimezone=Hongkong&characterEncoding=utf-8&useSSL=false username: root password: 123456 jpa: open-in-view: false properties: hibernate: dialect: org.hibernate.dialect.MySQL57Dialect ``` 2. **資料來源 bean 注入** ```java @Slf4j @Configuration public class DataSourceConfiguration { @Bean(name = "HZDataSource") @Primary @Qualifier("HZDataSource") @ConfigurationProperties(prefix = "spring.datasource.hangzhou") public DataSource primaryDataSource() { return DataSourceBuilder.create().type(DruidDataSource.class).build(); } @Bean(name = "SHDataSource") @Qualifier("SHDataSource") @ConfigurationProperties(prefix = "spring.datasource.shanghai") public DataSource secondaryDataSource() { return DataSourceBuilder.create().type(DruidDataSource.class).build(); } } ``` 3. **注入 JPA 相關的 bean(一個數據源一個配置檔案)** ```java @Configuration @EnableTransactionManagement @EnableJpaRepositories( entityManagerFactoryRef = "entityManagerFactoryHZ", transactionManagerRef = "transactionManagerHZ", basePackages = {"cn.com.arcsoft.app.repo.jpa.hz"}, repositoryBaseClass = IBaseRepositoryImpl.class) public class RepositoryHZConfig { private final DataSource HZDataSource; private final JpaProperties jpaProperties; private final HibernateProperties hibernateProperties; public RepositoryHZConfig(@Qualifier("HZDataSource") DataSource HZDataSource, JpaProperties jpaProperties, HibernateProperties hibernateProperties) { this.HZDataSource = HZDataSource; this.jpaProperties = jpaProperties; this.hibernateProperties = hibernateProperties; } @Primary @Bean(name = "entityManagerFactoryHZ") public LocalContainerEntityManagerFactoryBean entityManagerFactoryHZ(EntityManagerFactoryBuilder builder) { // springboot 2.x Map properties = hibernateProperties.determineHibernateProperties( jpaProperties.getProperties(), new HibernateSettings()); return builder.dataSource(HZDataSource) .properties(properties) .packages("cn.com.arcsoft.app.entity") .persistenceUnit("HZPersistenceUnit") .build(); } @Primary @Bean(name = "transactionManagerHZ") public PlatformTransactionManager transactionManagerHZ(EntityManagerFactoryBuilder builder) { return new JpaTransactionManager(entityManagerFactoryHZ(builder).getObject()); } } ``` 4. **在之前配置的對應的包中新增相應的 repository 就可以了。如果資料來源資料庫是相同的,可實現一個主的 repository,其餘繼承一下。** ```java @Primary @Qualifier("volumeHZRepository") public interface IVolumeRepository extends IBaseRepository { Volume findByUserIdAndIp(Integer userId, String ip); } @Qualifier("volumeSHRepository") public interface IVolumeSHRepository extends IVolumeRepository { } ``` ## JPA 與 Hibernate 在使用 Spring Data JPA 的時候,雖然底層是 Hibernate 實現的,但是我們在使用的過程中完全沒有感覺,因為我們在使用 JPA 規範提供的 API 來操作資料庫。但是遇到一些複雜的業務,或許任然需要關注 Hibernate,或者 JPA 底層的一些實現,例如 EntityManager 和 EntityManagerFactory 的建立和使用。 下面我就講講最核心的兩點。 ### 物件生命週期 用過 Mybatis 的都知道,它屬於半自動的 ORM,僅僅是將 SQL 執行後的結果對映到具體的物件,雖然它也做了對查詢結果的快取,但是一旦資料查出來封裝到實體類後,就和資料庫無關了。但是 JPA 後端的 Hibernate 則不同,作為全自動的 ORM,它自己有一套比較複雜的機制,用於處理物件和資料庫中的關係,兩者直接會進行繫結。 首先在 Hibernate 中,物件就不再是基本的 Java POJO 了,而是有四種狀態。 > 1. 臨時狀態 (transient): 剛用 new 語句建立,還未被持久化的並且不在 Session 的快取中的實體類。 > 2. 持久化狀態 (persistent): 已被持久化,並且在 Session 快取中的實體類。 > 3. 刪除狀態 (removed): 不在 Session 快取中,而且 Session 已計劃將其從資料庫中刪除的實體類。 > 4. 遊離狀態 (detached): 已被持久化,但不再處於 Session 的快取中的實體類。 ![image002-35](https://blog-20190524.oss-cn-hangzhou.aliyuncs.com/images/simple-way-to-learn-spring-data-jpa/image002-35.png?x-oss-process=style/logo) **需要特別關注的是持久化狀態的物件,這類物件一般是從資料庫中查詢出來的,同時會存在 Session 快取中,由於存在快取清理與 dirty checking 機制,當修改了物件的屬性,無需手動執行 save 方法,當事務提高後,改動會自動提交到資料庫中去。** ### 快取清理與 dirty checking 當事務提交後,會進行快取清理操作,所有 session 中的持久化物件都會進行 dirty checking。簡單描述一下過程: 1. 在一個事務中的各種查詢結果都會快取在對應的 session 中,並且存一份快照。 2. 在事務 commit 前,會呼叫 `session.flush()` 進行快取清理和 dirty checking。將所有 session 中的物件和對應快照進行對比,如果發生了變化,則說明該物件 dirty。 3. 執行 update 和 delete 等操作將 session 中變化的資料同步到資料庫中。 開啟只讀事務可遮蔽 dirty checking,提高查詢效率。 ## Troubleshooting 1. **Jpa 與 lombok 配合使用的問題產生 StackOverflowError** 使用 Hibernate 的關係註解 @ManyToMany 時使用 @Data,執行查詢時會出現 StackOverflowError 異常。主要是因為 @Data 幫我們實現了 hashCode() 方法出現了問題,出現了迴圈依賴。 解決方法:在關係欄位上加上 @EqualsAndHashCode.Exclude 即可。 ```java @EqualsAndHashCode.Exclude @ManyToMany(fetch = FetchType.LAZY,cascade = {CascadeType.PERSIST}) private Set membersSet; ``` [Lombok.hashCode issue with “java.lang.StackOverflowError: null”](https://github.com/rzwitserloot/lombok/issues/1007) 2. **Spring boot JPA:Unknown entity 解決方法** 在採用兩個大括號初始化物件後,再呼叫 JPA 的 save 方法時會丟擲 Unknown entity 這個異常,這是 JPA 無法正確識別匿名內部類導致的。 解決方法:手動 new 一個物件再呼叫 set 方法賦值。 [Spring boot JPA:Unknown entity 解決方法](https://www.jianshu.com/p/179bdaab348d) 3. **使用關係註解時產生的 LazyInitializationException 異常** > org.hibernate.LazyInitializationException: could not initialize proxy - no Session 如果使用 Hibernate 關係註解,可能會遇到這個問題,這是因為在 session 關閉後 get 物件中懶載入的值產生的。 解決方法: 1. 在實體類中添加註解 `@Proxy(lazy = false)` 2. 在 services 層的方法中新增 `@Transactional`,將 session 管理交給 spring 事務 ## 總結 本文主要講了下 Spring Data JPA 的基本使用和一些個人經驗。 ORM 發展至今,從 Hibernate 到 JPA,再到現在的 Spring Data JPA。可以看到是一個不斷簡化的過程,過去大段的 xml 已經沒有了,僅保留基本的 sql 字串即可。Spring Data JPA 雖然配置和使用起來簡單,但由於它的底層依然是 Hibernate 實現的,因此有些東西仍然需要去了解。就目前使用而言,有以下幾點感受: 1. 要用好 Spring Data JPA,Hibernate 的相關機制還是需要有一定的瞭解的,例如前面提到的物件宣告週期及 Session 重新整理機制等。如果不瞭解,容易出現一些莫名其妙的問題。 2. 如果是新手,個人不推薦使用關係註解。 技術本身就是一步步在簡化,如果不是非常複雜的例如 ERP 系統,沒必要去使用 JPA 和 Hibernate 原生的東西,完全可以手動多次查詢操作來代替關係註解。之所以這麼講,是因為對 JPA 的關係註解的使用,以及各種級聯操作的型別理解不深,會存在一些隱患。 ## 參考文獻 - [SpringBoot 整合 Spring-data-jpa](https://chenjiabing666.github.io/2018/12/20/SpringBoot%E6%95%B4%E5%90%88Spring-data-jpa/) - [Spring Boot(五):Spring Boot Jpa 的使用](http://www.ityouknow.com/springboot/2016/08/20/spring-boot-jpa.html) - [Spring Data JPA 進階(六):事務和鎖](https://www.yasinshaw.com/articles/39) - [Spring Data JPA - Reference Documentation](https://docs.spring.io/spring-data/jpa/docs/2.1.10.RELEASE/reference/html/) ## 附錄 ### 支援的方法關鍵詞 | Keyword | Sample | JPQL snippet | | ----------------- | --------------------------------------------------------- | ------------------------------------------------- | | And | findByLastnameAndFirstname | … where x\.lastname = ?1 and x\.firstname = ?2 | | Or | findByLastnameOrFirstname | … where x\.lastname = ?1 or x\.firstname = ?2 | | Is,Equals | findByFirstname,findByFirstnameIs,findByFirstnameEquals | … where x\.firstname = ?1 | | Between | findByStartDateBetween | … where x\.startDate between ?1 and ?2 | | LessThan | findByAgeLessThan | … where x\.age < ?1 | | LessThanEqual | findByAgeLessThanEqual | … where x\.age <= ?1 | | GreaterThan | findByAgeGreaterThan | … where x\.age > ?1 | | GreaterThanEqual | findByAgeGreaterThanEqual | … where x\.age >= ?1 | | After | findByStartDateAfter | … where x\.startDate > ?1 | | Before | findByStartDateBefore | … where x\.startDate < ?1 | | IsNull | findByAgeIsNull | … where x\.age is null | | IsNotNull,NotNull | findByAge\(Is\)NotNull | … where x\.age not null | | Like | findByFirstnameLike | … where x\.firstname like ?1 | | NotLike | findByFirstnameNotLike | … where x\.firstname not like ?1 | | StartingWith | findByFirstnameStartingWith | … where x\.firstname like ?1(附加引數繫結 %) | | EndingWith | findByFirstnameEndingWith | … where x\.firstname like ?1(與前置繫結的引數 %) | | Containing | findByFirstnameContaining | … where x\.firstname like ?1(引數繫結包裝 %) | | OrderBy | findByAgeOrderByLastnameDesc | … where x\.age = ?1 order by x\.lastname desc | | Not | findByLastnameNot | … where x\.lastname <> ?1 | | In | findByAgeIn\(Collection ages\) | … where x\.age in ?1 | | NotIn | findByAgeNotIn\(Collection ages\) | … where x\.age not in ?1 | | True | findByActiveTrue\(\) | … where x\.active = true | | False | findByActiveFalse\(\) | … where x\.active = false | | IgnoreCase | findByFirstnameIgnoreCase | … where UPPER\(x\.firstame\) = UPPER\(?1\) | | Top 或者 First | findTopByNameAndAge,findFirstByNameAndAge | where … limit 1 | | Topn 或者 Firstn | findTop2ByNameAndAge,findFirst2ByNameAndAge | where … limit 2 | | Distinct | findDistinctPeopleByLastnameOrFirstname | select distinct …\. | | count | countByAge,count | select count\(\*\)