首先先区分一下概念:
路由是指一个过程,就是利用定义好的一些规则,让不同的URI能够调用不同的处理器(一个匿名函数或者一个类中的方法)这样一个过程。
平常很多框架所说的定义一个路由就是注册一个这样的规则到系统中去。
slim的路由是使用了FastRoute这个库,作者写了一篇帖子,介绍了它写这个库的原因(原文链接):
使用正则的快速路由库
前段时间,我在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个占位符。整个测试很明显进行了循环了几百次。
关于路由的问题
为了确保我们在说同一个事物,让我们定义一下"路由"是什么。在大多数实际的形式中,它是指跟以下形式类似的利用一套路由定义:
$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这个函数对一个样本的输出:
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扩展实现,但是在最糟糕匹配上,它表现地快了一点,如果一个都没匹配的话,更快了。