背景: 近期随着项目开源后热度的不断上涨,越来越多小伙伴开始对框架核心功能感兴趣,今天就让我带大家深入源码和架构,一起探索Easy-Es(简称EE)的核心功能是如何被设计和实现的.

和众多ORM框架一样,EE最为核心的功能就是CRUD(增删改查),当然由于ES本身的特殊性,EE在核心功能中还额外引入了Index(索引)的管理.鉴于核心功能的CRUD接口API与Mybatis-Plus(MP)的一致,这里就不再浪费篇幅过多介绍,我们重点来看下Index的索引API:

// 是否存在索引
Boolean existsIndex(String indexName);
// 创建索引
Boolean createIndex(LambdaEsIndexWrapper<T> wrapper);
// 更新索引
Boolean updateIndex(LambdaEsIndexWrapper<T> wrapper);
// 删除指定索引
Boolean deleteIndex(String indexName);

 

对于ES索引的理解,笼统一点大家可以当成传统关系型数据库中的表,而非数据库中的那个"索引",那么对是否存在索引和指定索引删除,也就对应了索引名(表名)这个入参,你要查找哪个表是否存在或删除该表,你需要先知道表名,故有此设计.

复杂的是索引的创建和更新,两者都涉及了大量的参数,比如在创建索引时你需要考虑将哪些字段加入索引?用什么样的类型?需要多少个副本?需要多少分片?需要什么分词器?是否需要给索引别名?...等一些列问题,因此我们引入了LambdaEsIndexWrapper这个索引条件封装类,用于用户根据索引实体Model快速封装出想要的索引并完成索引的创建和更新,我们来看一个具体的案例,EE是如何帮助用户快速创建索引的.

@Test
    public void testCreatIndex() {
        LambdaEsIndexWrapper<Document> wrapper = new LambdaEsIndexWrapper<>();
        wrapper.indexName(Document.class.getSimpleName().toLowerCase());
        // 此处将文章标题映射为keyword类型(不支持分词),文档内容映射为text类型(支持分词查询)
        wrapper.mapping(Document::getTitle, FieldType.KEYWORD)
                .mapping(Document::getContent, FieldType.TEXT,Analyzer.IK_SMART,Analyzer.IK_SMART);

        // 设置分片及副本信息,可缺省
        wrapper.settings(3, 2);

        // 设置别名信息,可缺省
        String aliasName = "daily";
        wrapper.createAlias(aliasName);

        // 创建索引
        boolean isOk = documentMapper.createIndex(wrapper);
        Assert.assertTrue(isOk);
    }

 

通过上述Demo,我们可以看出用户可以直接使用实体的字段名作为索引中的字段名,枚举中的字段类型/分词器类型作为可选的类型,从而告别传统方式,代码中不出现魔法值,不用一层一层创建Map去封装这些参数,就可以快速完成索引的创建.

其核心原理也比较简单,就是把wrapper中的条件逐一加入队列(FIFO),然后按照Es语法,逐一封装进Map,再调用RestHighLevelClient完成索引的创建/更新,这里面就一点可能大家会比较感兴趣,我简单提一下:

难点一:如何通过Lambda语法如Document::getTitle获取类Document中的title字段?

我们可以通过反射轻而易举获取一个类中的所有字段列表和值,但想要获取具体的字段却无能为力,还好JDK8提供了给力的函数式编程,让这一切变成可能,Document::getTitle实际上传入的是一个函数,也就是Document类中title的get方法,从get方法中获取一个字段的名称就很简单了,因为get方法的特殊性,前3个字母都是get打头或前2个字母都是is打头,剩下的字母就是字段名称,我们只需要拿到后处理大小写即可.

先自定义一个函数式接口,用于接收Document::getXXX方法:

@FunctionalInterface
public interface SFunction<T, R> extends Serializable {
    R apply(T t);
}

让LambdaIndexWrapper实现此接口和Index接口:

public class LambdaEsIndexWrapper <T>  implements Index<LambdaEsIndexWrapper <T>, SFunction<T, ?>> {
// 省略其它...
}

 如此,我们便可在Index接口中,把Document::getTiltle作为函数传入:

Children mapping(R column, FieldType fieldType, Analyzer analyzer, Analyzer searchAnalyzer)

然后封装工具类去解析此函数接口:

 public static <R> String getFieldName(R func) {
        if (!(func instanceof SFunction)) {
            throw new RuntimeException("not support this type of column");
        }

        try {
            // 通过获取对象方法,判断是否存在该方法
            Method method = func.getClass().getDeclaredMethod("writeReplace");
            method.setAccessible(Boolean.TRUE);
            // 利用jdk的SerializedLambda 解析方法引用
            java.lang.invoke.SerializedLambda serializedLambda = (SerializedLambda) method.invoke(func);
            String getter = serializedLambda.getImplMethodName();
            return resolveFieldName(getter);
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }

如此,便可拿到getXXX的方法名了,通过此方法名解析具体的小写字段值的代码过于简单就不贴了,DDDD.

源码部分还有几块更难啃的骨头,相信也有很多小伙伴读到这些代码的时候会有点懵,因为这部分确实有点复杂,我在开发时也曾被难倒了,想了很多天才找到解决办法,期间差点就放弃了,今天也和大家一起分享一下.

在完整文档顾虑粉碎模块中,我曾画了一张图,用于阐述EE在查询时做了什么?

在常规查询中,EE将用户指定的查询条件逐一加入队列(FIFO),然后在BaseEsMapperImpl类中处理时遍历此队列,然后按照MP和RestHighLevelClient语法差异,逐一转换并调用RestHighLevelClient进行查询即可.

但如果查询条件中出现了or,出现了and(xxx or xxx),甚至or xxx and(xxx or xxx)都出现,那情况就要复杂得多了,我举个具体的例子:

在MySQL中,我要查询作者为"王多鱼",或(标题为"老王"或标题为老汉的文档)或内容为"推*技术过硬,"或,此时SQL的Where条件是:

where creator = "王多鱼" OR (title = '老王' OR title = '老汉') OR content = '推*技术过硬';

如果使用EE来表述这段复杂的SQL,其写法为:

@Test public void testQuery(){
    LambdaEsQueryWrapper<Document> wrapper = new LambdaEsQueryWrapper<>();
    wrapper.eq(Document::getContent,"推*技术过硬")
           .and(w->w.eq(Document::getTitle,"老王").or().eq(Document::getTitle,"老汉"))
           .or()
           .eq(Document::getCreator,"王多鱼");
    List<Document> documents = documentMapper.selectList(wrapper);
    System.out.println(documents);
}

生成的DSL语法为:

{"size":10000,"query":{"bool":{"must":[{"term":{"content":{"value":"推*技术过硬","boost":1.0}}},{"bool":{"should":[{"term":{"title":{"value":"老王","boost":1.0}}},{"term":{"title":{"value":"老汉","boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}}],"should":[{"term":{"creator":{"value":"王多鱼","boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}}}

不难看出,我们不仅要兼容or在内层的情况,还要处理or在外层的情况,而or如果要出现在内层,一定会有and出现.EE的Wrapper是正向封装并且采用先进先出的队列置入查询条件的,遍历此队列时,当碰到and条件时,我需要把括号内的这些条件用一个新的boolQuery对象给封装进去,然后在最外层的booQuery用must条件将此boolQuery置入;当碰到or在外层时,我需要把整个or前面的部分作为一个整体,把整个or后面的语句作为另外一个整体,分别用两个boolQuery封装,然后最外层的boolQuery用should连接内层的两个boolQuery;当碰到or在内层时,需要把or前后的查询条件用should连接.因为队列是正向处理的,所以其中or出现在外层时,需要再次遍历or前面已经封装过了的所有查询条件,把它们重新置入一个新的boolQuery作为整体,至此,整个查询条件按照RestHighLevelClient语法就封装完毕了,我们可以先看下源码:

public static BoolQueryBuilder initBoolQueryBuilder(List<BaseEsParam> baseEsParamList) {
        BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
        // 用于连接and,or条件内的多个查询条件,包装成boolQuery
        BoolQueryBuilder inner = null;
        // 是否有外层or
        boolean hasOuterOr = false;
        for (int i = 0; i < baseEsParamList.size(); i++) {
            BaseEsParam baseEsParam = baseEsParamList.get(i);
            if (Objects.equals(BaseEsParamTypeEnum.AND_LEFT_BRACKET.getType(), baseEsParam.getType()) || Objects.equals(OR_LEFT_BRACKET.getType(), baseEsParam.getType())) {
                // 说明有and或者or
                for (int j = i + 1; j < baseEsParamList.size(); j++) {
                    if (Objects.equals(baseEsParamList.get(j).getType(), OR_ALL.getType())) {
                        // 说明左括号内出现了内层or查询条件
                        for (int k = i + 1; k < j; k++) {
                            // 内层or只会出现在中间,此处将内层or之前的查询条件类型进行处理
                            BaseEsParam.setUp(baseEsParamList.get(k));
                        }
                    }
                }
                inner = QueryBuilders.boolQuery();
            }

            // 此处处理所有内外层or后面的查询条件类型
            if (Objects.equals(baseEsParam.getType(), OR_ALL.getType())) {
                hasOuterOr = true;
            }
            if (hasOuterOr) {
                BaseEsParam.setUp(baseEsParam);
            }

            // 处理括号中and和or的最终连接类型 and->must, or->should
            if (Objects.equals(AND_RIGHT_BRACKET.getType(), baseEsParam.getType())) {
                boolQueryBuilder.must(inner);
                inner = null;
            }
            if (Objects.equals(OR_RIGHT_BRACKET.getType(), baseEsParam.getType())) {
                boolQueryBuilder.should(inner);
                inner = null;
            }

            // 添加字段名称,值,查询类型等
            if (Objects.isNull(inner)) {
                addQuery(baseEsParam, boolQueryBuilder);
            } else {
                addQuery(baseEsParam, inner);
            }
        }
        return boolQueryBuilder;
    }

其中setUp方法会将已经加入must条件队列中的参数重置入should条件队列中.

为了以最小的时间复杂度实现上述算法,我思考和尝试了非常久,这块代码和思路理解起来也最为烧脑,如果第一次看没看懂也别气馁,多看几遍,多尝试便可加深理解. 当然如果您有更优的算法,可以完美解决各种场景下的查询,也可以带上时间复杂度和算法联系我,非常感谢.

当然源码中还有几块难啃的骨头,也差点让我放弃...读者可以自行挖掘,毕竟有了源码已经可以不用摸着石头过河了,难度小了很多,确有多次阅读仍难以理解的地方,可以留言告诉我,我会抽空帮忙解答.

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐