大数据&云计算大数据程序员

Presto源码分析之模式匹配

2018-07-14  本文已影响113人  xumingmingv

概要

Presto里面有个小小的模式匹配的库: presto-matching ,这个库很小,一共就15个文件,但是在 Presto 里面作用还蛮大的,Presto 里面很关键的查询优化(Query Optimization)就是要靠这个小小的库来识别性能有问题的查询计划,替换成性能更好的计划。

在这篇文章里,我们会详细介绍一下 presto-matching 库里面的几个主要概念: Pattern, Match, Matcher、 整个库的设计思路以及它在 Presto 查询优化里面的具体应用。

源码分析

presto-matching 里面几个主要的类以及相互间的关系如下:

presto-matching 关键类图

Pattern

Pattern 在库里面是一个抽象类,它主要起了四方面的作用:首先它定义了模式的结构;其次定义了模式的行为;再次它定义了常用模式构造的方法,形成了一个小型的DSL;最后它定义了模式与匹配之间的桥梁方法: matches

Pattern的结构

我们先来看看模式的结构。模式的结构是这样的, 模式本身里面到底有哪些属性是各个具体的Pattern 子类实现自己定义的,比如 EqualsPattern 里面有一个 expectedValue , 用来表示要判定是否相等的值是多少;FilterPattern 里面会有一个 predicate 字段,用来判定对象需要符合的条件。

而所有的 Pattern 里面都会有一个 previous 的字段,这个字段指向上一个模式,这样我们虽然只拿到了一个 Pattern 对象,但是其实它背后可能串了一个链的对象,这些模式之间的关系是“并且”的关系,我们可以称之为“复合Pattern”。

复合Pattern结构

Pattern的行为

其次它定义了模式的行为, 主要是两个抽象方法:

    Match<T> accept(Matcher matcher, Object object, Captures captures);

    void accept(PatternVisitor patternVisitor);

第一个 accept 方法是模式匹配的场景,它的第一个参数 Matcher 是具体执行模式识别的核心类,后面会详细介绍,第二个参数 object 是要被匹配的对象,第三个参数 captures 相当于很多类库里面的 Context 对象,它的作用是保存在模式识别的过程中我们关心的某个子节点,后续可能会对这个子节点进行进一步处理,比如说替换掉以优化性能。

第二个 accept 方法目前主要的使用场景是对 Pattern 本身进行遍历以实现 toString , 现在唯一的 PatternVisitor: DefaultPrinter 是做这个的。

这里其实是对 Pattern 这一个对象实现了两套 Visitor 的模式,一套用来进行模式匹配,一套用来进行通用的结构遍历。

Pattern DSL

Pattern 类的第三个作用就是定义了一些常用的模式构造的方法,比如:

从这个角度上看,Pattern 这个类其实扮演了模式匹配库的门面角色,虽然 Pattern 有几个具体的子类,但是这个库的用户不会直接去用,而都是使用 Pattern 里面的这些工厂方法,这样的好处有两个,一是隔离了变化,这样 Pattern 的子类名、里面具体的实现逻辑可以自由变化而不用担心影响到用户;二是这些工厂方法用起来很简洁,使用的时候看起来像是在手写语法树,有点DSL的感觉。

给大家看个例子,下面的这段代码表示要寻找一个 Project 节点,这个 Project 节点下面(source)要有一个 Scan 节点:

project().with(
    source().matching(
        scan()
    )
)

可以看出,非常的形象,从代码可以直接看出要寻找的模式是怎么样的。而如果改成让你直接用Pattern 子类来实现同样逻辑的话,大概是这样的:

new FilterPattern<>(
    new TypeOfPattern<>(ScanNode.class)
    new WithPattern<>(
        new Property<>("source", SingleSourceRelNode::getSource)
        new TypeOfPattern<>(ProjectNode.class)
    )
)

是不是觉得很不直观,很累赘,一点都不想用了吧?没错,这就是DSL的力量。没有对比就没有伤害啊。

把模式与匹配逻辑连接起来

Pattern 的最后一个主要作用是定义了把模式与匹配的逻辑连接起来的桥梁方法: matches():

public boolean matches(Object object)
{
    return DEFAULT_MATCHER.match(this, object).isPresent();
}

从代码实现就可以看出, matches 方法的作用对于给定的一个对象,判断它是否能匹配当前的Pattern。

Pattern的子类

目前Pattern的子类主要有5个:

匹配(Match)

前面我们讲完了模式匹配的前半部分: 模式,下面我们来讲讲后半部分: 匹配。匹配的关键类是 Match , Match 跟 Pattern 一样,也是一个抽象类。它主要定义了:

比如在下面的例子里面:

project().with(
    source().matching(
        scan().capturedAs(SCAN_NODE);
    )
)

我们可以用下面的代码获取到这个 ScanNode:

    ScanNode scan = match.capture(SCAN_NODE);

这个类另外一个比较有意思的点是, Pattern.matches() 返回的永远不会为 null (其实不只是这一个类,整个 Presto 都是这个风格,不会返回 null,因此你可以看到代码里面大量的使用了 Optional 类,然后判断 optional.isPresent() 来看是否真的有结果)。Match 有两个私有实现,一个是 Present , 一个是 Empty 。

如果匹配到了,那么返回的是 Present , 这个 Present 里面会有两个东西:

而如果没有匹配到,那么返回的是 Empty, 看起来很优雅。

值得注意的是这两个实现类 Present, Empty 都是私有的,用户是无法直接用的,对用户来说只有一个抽象类 Match, Match 本身提供工厂方法来根据使用场景构造具体的实现类给你使用:

Matcher

模式(Pattern)跟匹配(Match)都讲完了, 最后我们来讲讲联系这两端的桥梁: 匹配器(Matcher):

如前面所说,匹配器使用的是 Visitor 的模式,它定义了匹配各种不同模式的方法:

public interface Matcher {
    default <T> Match<T> match(Pattern<T> pattern, Object object) {
        return match(pattern, object, Captures.empty());
    }
    <T> Match<T> match(Pattern<T> pattern, Object object, Captures captures);
    <T> Match<T> matchTypeOf(TypeOfPattern<T> typeOfPattern, Object object, Captures captures);
    <T> Match<T> matchWith(WithPattern<T> withPattern, Object object, Captures captures);
    <T> Match<T> matchCapture(CapturePattern<T> capturePattern, Object object, Captures captures);
    <T> Match<T> matchEquals(EqualsPattern<T> equalsPattern, Object object, Captures captures);
    <T> Match<T> matchFilter(FilterPattern<T> filterPattern, Object object, Captures captures);
}

目前 Matcher 接口只有一个默认实现: DefaultMatcher, 估计在可以预见的将来也就只有一个实现了,因为实现本身很简单,没有太多花头。 这里最核心的方法是:

    @Override
    public <T> Match<T> match(Pattern<T> pattern, Object object, Captures captures)
    {
        if (pattern.previous() != null) {
            Match<?> match = match(pattern.previous(), object, captures);
            return match.flatMap((value) -> pattern.accept(this, value, match.captures()));
        }
        else {
            return pattern.accept(this, object, captures);
        }
    }

它递归地在整个 Pattern 链上调用 match 方法,看看是否这个入参 object 能够满足这个链上的所有 Pattern,同时把过程当中把用户想捕获的子节点通过 Captures 保存下来。

模式匹配在Presto里面的应用

前面也介绍过,模式匹配在 Presto 里面主要用来寻找执行计划里面有待优化的部分,执行计划优化在 Presto 里面有两类: 一类基于规则的优化器(Rule Based Optimization),一类是基于代价的优化器(Cost Based Optimization),而模式识别主要是在基于规则的场景下使用的,比如其中有一个优化规则 PushLimitThroughProject 是这样的:

如果在Limit节点下面有一个Project节点,那么把这个Limit节点下推到Project节点下面。

这里的Limit, Project都是关系代数里面的概念, 不熟悉的同学可以这么简单理解: Limit就对应到SQL语句里面的LIMIT语句,Project对应到SQL语句里面的SELECT语句。这样上面那条优化规则的意思就很好理解了:你如果知道后面进行 limit 操作,那么不如在前面 select 时候时候就少 select 一些数据出来,这样整个查询总的处理的数据量就少了。那么我们看看这么一个规则是怎么通过模式识别来实现的:

// 为了整个代码的简洁性,不影响理解的情况下,这里删除了一些不大相关的方法。
// PushLimitThroughProject的完整实现请参见Presto的源码
public class PushLimitThroughProject
         implements Rule<LimitNode> {
    // 我们给要捕获的ProjectNode节点分配一个Key
    private static final Capture<ProjectNode> CHILD = newCapture();
    private static final Pattern<LimitNode> PATTERN = limit()
            .with(source().matching(
                    project().capturedAs(CHILD) // 通过capturedAs捕获ProjectNode
                    ));

    @Override
    public Result apply(LimitNode parent, Captures captures, Context context) {
        // 把LimitNode和ProjectNode的位置进行互换从而把Limit换到Project下面去
        // 达到的效果就是先做Limit再进行Project
        return Result.ofPlanNode(transpose(parent, captures.get(CHILD)));
    }
}

这里的代码很简单,配合我的注释应该很好理解。这样一个优雅的小模式识别的库在这个 Presto 查询优化的大场景中就起到了很大的作用。

感想

好的代码就像一首散文,一首诗,虽然里面的每个字都会写,但是自己却写不出这么美妙的代码。至于它为什么好,我觉得这里融入了作者对于技术、业务的充分理解和抽象,以及作者本身作为这个库的用户不断打磨推敲出来的。

Presto 的代码虽然总体来说不是很好:注释很少、接口太多、定义很随便、构造函数动辄十几个参数等等,问题数不胜数,但是看到这个模式识别的小库还是有点惊艳的感觉的。

这个库设计得比较干净,虽然它在 Presto 里面的主要场景就是做查询优化的模式识别,但是它的实现没有跟查询优化绑定死,以后如果有类似模式识别的场景只要把这个库稍加改造应该就可以使用。

上一篇 下一篇

猜你喜欢

热点阅读