springDataJpa 最佳實踐

klblog發表於2019-11-12

前言

Spring Data Jpa框架的目標是顯著減少實現各種永續性儲存的資料訪問層所需的樣板程式碼量。Spring Data Jpa儲存庫抽象中的中央介面是Repository。它需要領域實體類以及領域實體ID型別作為型別引數來進行管理。該介面主要用作標記介面,以捕獲要使用的型別並幫助您發現擴充套件該介面的介面。CrudRepository、JpaRepository是更具體的資料操作抽象,一般我們在專案中使用的時候定義我們的領域介面然後繼承CrudRepository或JpaRepository即可實現實現基礎的CURD方法了,但是這種用法有侷限性,不能處理超複雜的查詢,而且稍微複雜的查詢程式碼寫起來也不是很優雅,所以下面看看怎麼最優雅的解決這個問題。

擴充套件介面用法

/**
 * @author: kl @kailing.pub
 * @date: 2019/11/11
 */
@Repository
public interface SendLogRepository extends JpaRepository<SendLog,Integer> {

    /**
     * 派生的通過解析方法名稱的查詢
     * @param templateName
     * @return
     */
    List<SendLog> findSendLogByTemplateName(String templateName);

    /**
     * HQL
     * @param templateName
     * @return
     */
    @Query(value ="select SendLog from  SendLog s where s.templateName = :templateName")
    List<SendLog> findByTempLateName(String templateName);

    /**
     * 原生sql
     * @param templateName
     * @return
     */
    @Query(value ="select s.* from  sms_sendlog s where s.templateName = :templateName",nativeQuery = true)
    List<SendLog> findByTempLateNameNative(String templateName);
}

優點:

  • 1、這種擴充套件介面的方式是最常見的用法,繼承JpaRepository介面後,立馬擁有基礎的CURD功能
  • 2、還可以通過特定的方法名做解析查詢,這個可以算spring Data Jpa的最特殊的特性了。而且主流的IDE對這種使用方式都有比較好的自動化支援,在輸入要解析的方法名時會給出提示。
  • 3、可以非常方便的以註解的形式支援HQL和原生SQL

缺陷:

  • 1、複雜的分頁查詢支援不好

缺陷就一條,這種擴充套件介面的方式要實現複雜的分頁查詢,有兩種方式,而且這兩種方式程式碼寫起來都不怎麼優雅,而且會把大量的條件拼接邏輯寫在呼叫查詢的service層。

  • 第一種例項查詢(Example Query)方式:

    public void testExampleQuery() {
        SendLog log = new SendLog();
        log.setTemplateName("kl");
        /*
         * 注意:withMatcher方法的propertyPath引數值填寫領域物件的欄位值,而不是實際的表欄位
         */
        ExampleMatcher matcher = ExampleMatcher.matching()
                .withMatcher("templateName", match -> match.contains());
        Example example = Example.of(log, matcher);
    
        Pageable pageable = PageRequest.of(0, 10);
        Page<SendLog> logPage = repository.findAll(example, pageable);
    }

    上面程式碼實現的語義是模糊查詢templateName等於"kl"的記錄並分頁,乍一看這個程式碼還過得去哈,其實當查詢的條件多一點,這種程式碼就會變得又臭又長,而且只支援基礎的字串型別的欄位查詢,如果查詢條件有時間篩選的話就不支援了,在複雜點多表關聯的話就更GG了,所以這種方式不合格直接上黑名單了。

  • 第二種繼承JpaSpecificationExecutor方式:

JPA 2引入了一個標準API,您可以使用它來以程式設計方式構建查詢。Spring Data JPA提供了使用JPA標準API定義此類規範的API。這種方式首先需要繼承JpaSpecificationExecutor介面,下面我們用這種方式實現和上面相同語義的查詢:

    public void testJpaSpecificationQuery() {
        String templateName = "kk";
        Specification specification = (Specification) (root, query, criteriaBuilder) -> {
            Predicate predicate = criteriaBuilder.like(root.get("templateName"),templateName);
            query.where(predicate);
            return predicate;
        };

        Pageable pageable = PageRequest.of(0, 2);
        Page<SendLog> logPage = sendLogRepository.findAll(specification, pageable);
    }

這種方式顯然更對味口了吧,而且也支援複雜的查詢條件拼接,比如日期等。唯一的缺憾是領域物件的屬性字串需要手寫了,而且介面只會提供findAll(@Nullable Specification spec, Pageable pageable)方法,各種複雜查詢邏輯拼接都要寫在service層。對於架構分層思想流行了這麼多年外加強迫症的人來說實在是不能忍,如果單獨封裝一個Dao類編寫複雜的查詢又顯的有點多餘和臃腫

Spring Data Jpa最佳實踐

在詳細介紹最佳實踐前,先思考和了解一個東西,Spring Data Jpa是怎麼做到繼承一個介面就能實現各種複雜查詢的呢?這裡其實是一個典型的代理模式的應用,只要繼承了最底層的Repository介面,在應用啟動時就會幫你生成一個代理例項,而真正的目標類才是最終執行查詢的類,這個類就是:SimpleJpaRepository,它實現了JpaRepository、JpaSpecificationExecutor的所有介面,所以只要基於SimpleJpaRepository定製Repository基類,就能擁有繼承介面一樣的查詢功能,而且可以在實現類裡編寫複雜的查詢方法了。

一、繼承SimpleJpaRepository實現類

/**
 * @author: kl @kailing.pub
 * @date: 2019/11/8
 */
public abstract class BaseJpaRepository<T, ID> extends SimpleJpaRepository<T, ID> {

    public EntityManager em;

    BaseJpaRepository(Class<T> domainClass, EntityManager em) {
        super(domainClass, em);
        this.em = em;
    }
}

構造一個SimpleJpaRepository例項,只需要一個領域物件的型別,和EntityManager 例項即可,EntityManager在Spring的上下文中已經有了,會自動注入。領域物件型別在具體的實現類中注入即可。如:

/**
 * @author: kl @kailing.pub
 * @date: 2019/11/11
 */
@Repository
public class SendLogJpaRepository extends BaseJpaRepository<SendLog,Integer> {

    public SendLogJpaRepository(EntityManager em) {
        super(SendLog.class, em);
    }
    /**
     * 原生查詢
     * @param templateName
     * @return
     */
    public SendLog findByTemplateName(String templateName){
        String sql = "select * from send_log where templateName = :templateName";
        Query query =em.createNativeQuery(sql);
        query.setParameter("templateName",templateName);
        return (SendLog) query.getSingleResult();
    }

    /**
     * hql查詢
     * @param templateName
     * @return
     */
    public SendLog findByTemplateNameNative(String templateName){
        String hql = "from SendLog where templateName = :templateName";
        TypedQuery<SendLog> query =em.createQuery(hql,SendLog.class);
        query.setParameter("templateName",templateName);
       return query.getSingleResult();
    }

    /**
     *  JPASpecification 實現複雜分頁查詢
     * @param logDto
     * @param pageable
     * @return
     */
    public Page<SendLog> findAll(SendLogDto logDto,Pageable pageable) {
        Specification specification = (Specification) (root, query, criteriaBuilder) -> {
            Predicate predicate = criteriaBuilder.conjunction();
            if(!StringUtils.isEmpty(logDto.getTemplateName())){
                predicate.getExpressions().add( criteriaBuilder.like(root.get("templateName"),logDto.getTemplateName()));
            }
            if(logDto.getStartTime() !=null){
                predicate.getExpressions().add(criteriaBuilder.greaterThanOrEqualTo(root.get("createTime").as(Timestamp.class),logDto.getStartTime()));
            }
            query.where(predicate);
            return predicate;
        };
        return  findAll(specification, pageable);
    }
}

通過繼承BaseJpaRepository,使SendLogJpaRepository擁有了JpaRepository、JpaSpecificationExecutor介面中定義的所有方法功能。而且基於抽象基類中EntityManager例項,也可以非常方便的編寫HQL和原生SQL查詢等。最賞心悅目的是不僅擁有了最基本的CURD等功能,而且超複雜的分頁查詢也不分家了。只是JpaSpecification查詢方式還不是特別出彩,下面繼續最佳實踐

二、整合QueryDsl結構化查詢

Querydsl是一個框架,可通過其流暢的API來構造靜態型別的類似SQL的查詢。這是Spring Data Jpa文件中對QueryDsl的描述。Spring Data Jpa對QueryDsl的擴充套件支援的比較好,基本可以無縫整合使用。Querydsl定義了一套和JpaSpecification類似的介面,使用方式上也類似,由於QueryDsl多了一個maven外掛,可以在編譯期間生成領域物件操作實體,所以在拼接複雜的查詢條件時相比較JpaSpecification顯的更靈活好用,特別在關聯到多表查詢的時候。下面看下怎麼整合:

1、快速整合

因為之前有寫過最簡單的QueryDsl整合方式,所以這裡就不在贅述了,具體參見《Querydsl結構化查詢之jpa》,

2、豐富BaseJpaRepository基類

/**
 * @author: kl @kailing.pub
 * @date: 2019/11/8
 */
public abstract class BaseJpaRepository<T, ID> extends SimpleJpaRepository<T, ID> {

    public EntityManager em;
    protected final QuerydslJpaPredicateExecutor<T> jpaPredicateExecutor;

    BaseJpaRepository(Class<T> domainClass, EntityManager em) {
        super(domainClass, em);
        this.em = em;
        this.jpaPredicateExecutor = new QuerydslJpaPredicateExecutor<>(JpaEntityInformationSupport.getEntityInformation(domainClass, em), em, SimpleEntityPathResolver.INSTANCE, getRepositoryMethodMetadata());
    }
}

在BaseJpaRepository基類中新增了QuerydslJpaPredicateExecutor例項,它是Spring Data Jpa基於QueryDsl的一個實現。用來執行QueryDsl的Predicate相關查詢。整合QueryDsl後,複雜分頁查詢的畫風就變的更加清爽了,如:

    /**
     * QSendLog實體是QueryDsl外掛自動生成的,外掛會自動掃描加了@Entity的實體,生成一個用於查詢的EntityPath類
     */
    private  final  static QSendLog sendLog = QSendLog.sendLog;

    public Page<SendLog> findAll(SendLogDto logDto, Pageable pageable) {
        BooleanExpression expression = sendLog.isNotNull();
        if (logDto.getStartTime() != null) {
            expression = expression.and(sendLog.createTime.gt(logDto.getStartTime()));
        }
        if (!StringUtils.isEmpty(logDto.getTemplateName())) {
            expression = expression.and(sendLog.templateName.like("%"+logDto.getTemplateName()+"%"));
        }
        return jpaPredicateExecutor.findAll(expression, pageable);
    }

到目前為止,實現相同的複雜分頁查詢,程式碼已經非常的清爽和優雅了,在複雜的查詢在這種模式下也變的非常的清晰。但是,這還不是十分完美的。還有兩個問題需要解決下:

  • QuerydslJpaPredicateExecutor實現的方法不支援分頁查詢同時又有欄位排序。下面是它的介面定義,可以看到,要麼分頁查詢一步到位但是沒有排序,要麼排序查詢返回List列表自己封裝分頁。

    public interface QuerydslPredicateExecutor<T> {
    Optional<T> findOne(Predicate predicate);
    Iterable<T> findAll(Predicate predicate);
    Iterable<T> findAll(Predicate predicate, Sort sort);
    Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>... orders);
    Iterable<T> findAll(OrderSpecifier<?>... orders);
    Page<T> findAll(Predicate predicate, Pageable pageable);
    long count(Predicate predicate);
    boolean exists(Predicate predicate);
    }
  • 複雜的多表關聯查詢QuerydslJpaPredicateExecutor不支援

3、最終的BaseJpaRepository形態

Spring Data Jpa對QuerDsl的支援畢竟有限,但是QueryDsl是有這種功能的,像上面的場景就需要特別處理了。最終改造的BaseJpaRepository如下:

/**
 * @author: kl @kailing.pub
 * @date: 2019/11/8
 */
public abstract class BaseJpaRepository<T, ID> extends SimpleJpaRepository<T, ID> {

    protected final JPAQueryFactory jpaQueryFactory;
    protected final QuerydslJpaPredicateExecutor<T> jpaPredicateExecutor;
    protected final EntityManager em;
    private final EntityPath<T> path;
    protected final Querydsl querydsl;

    BaseJpaRepository(Class<T> domainClass, EntityManager em) {
        super(domainClass, em);
        this.em = em;
        this.jpaPredicateExecutor = new QuerydslJpaPredicateExecutor<>(JpaEntityInformationSupport.getEntityInformation(domainClass, em), em, SimpleEntityPathResolver.INSTANCE, getRepositoryMethodMetadata());
        this.jpaQueryFactory = new JPAQueryFactory(em);
        this.path = SimpleEntityPathResolver.INSTANCE.createPath(domainClass);
        this.querydsl = new Querydsl(em, new PathBuilder<T>(path.getType(), path.getMetadata()));
    }

    protected Page<T> findAll(Predicate predicate, Pageable pageable, OrderSpecifier<?>... orders) {
        final JPAQuery countQuery = jpaQueryFactory.selectFrom(path);
        countQuery.where(predicate);
        JPQLQuery<T> query = querydsl.applyPagination(pageable, countQuery);
        query.orderBy(orders);
        return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchCount);
    }
}

新增了findAll(Predicate predicate, Pageable pageable, OrderSpecifier<?>... orders)方法,用於支援複雜分頁查詢的同時又有欄位排序的查詢場景。其次的改動是引入了JPAQueryFactory例項,用於多表關聯的複雜查詢。使用方式如下:

    /**
     * QSendLog實體是QueryDsl外掛自動生成的,外掛會自動掃描加了@Entity的實體,生成一個用於查詢的EntityPath類
     */
    private  final  static QSendLog qSendLog = QSendLog.sendLog;
    private  final static QTemplate qTemplate = QTemplate.template;

    public Page<SendLog> findAll(SendLogDto logDto, Template template, Pageable pageable) {
        JPAQuery  countQuery = jpaQueryFactory.selectFrom(qSendLog).leftJoin(qTemplate);
        countQuery.where(qSendLog.templateCode.eq(qTemplate.code));
        if(!StringUtils.isEmpty(template.getName())){
            countQuery.where(qTemplate.name.eq(template.getName()));
        }
        JPQLQuery query = querydsl.applyPagination(pageable, countQuery);
        return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchCount);
    }

三、整合p6spy列印執行的sql

上面的功能以及十分完美了,但是談到最佳實踐似乎少了一個列印SQL的功能。在使用Jpa的結構化語義構建複雜查詢時,經常會因為各種原因導致查詢的結果集不是自己想要的,但是又沒法排查,因為不知道最終執行的sql是怎麼樣的。Spring Data Jpa也有列印sql的功能,但是比較雞肋,它列印的是沒有替換查詢引數的sql,沒法直接複製執行。所以這裡推薦一個工具p6spy,p6spy是一個列印最終執行sql的工具,而且可以記錄sql的執行耗時。使用起來也比較方便,簡單三步整合:

  • 1、引入依賴
            <dependency>
                <groupId>p6spy</groupId>
                <artifactId>p6spy</artifactId>
                <version>${p6spy.version}</version>
            </dependency>
  • 2、修改資料來源連結字串
    jdbc:mysql://127.0.0.1:3306 改成 jdbc:p6spy:mysql://127.0.0.1:3306
  • 3、新增配置spy.propertis配置
    appender=com.p6spy.engine.spy.appender.Slf4JLogger
    logMessageFormat=com.p6spy.engine.spy.appender.CustomLineFormat
    customLogMessageFormat = executionTime:%(executionTime)| sql:%(sqlSingleLine)

    這個是最簡化的自定義列印的配置,更多配置可參考:https://p6spy.readthedocs.io/en/latest/con...

    結語

    最後的BaseJpaRepository功能上基本滿足了所有的查詢需求,又做了基礎查詢和複雜查詢的不分離,不至於把大量的複雜查詢拼接邏輯寫到service層,或者是新建的複雜查詢類裡。徹底解決了文首提出的那些問題。基於QueryDsl的複雜查詢程式碼邏輯清晰,結構優雅,極力推薦使用。最後,在安利下p6spy,一個非常實用的列印sql的工具,可以幫助排查分析JPA最終生成執行的sql語句,其列印的sql語句可以直接複製到mysql管理工具中執行的。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

kl