概要
Presto里面有个小小的模式匹配的库: presto-matching
,这个库很小,一共就15个文件,但是在 Presto 里面作用还蛮大的,Presto 里面很关键的查询优化(Query Optimization)就是要靠这个小小的库来识别性能有问题的查询计划,替换成性能更好的计划。
在这篇文章里,我们会详细介绍一下 presto-matching
库里面的几个主要概念: Pattern
, Match
, Matcher
、 整个库的设计思路以及它在 Presto 查询优化里面的具体应用。
源码分析
presto-matching 里面几个主要的类以及相互间的关系如下:
Pattern
Pattern 在库里面是一个抽象类,它主要起了四方面的作用:首先它定义了模式的结构;其次定义了模式的行为;再次它定义了常用模式构造的方法,形成了一个小型的DSL;最后它定义了模式与匹配之间的桥梁方法: matches
。
Pattern的结构
我们先来看看模式的结构。模式的结构是这样的, 模式本身里面到底有哪些属性是各个具体的Pattern 子类实现自己定义的,比如 EqualsPattern
里面有一个 expectedValue
, 用来表示要判定是否相等的值是多少;FilterPattern
里面会有一个 predicate
字段,用来判定对象需要符合的条件。
而所有的 Pattern 里面都会有一个 previous
的字段,这个字段指向上一个模式,这样我们虽然只拿到了一个 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 类的第三个作用就是定义了一些常用的模式构造的方法,比如:
- 匹配任何对象的
any()
- 匹配指定类型对象的
typeOf()
- 匹配指定条件的
matching()
- 对象的属性匹配某种条件的
with()
- 捕获某个节点的
capturedAs()
从这个角度上看,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个:
- TypeOfPattern: 检测某个节点是不是指定类型的节点,比如是不是一个 TableScan 节点,是不是一个 Join 节点。这个在 Presto 的查询优化里面是最常用的了。Pattern.typeOf() 底层调用的就是这个实现。
- FilterPattern: 检测节点是否符合某个 predicate。Pattern.matching() 底层调用的就是这个实现。
- EqualsPattern: 这其实是 FilterPattern 的一种特殊形式。
- WithPattern: 它的作用是检测对象的某个属性是否符合某种模式,比如上面例子里面的 project().with(source().xxxx) , 这里的 source() 就是表示 ProjectNode 的下游节点。这个Pattern也非常的重要,没有这个模式的话,我们只能对一个对象本身进行检测,有了这个Pattern之后,我们就可以对一个节点的树形结构进行模式匹配。
- CapturePattern: 这个模式是为了捕获当前的节点,并且把当前节点跟用户给定的 Capture 绑定上,后面用户可以通过这个 Capture 获取到对应的节点。
匹配(Match)
前面我们讲完了模式匹配的前半部分: 模式,下面我们来讲讲后半部分: 匹配。匹配的关键类是 Match , Match 跟 Pattern 一样,也是一个抽象类。它主要定义了:
-
isPresent()
: 是否匹配上了。 -
value()
: 如果匹配上了,匹配到的值是什么? -
capture(capture)
, 如果匹配上了,那么你可以调用这个方法去获取这个匹配到的结构里面的某个子节点。
比如在下面的例子里面:
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 里面会有两个东西:
-
value
: 匹配到的节点(值) -
captures
: 匹配的过程中捕获的所有子节点。
而如果没有匹配到,那么返回的是 Empty, 看起来很优雅。
值得注意的是这两个实现类 Present, Empty 都是私有的,用户是无法直接用的,对用户来说只有一个抽象类 Match, Match 本身提供工厂方法来根据使用场景构造具体的实现类给你使用:
-
Match<T> of(S value, Captures captures)
, 模式匹配成功的话使用这个工厂方法,返回的是 Present. -
Match<S> empty()
, 没有匹配到的话使用这个工厂方法,返回的是 Empty 。
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 里面的主要场景就是做查询优化的模式识别,但是它的实现没有跟查询优化绑定死,以后如果有类似模式识别的场景只要把这个库稍加改造应该就可以使用。