关于java:Spring-Boot-Querydsl-框架大大简化复杂查询操作

2次阅读

共计 17204 个字符,预计需要花费 44 分钟才能阅读完成。

概述

本篇博客次要将介绍的是利用 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…

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": "Android.chen@sap.com",
 "changedBy": "Android.chen@sap.com",
 "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/topdeve…

近期热文举荐:

1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)

2. 劲爆!Java 协程要来了。。。

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

4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!

5.《Java 开发手册(嵩山版)》最新公布,速速下载!

感觉不错,别忘了顺手点赞 + 转发哦!

正文完
 0