Spring Boot + Querydsl 框架,大大簡化複雜查詢操作!!

概述

本篇博客主要將介紹的是利用spring query dsl框架實現的服務端查詢解析和實現介紹。

查詢功能是在各種應用程序裏面都有應用,且非常重要的功能。用戶直接使用的查詢功能往往是在我們做好的UI界面上進行查詢,UI會將查詢請求發給查詢實現的服務器,或者專門負責實現查詢的一個組件。市場上有專門做查詢的框架,其中比較出名,應用也比較廣泛的是elasticsearch。

定義查詢請求

對於服務端來說,前端UI發送過來的查詢請求必然是按一定規則組織起來的,這樣的規則後端必須能夠支持和解析。換一種說法就是服務調用者和服務發佈者之間需要遵循同一個規範纔可以。百度的UI查詢是這樣定義的:

在上圖中加了藍色下劃線的地方即爲我們在百度當中搜索的字符串內容,可以發現,百度的實現是將搜索的內容當做了http請求的url的參數來處理的,用了一個q作爲key,q後面的內容就是所查詢的內容。

google的實現是類似的,如下圖所示:

對於google和百度這樣的只有一個搜索框的查詢界面,這樣處理是比較合理的,也不是整個查詢實現最關鍵的部分。更爲關鍵的是後續服務器將這個查詢內容進行了怎樣的處理。對於別的一些產品來說,可能需要對某些關鍵字進行單獨的查詢,這個時候肯定就不是一個搜索框能個滿足的需求了。

總的來說,我們可以有如下的方式來組織一個查詢

google-like查詢

這種查詢典型的應用是一個查詢框,什麼都可以查的情形,例如google和百度。對於這樣的查詢需求來說,在構建查詢請求時只需將查詢的內容放在http請求的的參數裏面即可。

這樣的查詢解析是非常方便的,難度和需要考慮得事情在於要講查詢的內容放到哪些地方去查詢。從數據庫的層面來說就是要去哪些數據庫的哪些表去查詢。

特定字段的類sql查詢

這種查詢是指定某個字段,然後採用類似於sql語句的寫法進行查詢,各種查詢條件以一定的形式組織在一起,發給服務器進行解析。這樣的查詢對服務器解析查詢的能力要求更高,它提供了一些更加具體的查詢條件。

例如我們以冒號表示等於,則一個查詢字符串的形式是:

name:bill

這個查詢的意思就是查詢名字name等於bill的記錄。

我們也可以將多個條件拼接在一起,讓他們直接用邏輯關係組合在一起,例如或者和並且的邏輯關係。例如:

name:bill AND city:LA

或者下面這種或者的關係:

name:bill OR city:LA

上面的查詢語句意味着我們的前後臺要定義一套自己的查詢邏輯和架構,並且解析它,並將它轉換爲正確的查詢。若我們想實現靈活的查詢,則上面的查詢語句在符合規則的前提下應當是可以自由組合的。怎麼做取決於我們的實際需求。如果一個寫死的查詢關鍵字就能滿足我們的需求,則在當前那個時期自然也是合理的。

但是從靈活性角度,技術角度,實現成靈活的可解析的,顯然是我們更想要的功能。最靈活的當然就是sql語句能支持怎樣的查詢,我們都能支持對應的查詢寫法,但是這對服務器的解析邏輯就有了更加高的要求,尤其是當主表子表混在一起查詢之後,會更加複雜

使用Spring Data Querydsl

什麼是Querydsl呢?Querydsl是一個框架,它可以通過它提供的的API幫助我們構建靜態類型的SQL-like查詢,也就是在上面我們提到的組織查詢方式。可以通過諸如Querydsl之類的流暢API構造查詢。

Querydsl是出於以類型安全的方式維護HQL查詢的需要而誕生的。 HQL查詢的增量構造需要String連接,這導致難以閱讀的代碼。通過純字符串對域類型和屬性的不安全引用是基於字符串的HQL構造的另一個問題。

隨着域模型的不斷變化,類型安全性在軟件開發中帶來了巨大的好處。域更改直接反映在查詢中,而查詢構造中的自動完成功能使查詢構造更快,更安全。

用於Hibernate的HQL是Querydsl的第一個目標語言,如今querydsl支持JPA,JDO,JDBC,Lucene,Hibernate Search,MongoDB,Collections和RDFBean作爲它的後端。

其官方網站在這裏:http://querydsl.com/

推薦一個 Spring Boot 基礎教程及實戰示例:

https://github.com/javastacks/spring-boot-best-practice

Querydsl和spring有什麼關係呢?幾個Spring Data的模塊通過QuerydslPredicateExecutor提供了與Querydsl的集成,如以下示例所示:

public interface QuerydslPredicateExecutor<T> {
//查找並返回與Predicate匹配的單個entity。
  Optional<T> findById(Predicate predicate);
//查找並返回與Predicate匹配的所有entity
  Iterable<T> findAll(Predicate predicate);
//返回與Predicate匹配的數量。
  long count(Predicate predicate);
//返回是否存在與Predicate匹配的entity。
  boolean exists(Predicate predicate);

  // … more functionality omitted.
}

Predicate就是我們需要傳入的一個查詢的抽象。

在spring當中使用Querydsl,只需要在spring的repository接口繼承QuerydslPredicateExecutor,如以下示例所示:

interface UserRepository extends CrudRepository<User, Long>, QuerydslPredicateExecutor<User> {
}

在定義了上面的這個接口之後,我們就可以使用Querydsl Predicate編寫type-safe的查詢,如以下示例所示:

Predicate predicate = user.firstname.equals("dave")
 .and(user.lastname.startsWith("mathews"));

userRepository.findAll(predicate);

上面的代碼構建出的predicate體現在sql語句裏的話就是這樣的: where firstname = 'dave' and lastname ='mathews%'。這就是所謂的類sql的查詢,用起來非常的直觀。

因此,我們可以將我們接收到的查詢請求,轉化爲對應的predicte,且從技術上講,只要predict支持的查詢拼接我們都能支持,難點只在於如何解析查詢請求,以及如何將他們轉換爲對應的predicate.

利用Spring Query DSL實現動態查詢

下面是使用spring和Querydsl實現動態查詢的一個例子.

現在假設我們有Model類如下:

public class Student {

    private String id;

    private String gender;

    private String firstName;

    private String lastName;

    private Date createdAt;

    private Boolean isGraduated;

}

我們希望可以實現該類所有字段直接自由組合進行查詢,且可以按照與和或的邏輯進行查詢。且我們約定用冒號表示等於,例如:

firstname:li AND lastname:hua

firstname:li OR lastname:hua

firstname:li AND lastname:hua AND gender:male

上面的查詢都比較清晰,解析不會有太大難度,下面我們來看這樣一個查詢:

firstname:li OR lastname:hua AND gender:male

這個查詢的問題在於作爲邏輯與的gender查詢,到底是隻和前面一個條件進行與操作,還是與前面兩個條件一起進行一個與操作,顯然與的條件往往是作爲filter的功能出現的。

因此我們應當將其看作整個其他條件的與操作,因此我們需要先將前面的條在組合在一起,例如,我們可以使用括號表示這個邏輯,那麼查詢就會變成:

(firstname:li AND lastname:hua) AND gender:male

這下邏輯就變得清晰了,難題就在於怎麼解析了

public class QueryAnalysis{
    private static final String EMPTY_STRING = "";

    private static final String BLANK_STRING = " ";

    private static final String COLON = ":";

    private static final String BP_CATEGORY_CODE = "categoryCode";

    private static final String OPEN_PARENTTHESIS = "(";

    private static final String CLOSE_PARENTTHESIS = ")";

    private static final String QUERY_REGEX = "([\\w.]+?)(:|<|>|!:)([^ ]*)";
    //it has to lie between two blanks
    private static final String QUERY_LOGIC_AND = " AND ";

    private void generateQueryBuilderWithQueryString(PredicateBuilder builder, String q,
            List<String> queryStringList) {
        StringBuilder stringBuilder = new StringBuilder();
        String queryTerm = q;
        if (q == null) {
            return;
        }

        if (!q.contains(" AND ") && !q.startsWith("(") && !q.endsWith(")")) {
            queryTerm = stringBuilder.append("(").append(q).append(")").toString();
        }

        Map<String, Matcher> matcherMap = getMatcherWithQueryStr(queryTerm);
        Matcher matcherOr = matcherMap.get("matcherOr");
        Matcher matcherAnd = matcherMap.get("matcherAnd");

        while (matcherOr.find()) {

            builder.withOr(matcherOr.group(1), matcherOr.group(2), matcherOr.group(3));
        }
        while (matcherAnd.find()) {

            builder.withAnd(matcherAnd.group(1), matcherAnd.group(2), matcherAnd.group(3));
            isSearchParameterValid = true;
        }
   }

    private static Map<String, Matcher> getMatcherWithQueryStr(String q) {
        StringBuilder stringBuilder = new StringBuilder();
        Pattern pattern = Pattern.compile(QUERY_REGEX);
        // inside the subString is "or",outside them are "and"
        String[] queryStringArraySplitByAnd = q.split(QUERY_LOGIC_AND);
        String queryStringOr = EMPTY_STRING;
        String queryStringAnd = EMPTY_STRING;
        for (String string : queryStringArraySplitByAnd) {
            if (string.trim().startsWith(OPEN_PARENTTHESIS) && string.trim().endsWith(CLOSE_PARENTTHESIS)) {
                //only support one OR sentence
                queryStringOr = string.trim().substring(1,string.length()-1);
            } else {
                queryStringAnd = stringBuilder.append(string).append(BLANK_STRING).toString();
            }
        }

        String queryStringAndTrim = queryStringAnd.trim();
        if(queryStringAndTrim.startsWith(OPEN_PARENTTHESIS) && queryStringAndTrim.endsWith(CLOSE_PARENTTHESIS)){
            queryStringAnd = queryStringAndTrim.substring(1,queryStringAndTrim.length()-1);
        }

        Matcher matcherOr = pattern.matcher(queryStringOr);
        Matcher matcherAnd = pattern.matcher(queryStringAnd);

        Map<String, Matcher> matcherMap = new ConcurrentHashMap<>();
        matcherMap.put("matcherOr", matcherOr);
        matcherMap.put("matcherAnd", matcherAnd);
        return matcherMap;
    }
}

Predicate的邏輯如下:

import java.util.ArrayList;
import java.util.List;

import com.querydsl.core.types.dsl.BooleanExpression;

/**
 * This class is mainly used to classify all the query parameters
 */
public class PredicateBuilder {

    private static final String BLANK_STRING = " ";

    private static final String TILDE_STRING = "~~";

    private List<SearchCriteria> paramsOr;

    private List<SearchCriteria> paramsAnd;

    private BusinessPartnerMessageProvider messageProvider;

    public PredicateBuilder(BusinessPartnerMessageProvider messageProvider){
        paramsOr = new ArrayList<>();
        paramsAnd = new ArrayList<>();
    }

    public PredicateBuilder withOr(
            String key, String operation, Object value) {
        String keyAfterConverted = keyConverter(key);
        Object valueAfterConverted = value.toString().replaceAll(TILDE_STRING,BLANK_STRING).trim();
        paramsOr.add(new SearchCriteria(keyAfterConverted, operation, valueAfterConverted));
        return this;
    }

    public PredicateBuilder withAnd(
            String key, String operation, Object value) {
        String keyAfterConverted = keyConverter(key);
        Object valueAfterConverted = value.toString().replaceAll(TILDE_STRING,BLANK_STRING).trim();
        paramsAnd.add(new SearchCriteria(keyAfterConverted, operation, valueAfterConverted));
        return this;
    }

    protected String keyConverter(String key){
        return key;
    }

    public BooleanExpression buildOr(Class classType) {

        return handleBPBooleanExpressionOr(classType);
    }

    public BooleanExpression buildAnd(Class classType) {

        return handleBPBooleanExpressionAnd(classType);
    }

    private BooleanExpression handleBPBooleanExpressionOr(Class classType) {

        if (paramsOr.isEmpty()) {
            return null;
        }
        return buildBooleanExpressionOr(paramsOr, classType);

    }

    private BooleanExpression handleBPBooleanExpressionAnd(Class classType) {
        if (paramsAnd.isEmpty()) {
            return null;
        }
        return buildBooleanExpressionAnd(paramsAnd, classType);

    }

    private BooleanExpression buildBooleanExpressionOr(List<SearchCriteria> paramsOr, Class classType){
        List<BooleanExpression> predicates = new ArrayList<>();
        BooleanExpressionBuilder predicate;
        for (SearchCriteria param : paramsOr) {

            predicate = new BooleanExpressionBuilder(param, messageProvider);

            BooleanExpression exp = predicate.buildPredicate(classType);

            if (exp != null) {
                predicates.add(exp);
            }
        }
        BooleanExpression result = null;
        if(!predicates.isEmpty()) {
            result = predicates.get(0);
            for (int i = 1; i < predicates.size(); i++) {
                result = result.or(predicates.get(i));
            }
        }
        return result;
    }

    private BooleanExpression buildBooleanExpressionAnd(List<SearchCriteria> paramsAnd, Class classType){
        List<BooleanExpression> predicates = new ArrayList<>();
        BooleanExpressionBuilder predicate;
        for (SearchCriteria param : paramsAnd) {

            predicate = new BooleanExpressionBuilder(param, messageProvider);

            BooleanExpression exp = predicate.buildPredicate(classType);

            if (exp != null) {
                predicates.add(exp);
            }
        }
        BooleanExpression result = null;
        if(!predicates.isEmpty()) {
            result = predicates.get(0);
            for (int i = 1; i < predicates.size(); i++) {
                result = result.and(predicates.get(i));
            }
        }
        return result;
    }

}

BooleanExpressionBuilder的邏輯如下:

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.TimeZone;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.BooleanPath;
import com.querydsl.core.types.dsl.DateTimePath;
import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.core.types.dsl.StringPath;

public class BooleanExpressionBuilder {

    private SearchCriteria criteria;
    private BusinessPartnerMessageProvider messageProvider;
    private static final String NO_SUCH_FILED_MESSAGE = "NO_SUCH_FIELD_FOR_QUERY_PARAMETER";

    public BooleanExpressionBuilder(final SearchCriteria criteria ) {
        this.criteria = new SearchCriteria(criteria.getKey(),criteria.getOperation(),criteria.getValue());

    }

    public BooleanExpression buildPredicate(Class classType) {
        // the second param for PathBuilder constructor is the binding path.
        PathBuilder<Class> entityPath = new PathBuilder<>(classType, classType.getSimpleName());
        Boolean isValueMatchEndWith = criteria.getValue().toString().endsWith("*");
        Boolean isValueMatchStartWith = criteria.getValue().toString().startsWith("*");
        Boolean isOperationColon = ":".equalsIgnoreCase(criteria.getOperation());
        int searchValueLength = criteria.getValue().toString().length();

        StringPath stringPath = entityPath.getString(criteria.getKey());
        DateTimePath<Date> timePath = entityPath.getDateTime(criteria.getKey(), Date.class);
        NumberPath<Integer> numberPath = entityPath.getNumber(criteria.getKey(), Integer.class);

        if ((isOperationColon) && (!isValueMatchStartWith) && (!isValueMatchEndWith)) {
            return getEqualBooleanExpression(classType, entityPath, stringPath, timePath, numberPath);
        }

        if (">".equalsIgnoreCase(criteria.getOperation())) {
            return getGreaterThanBooleanExpression(classType, timePath, numberPath);
        }

        if ("<".equalsIgnoreCase(criteria.getOperation())) {
            return getLessThanBooleanExpression(classType, timePath, numberPath);
        }

        // !:means !=
        if ("!:".equalsIgnoreCase(criteria.getOperation())) {
            return getNotEqualBooleanExpression(classType, entityPath,
                    stringPath, timePath, numberPath);
        }
        //start with xxx
        if ((isOperationColon) && isValueMatchEndWith && (!isValueMatchStartWith)) {
            if (isSearchKeyValidForClass(classType))
                return stringPath
                        .startsWithIgnoreCase(criteria.getValue().toString().substring(0, searchValueLength - 1).trim());
        }

        if ((isOperationColon) && (!isValueMatchEndWith) && (isValueMatchStartWith)) {
            if (isSearchKeyValidForClass(classType))
                return stringPath.endsWithIgnoreCase(criteria.getValue().toString().substring(1, searchValueLength).trim());
        }
        //contain xxx
        if ((isOperationColon) && isValueMatchEndWith && isValueMatchStartWith) {
            return getContainsBooleanExpression(classType, searchValueLength, stringPath);
        }
        return null;
    }

    private BooleanExpression getContainsBooleanExpression(Class classType,
            int searchValueLength, StringPath stringPath) {
            try {
                Class fieldType = classType.getDeclaredField(criteria.getKey()).getType();
                if (fieldType.equals(String.class) && searchValueLength>1) {
                    return stringPath.containsIgnoreCase(criteria.getValue().toString().substring(1,searchValueLength-1).trim());
                }
                //if there are only a "*" in the seatch value, then
                if(fieldType.equals(String.class) && searchValueLength==1){
                    return stringPath.eq(criteria.getValue().toString());
                }
            } catch (NoSuchFieldException | SecurityException e) {

            }
        return null;
    }

    private boolean isSearchKeyValidForClass(Class classType) {
        try {
            Class fieldType = classType.getDeclaredField(criteria.getKey()).getType();
            if (fieldType.equals(String.class)) {
                return true;
            }
        } catch (NoSuchFieldException | SecurityException e) {
            throw new BadRequestValidationException(messageProvider.getMessage(NO_SUCH_FILED_MESSAGE,
                    new Object[] { criteria.getKey() }), e);
        }
        return false;
    }

    private BooleanExpression getNotEqualBooleanExpression(Class classType, PathBuilder<Class> entityPath,
            StringPath stringPath, DateTimePath<Date> timePath, NumberPath<Integer> numberPath) {
            try {
                Class fieldType = classType.getDeclaredField(criteria.getKey()).getType();
                if (fieldType.equals(Date.class)) {
                    dateTimeValueConverter();
                    return timePath.ne((Date) criteria.getValue());
                }
                if (fieldType.equals(Integer.class)) {
                    int value = Integer.parseInt(criteria.getValue().toString());
                    return numberPath.ne(value);
                }
                if (fieldType.equals(String.class)) {
                    return stringPath.ne(criteria.getValue().toString());
                }
                if (fieldType.equals(boolean.class)) {
                    booleanConverter();
                    BooleanPath booleanPath = entityPath.getBoolean(criteria.getKey());
                    return booleanPath.ne((Boolean) criteria.getValue());
                }
                if (fieldType.equals(Boolean.class)) {
                    booleanConverter();
                    BooleanPath booleanPath = entityPath.getBoolean(criteria.getKey());
                    return booleanPath.ne((Boolean) criteria.getValue());
                }
            } catch (NoSuchFieldException | SecurityException e) {
                throw new BadRequestValidationException();
            }
        return null;
    }

    private BooleanExpression getLessThanBooleanExpression(Class classType,
            DateTimePath<Date> timePath, NumberPath<Integer> numberPath) {
            try {
                Class fieldType = classType.getDeclaredField(criteria.getKey()).getType();
                if (fieldType.equals(Date.class)) {
                    dateTimeValueConverter();
                    return timePath.lt((Date) criteria.getValue());
                }
                if (fieldType.equals(Integer.class)) {
                    integerValueConverter();
                    return numberPath.lt((Integer) criteria.getValue());
                }
            } catch (NoSuchFieldException | SecurityException e) {
                throw new BadRequestValidationException(e.getCause());
            }
        return null;
    }

    private BooleanExpression getGreaterThanBooleanExpression(Class classType,
            DateTimePath<Date> timePath, NumberPath<Integer> numberPath) {
            // other data types do not make sense when use >
            try {
                Class fieldType = classType.getDeclaredField(criteria.getKey()).getType();
                if (fieldType.equals(Date.class)) {
                    dateTimeValueConverter();
                    return timePath.gt((Date) criteria.getValue());
                }
                if (fieldType.equals(Integer.class)) {
                    integerValueConverter();
                    return numberPath.gt((Integer) criteria.getValue());
                }
            } catch (NoSuchFieldException | SecurityException e) {
                throw new BadRequestValidationException(e.getCause());
            }

        return null;
    }

    private BooleanExpression getEqualBooleanExpression(Class classType, PathBuilder<Class> entityPath, StringPath stringPath,
            DateTimePath<Date> timePath, NumberPath<Integer> numberPath) {
        // :means =
            try {
                Class fieldType = classType.getDeclaredField(criteria.getKey()).getType();
                if (fieldType.equals(Integer.class)) {
                    integerValueConverter();
                    return numberPath.eq((Integer) criteria.getValue());
                }
                if (fieldType.equals(Date.class)) {
                    dateTimeValueConverter();
                    return timePath.eq((Date) criteria.getValue());
                }
                if (fieldType.equals(boolean.class)) {
                    booleanConverter();
                    BooleanPath booleanPath = entityPath.getBoolean(criteria.getKey());
                    return booleanPath.eq((Boolean) criteria.getValue());
                }
                if (fieldType.equals(Boolean.class)) {
                    booleanConverter();
                    BooleanPath booleanPath = entityPath.getBoolean(criteria.getKey());
                    return booleanPath.eq((Boolean) criteria.getValue());
                }
                if (fieldType.equals(String.class)) {
                    return stringPath.equalsIgnoreCase(criteria.getValue().toString());
                }
            } catch (NoSuchFieldException | SecurityException e) {
                throw new BadRequestValidationException(e.getCause());
            }

        return null;
    }

    // convert string to datetime
    private void dateTimeValueConverter() {
        criteria.setValue(convertToTimeStamp(criteria.getValue().toString()));
    }

    private void  booleanConverter() {
        if (criteria.getValue().toString().equalsIgnoreCase("true")) {
            criteria.setValue(true);
        } else if (criteria.getValue().toString().equalsIgnoreCase("false")) {
            criteria.setValue(false);
        } else {
            throw new BadRequestValidationException("Invalid Boolean");
        }
    }

    // convert string to Integer
    private void integerValueConverter() {
        criteria.setValue(Integer.parseInt(criteria.getValue().toString()));
    }

    private Date convertToTimeStamp(String time) {
        //convert date here
        return parsedDate;
    }

}

查詢條件的抽象類SearchCriteria定義如下:

public class SearchCriteria {
    private String key;
    private String operation;
    private Object value;
}

大致的實現邏輯如下圖所示:

比較關鍵的點有下面這些:

  • 對字符串的解析需要藉助正則表達式的幫助,正則表達式決定了我們支持怎樣的查詢.
  • 由於字符串可以任意輸入,存在無限種可能,對查詢字符串的校驗很關鍵也很複雜。
  • 不同邏輯的查詢條件需要存放在不同的容器裏面,因爲他們的拼接邏輯不一樣,一個是或一個是與
  • 不同的字段類型需要調用不同的生成Predicate的方法,例如String,Boolean和Date這些類型他們都有自己對應的查詢實現
  • 生成子表的Predicate很複雜,與主表的查詢條件一起查詢時邏輯更加複雜,上面的邏輯拿掉了這一部分。但是這個功能是可以實現的。

實現過程中的難題

主表包含多個子表數據時的AND查詢

距離說明,現在有數據定義如下:

{
 "customerNumber": "5135116903",
 "customerType": "INDIVIDUAL",
 "createdBy": "[email protected]",
 "changedBy": "[email protected]",
 "createdAt": "2018-06-26T10:15:17.212Z",
 "changedAt": "2018-06-26T10:15:17.212Z",
 "markets": [{
  "marketId": "A1",
  "currency": "USD",
  "country": "US",
  "active": true
 }, {
  "marketId": "A2",
  "currency": "USD",
  "country": "US",
  "active": false
 }, {
  "marketId": "A3",
  "currency": "USD",
  "country": "US",
  "active": true
 }]
}

其中父節點表是customer,子節點markets信息存儲在market表當中。

現在,假設我們有這樣的查詢:

customerNumber: 5135116903 AND markets.active:false

沒有疑問,上面的數據應該被查出來。現在查詢條件變成下面這樣:

customerNumber: 5135116903 AND markets.active:false AND markets.marketId:A1

現在問題來了,語句的意思是此客戶的marker既要是非active 的且ID要是A1,但是此客戶又有多個market,從整個數組裏來看,這個條件是滿足的。但是從單個的market個體來看這個條件是不滿足的。而我們作爲用戶的話希望得到的效果必然是無法查處此customer信息。

這會給實現帶來問題,因爲由於market是一個數組,在數據表中對應的就是幾條記錄,我們在解析並構建子表查詢時,必須確保對於子表的查詢條件是作用於單獨的一個node,也就是單獨的一條記錄,而不是從整個數組當中去查,否則就會有問題。

來源:https://blog.csdn.net/topdeveloperr/article/details/89550197

近期熱文推薦:

1.1,000+ 道 Java面試題及答案整理(2022最新版)

2.勁爆!Java 協程要來了。。。

3.Spring Boot 2.x 教程,太全了!

4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這纔是優雅的方式!!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章