一文搞定 Spring Data JPA

Fururur發表於2020-07-07

Spring Data JPA 是在 JPA 規範的基礎上進行進一步封裝的產物,和之前的 JDBC、slf4j 這些一樣,只定義了一系列的介面。具體在使用的過程中,一般接入的是 Hibernate 的實現,那麼具體的 Spring Data JPA 可以看做是一個物件導向的 ORM。雖然後端實現是 Hibernate,但是實際配置和使用比 Hibernate 簡單不少,可以快速上手。如果業務不太複雜,個人覺得是要比 Mybatis 更簡單好用。

本文就簡單列一下具體的知識點,詳細的用法可以見參考文獻中的部落格。本文具體會涉及到 JPA 的一般用法、事務以及對應 Hibernate 需要掌握的點。

基本使用

  1. 建立專案,選擇相應的依賴。一般不直接用 mysql 驅動,而選擇連線池。
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
   <groupId>mysql</groupId>
   <artifactId>mysql-connector-java</artifactId>
   <scope>runtime</scope>
</dependency>
<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>druid-spring-boot-starter</artifactId>
   <version>1.1.18</version>
</dependency>
  1. 配置全域性 yml 檔案。
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 的引數。
  1. 建立實體類並新增 JPA 註解
@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;
}
  1. 建立對應的 Repository

實現 JpaRepository 介面,生成基本的 CRUD 操作樣板程式碼。並且可根據 Spring Data JPA 自帶的 Query Lookup Strategies 建立簡單的查詢操作,在 IDEA 中輸入 findBy 等會有提示。

public interface IUserRepository extends JpaRepository<User,Long> {
    List<User> findByName(String name);
    List<User> findByAgeAndCreateTimeBetween(Integer age, LocalDateTime createTime, LocalDateTime createTime2);
}

查詢

預設方法

Repository 繼承了 JpaRepository 後會有一系列基本的預設 CRUD 方法,例如:

List<T> findAll();
Page<T> findAll(Pageable pageable);
T getOne(ID id);
T S save(T entity);
void deleteById(ID id);

宣告式查詢

Repository 繼承了 JpaRepository 後,可在介面中定義一系列方法,它們一般以 findBycountBydeleteByexistsBy 等開頭,如果使用 IDEA,輸入以下關鍵字後會有響應的提示。例如:

public interface IUserRepository extends JpaRepository<User,Integer>{
    User findByUsername(String username);
    Integer countByDept(String dept);
}

對於一些單表多欄位查詢,使用這種方式就非常舒服了,而且完全 oop 思想,不需要思考具體的 SQL 怎麼寫。但有個問題,欄位多了之後方法名會很長,呼叫的時候會比較難受,這個時候可以利用 jdk8 的特性將它縮短,當然這種情況也可以直接用 @Query 寫 HQL 或 SQL 解決。

User findFirstByEmailContainsIgnoreCaseAndField1NotNullAndField2NotNull(final String email);

default User getByEmail(final String email) {
	return findFirstByEmailContainsIgnoreCaseAndField1NotNullAndField2NotNull(email);
}

常見的操作可見 [附錄 - 支援的方法關鍵詞](### 支援的方法關鍵詞)

使用註解和 SQL

@Transactional(readOnly = true)
public interface UserRepository extends JpaRepository<User, Long> {
	@Query(nativeQuery = true, value = "select * from user where tel = ?1")
	List<User> getUser(String tel);

	@Modifying
	@Transactional
	@Query("delete from User u where u.active = false")
	void deleteInactiveUsers();
}
  1. @Query 中可寫 HQL 和 SQL,如果是 SQL,則 nativeQuery = true

複雜查詢 Specification

// 複雜查詢,建立 Specification
private Page<OrderInfoEntity> getOrderInfoListByConditions(String tel, int pageSize, int pageNo, String beginTime, String endTime) {
    Specification<OrderInfoEntity> specification = new Specification<OrderInfoEntity>() {
        @Override
        public Predicate toPredicate(Root<OrderInfoEntity> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
            List<Predicate> 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<UserProject> 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 的使用

談談幾點用法上的總結:

  1. 持久層方法上繼承 JpaRepository,對應實現類 SimpleJpaRepository 中包含 @Transactional(readOnly = true) 註解,因此預設持久層中的 CRUD 方法均新增了事務
  2. 申明式事務更常用的是在 service 層中的方法上,一般會呼叫多個 Repository 來完成一項業務邏輯,過程中可能會對多張資料表進行操作,出現異常一般需要級聯回滾。一般操作,直接在 Serivce 層方法中新增 @Transactional 即可,預設使用資料的隔離級別,預設所有 Repository 方法加入 Service 層中的事務。
  3. @Transactional 註解中最核心的兩個引數是 propagationisolation。前者用於控制事務的傳播行為,指定小事務加入大事務還是所有事務均單獨執行等;後者用於控制事務的隔離級別,預設和 MySQL 保持一致,為不可重複讀。我們也可以通過這個欄位手動修改單個事務的隔離級別。具體的應用場景可見我另一篇部落格 談談事務的隔離性及在開發中的應用
  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 上不需要。

@Entity
public class Repository{
  @OneToMany(cascade = {CascadeType.ALL})
  @JoinColumn(name = "repo_id")
  private List<Goods> list;
}

public class Goods{
}

具體可參考:@OneToMany、@ManyToOne 以及 @ManyToMany 講解(五)

JPA 的這幾個註解和 Hibernate 的關聯度比較大,而且一般適合於 code first 的形式,也就是說先有實體類後生成資料庫。在這裡我並不建議沒有學習過 Hibernate 直接上手 Spring Data JPA 的人去使用這些註解,因為一旦加上關係註解後,從查詢的角度雖然方便了,但是涉及到一些級聯的操作,例如刪除、修改等操作,容易採坑。需要額外去了解 Hibernate 的快取重新整理機制。

多資料來源

預設單資料來源的情況下,我們只需要將自己的 Repository 實現 JpaRepository 介面即可,通過 Spring Boot 的 Auto Configuration 會自動幫我們注入所需的 Bean,例如 LocalContainerEntityManagerFactoryBeanEntityManager DataSource

但是在多資料來源的情況下,就需要根據配置檔案去條件化建立這些 Bean 了。

  1. 配置檔案新增多個資料來源資訊
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
  1. 資料來源 bean 注入
@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();
    }
}
  1. 注入 JPA 相關的 bean(一個資料來源一個配置檔案)
@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<String, Object> 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());
    }
}
  1. 在之前配置的對應的包中新增相應的 repository 就可以了。如果資料來源資料庫是相同的,可實現一個主的 repository,其餘繼承一下。
@Primary
@Qualifier("volumeHZRepository")
public interface IVolumeRepository extends IBaseRepository<Volume, Integer> {
    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

需要特別關注的是持久化狀態的物件,這類物件一般是從資料庫中查詢出來的,同時會存在 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 即可。

@EqualsAndHashCode.Exclude
@ManyToMany(fetch = FetchType.LAZY,cascade = {CascadeType.PERSIST})
private Set<User> membersSet;

Lombok.hashCode issue with “java.lang.StackOverflowError: null”

  1. Spring boot JPA:Unknown entity 解決方法

在採用兩個大括號初始化物件後,再呼叫 JPA 的 save 方法時會丟擲 Unknown entity 這個異常,這是 JPA 無法正確識別匿名內部類導致的。

解決方法:手動 new 一個物件再呼叫 set 方法賦值。

Spring boot JPA:Unknown entity 解決方法

  1. 使用關係註解時產生的 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 的關係註解的使用,以及各種級聯操作的型別理解不深,會存在一些隱患。

參考文獻

附錄

支援的方法關鍵詞

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(*)

相關文章