首先先区分一下概念:
路由是指一个过程,就是利用定义好的一些规则,让不一样的URI可以调用不一样的处理器(一个匿名函数或者一个类中的方法)这样一个过程。html
日常不少框架所说的定义一个路由就是注册一个这样的规则到系统中去。git
slim的路由是使用了FastRoute这个库,做者写了一篇帖子,介绍了它写这个库的缘由(原文连接):github
前段时间,我在Pux路由库遇到了一些问题。它号称比现用的路由库快几个数量级的,由于为了达到这个这个目的,这个库是经过PHP的C扩展实现的。
然而,粗略地看了Pux的源码以后,我强烈怀疑这个库优化了路由处理的错误部分,而我不借助于C扩展却能够很轻松地到更好的性能。当我看了Pux的基准测试以后,发现只测试了几个很是简单实际的单路由例子,我就更加确定了个人怀疑。
为了进一步调查这个问题,我写了一个小型路由库:FastRoute,这个库实现了接下来描述的分发处理。为了给出预先观点,我贴出了和Pux库对比的小型基准测试结果:正则表达式
1 placeholder | Pux (no ext) | Pux (ext) | FastRoute ----------------------------------------------------- First route | 0.17 s | 0.13 s | 0.14 s Last route | 2.51 s | 1.20 s | 0.49 s Unknown route | 2.34 s | 1.10 s | 0.34 s 9 placeholders | Pux (no ext) | Pux (ext) | FastRoute ----------------------------------------------------- First route | 0.22 s | 0.19 s | 0.20 s Last route | 2.65 s | 1.78 s | 0.59 s Unknown route | 2.50 s | 1.49 s | 0.40 s
这个基准测试使用了一百个路由,而后找出其中最快的路由(最佳例子),最慢的路由(最差的例子)和一个总共的未知平均路由。测试经过设置一个变量分为两部分,一部分使用了1个占位符,另外一部分使用了9个占位符。整个测试很明显进行了循环了几百次。express
为了确保咱们在说同一个事物,让咱们定义一下"路由"是什么。在大多数实际的形式中,它是指跟如下形式相似的利用一套路由定义:数组
$r->addRoute('GET', '/user/{name}/{id:\d+}', 'handler0'); $r->addRoute('GET', '/user/{id:\d+}', 'handler1'); $r->addRoute('GET', '/user/{name}', 'handler2');
而后调度处理一个基于它们的URI的过程:数据结构
$d->dispatch('GET', '/user/nikic/42'); // => provides 'handler0' and ['name' => 'nikic', 'id' => '42']
把这个过程提高到一个更抽象的层次,咱们将会为路由定义提供HTTP方法和任何特定的格式。在本文中,我会考虑的惟一同样事情是路由的调度阶段——路由如何被解析或调度器生成的数据不会被覆盖。
那么,路由处理的最耗时的部分是哪里呢?在一个混乱不堪的,过分被设计的系统中,它多是实例化数十个对象和调用数百个方法的开销。Pux库在减小这方面的开销作的很好。然而,在一个比较原始层次的系统中,依次通过一系列数十个或者数百个路由表达式,以后再经过提供的URI和它们进行匹配这个过程是最耗时的部分。让这个过程更快就是本文的主题。
合并的正则表达式:
优化这类问题的最基本方法是避免一个个的去匹配那些正则表达式,相反地,把它们结合在一块儿,变成一个大的正则表达式,这样的话,你只须要进行一次匹配就能够了。就拿最后一个例子的路由做说明,合并的正则表达式是这样的:框架
Individual regexes: ~^/user/([^/]+)/(\d+)$~ ~^/user/(\d+)$~ ~^/user/([^/]+)$~ Combined regex: ~^(?: /user/([^/]+)/(\d+) | /user/(\d+) | /user/([^/]+) )$~x
这个转化很简单:基本上你只要将那些正则表达式一个个地用OR 链接在一块儿就能够了。当匹配这个合并的正则表达式,如何找出具体哪一个路由规则被匹配了呢?为了找出来,让咱们来看一看preg_match这个函数对一个样本的输出:ide
preg_match($regex, '/user/nikic', $matches); => [ "/user/nikic", # full match "", "", # groups from first route (empty) "", # groups from second route (empty) "nikic", # groups from third route (used!) ]
那么,在$matches数组中找到第一个非空入口就是诀窍了(固然,没算上第一个彻底匹配)。函数
这里我贴上代码,方便你们测试:
$regex = "~^(?:/user/([^/]+)/(\d+)|/user/(\d+)|/user/([^/]+))$~x"; preg_match($regex, "/user/nikic", $matches); var_dump($matches);
(?:regexp) 匹配 pattern 但不获取匹配结果,也就是说这是一个非获取匹配,不进行存储供之后使用。这在使用 "或" 字符 (|) 来组合一个模式的各个部分是颇有用。例如, 'industr(?:y|ies) 就是一个比 'industry|industries' 更简略的表达式。介绍
为了使用这个结果,你将须要一个额外的数据结构来映射$matches的索引到匹配的路由规则(或者,一些关联那个路由规则的信息)
[ 1 => ['handler0', ['name', 'id']], 3 => ['handler1', ['id']], 4 => ['handler2', ['name']], ]
这里是一个实现整个处理流程的例子:
public function dispatch($uri) { if (!preg_match($this->regex, $uri, $matches)) { return [self::NOT_FOUND]; } // find first non-empty match (skipping full match) for ($i = 1; '' === $matches[$i]; ++$i); list($handler, $varNames) = $this->routeData[$i]; $vars = []; foreach ($varNames as $varName) { $vars[$varName] = $matches[$i++]; } return [self::FOUND, $handler, $vars]; }
在找到第一个非空的索引,关联的数据就能够被查找到了。经过遍历$matches数组并配对值和变量名,占位符的变量就能够被填充了。
那么这个方法执行起来效率如何呢?这里给出了和Pux的比较结果(使用C扩展):
1 placeholder | Pux (ext) | GPB-NC ----------------------------------- First route | 0.13 s | 0.20 s Last route | 1.20 s | 0.70 s Unknown route | 1.10 s | 0.16 s 9 placeholders | Pux (ext) | GPB-NC ----------------------------------- First route | 0.19 s | 0.41 s Last route | 1.78 s | 4.09 s Unknown route | 1.49 s | 0.30 s
GPB-NC表示“Group position based, non-chunked”调度。如你所见的那样,在单个占位符的测试例子这个方法提供了不错的性能。固然它不能战胜在最快路由上正确匹配的C扩展实现,可是在最糟糕匹配上,它表现地快了一点,若是一个都没匹配的话,更快了。