路由(Route)

Web开发中不可避免的要使用到URL。用得最多的,就是生成一个指向应用中其他某个页面的URL了。 开发者需要一个简洁的、集中的、统一的方法来完成这一过程。

否则的话,在代码中写入大量的诸如 http://www.digpage.com/post/view/100 的代码,一是过于冗长,二是易出错且难排查, 三是日后修改起来容易有遗漏。因此,从开发角度来讲,需要一种更简洁、可以统一管理、 又能排查错误的解决方案。

同时,我们在 附录2:Yii的安装 部分讲解了如何为Yii配置Web服务器,从中可以发现, 所有的用户请求都是发送给入口脚本 index.php 来处理的。那么,Yii需要提供一种高效的分派 请求的方法,来判断请求应当采用哪个 controller 哪个 action 进行处理。

结合以上2点需求,Yii为开发者提供了路由和URL管理组件。

所谓路由是指URL中用于标识用于处理用户请求的module, controller, action的部分, 一般情况下由 r 查询参数来指定。 如 http://www.digpage.com/index.php?r=post/view&id=100 , 表示这个请求将由PostController 的 actionView来处理。

同时,Yii也提供了一种美化URL的功能,使得上面的URL可以用一个比较整洁、美观的形式表现出来, 如 http://www.digpage.com/post/view/100 。 这个功能的实现是依赖于一个称为 urlManager 的应用组件。

使用 urlManager 开发者可以解析用户的请求,并指派相应的module, controller和action来进行处理, 还可以根据预义的路由规则,生成需要的URL返回给用户使用。 简而言之,urlManger具有解析请求以便确定指派谁来处理请求和根据路由规则生成URL 2个功能。

美化URL

一般情况下,Yii应用生成和接受形如 http://www.digpage.com/index.php?r=post/view&id=100 的URL。这个URL分成几个部分:

  • 表示主机信息的 http://www.digapge.com
  • 表示入口脚本的 index.php
  • 表示路由的 r=post/view
  • 表示普通查询参数的 id=100

其中,主机信息部分从URL来讲,一般是不能少的。当然内部链接可以使用相对路径,这种情况下看似 可以省略,但是User Agent最终发出Request时,也是包含主机信息的。换句话说,Web Server接收并 转交给Yii处理的URL,是完整的、带有主机信息的URL。

而入口脚本 index.php 我们知道,Web Server会将所有的请求都是交由其进行处理。 也就是说,Web Server应当视所有的URL为请求 index.php 脚本。这在 :ref:install 部分我们 已经对Web Server进行过相应配置了。如Nginx:

location / {
    try_files $uri $uri/ /index.php?$args;
}

即然这样,URL中有没有指定 index.php 已经不重要了,反正都是请求的它。 在URL里面假惺惺地留个 index.php ,实在是画蛇添足。 因此,Yii允许我们不在URL中出现入口脚本 index.php

其次,路由信息对于Yii应用而言也必不可少,表明应当使用哪个controller和action来处理请求, 否则Yii只能使用默认的路由来处理请求。这个形式比较固定,采用的是一种类似路径的形式, 一般为 module/controller/action 之类的。

如果将URL省略掉入口脚本,并将路由信息转换成路径,上面的URL就会变成: http://www.digpage.com/post/view?id=100 , 是不是看起来舒服很多?

这样的链接看起来简洁美观,对于用户比较友好。同时,也比较适合搜索引擎的胃口, 据说是SEO的手段之一。

但到了这里还没完,对于查询参数 id=100 而言,这个URL请求的是编号为100的一个POST, 并执行view操作。那么我们可以再进一步改成 http://www.digpage.com/post/view/100 。 这样是不是更爽?

有处女座的说了,这个编号100跟前面的字母们放一起显得另类呀,要是都是字母的就更好了。 那我们假如所请求的编号100的文章,其标题为 Route , 那么不妨使用用 http://www.digpage.com/post/view/Route 来访问。

这样的话,干脆再加上 .html 好了。 变成 http://www.digpage.com/post/view/Route.html , 这样的URL对比原来,堪称完美了吧?岂不是连处女座也满意了?

我们把 URL http://www.digpage.comindex.php?r=post/view&id=100 变成 http://www.digpage.com/post/view/Route.html 的过程就称为URL美化。

Yii有专门的 yii\web\UrlManager 来进行处理,其中:

  • 隐藏入口脚本可以通过 yii\web\UrlManager::showScriptName = false 来实现
  • 路由的路径化可以通过 yii\web\UrlManager::enablePrettyUrl = true 来实现
  • 参数的路径化可以通过路由规则来实现
  • 假后缀(fake suffix) .html 可以通过 yii\web\UrlManager::suffix = '.html' 来实现

这里点一点,有个印象就可以下,在 Url管理 部分就会讲到了。

路由规则

所谓孤掌难鸣,urlManager要发挥功能靠单打独斗是不行的,还要有另外一个的东东来配合。 这就是我们本篇要重点讲的:路由规则。

路由规则是指 urlManager 用于解析请求或生成URL的规则。 一个路由规则必须实现 yii\web\UrlRuleInterface 接口,这个接口定义了两个方法:

  • 用于解析请求的 yii\web\UrlRuleInterface::parseRequest()
  • 用于生成URL的 yii\web\UrlRuleInterface::createUrl()

Yii中,使用 yii\web\UrlRule 来表示路由规则,一般这个类是足够开发者使用的。 但是,如果开发者想自己实现解析请求或生成URL的逻辑,可以以这个类为基类进行派生, 并重载 parseRuquest()createUrl()

以下是配置文件中urlManager组件的路由规则配置部分,以几个相对简单、典型的路由规则的为例, 先有个感性认识:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
'rules' => [
    // 为路由指定了一个别名,以 post 的复数形式来表示 post/index 路由
    'posts' => 'post/index',

    // id 是命名参数,post/100 形式的URL,其实是 post/view&id=100
    'post/<id:\d+>' => 'post/view',

    // controller action 和 id 以命名参数形式出现
    '<controller:(post|comment)>/<id:\d+>/<action:(create|update|delete)>'
        => '<controller>/<action>',

    // 包含了 HTTP 方法限定,仅限于DELETE方法
    'DELETE <controller:\w+>/<id:\d+>' => '<controller>/delete',

    // 需要将 Web Server 配置成可以接收 *.digpage.com 域名的请求
    'http://<user:\w+>.digpage.com/<lang:\w+>/profile' => 'user/profile',
]

上面的例子并没有穷尽路由规则的例子,可以玩的花样还有很多。至于这些例子所表达的规则, 读者朋友们可以发挥想像去猜测,相信你们绝对可以猜个八九不离十。

目前不需要了解太多,只需大致了解上面这个数组用于为urlManager声明路由规则。 数组的键相当于请求(需要解析的或将要生成的),而元素的值则对应的路由, 即 controller/action 。请求部分可称为pattern,路由部分则可称为route。 对于这2个部分的形式,大致上可以这么看:

  • pattern 是从正则表达式变形而来。去除了两端的 / # 等分隔符。 特别注意别在pattern两端画蛇添足加上分隔符。
  • pattern 中可以使用正则表达式的命名参数,以供route部分引用。这个命名参数也是变形了的。 对于原来 (?P<name>pattern) 的命名参数,要变形成 <name:pattern>
  • pattern 中可以使用HTTP方法限定。
  • route 不应再含有正则表达式,但是可以按 <name> 的形式引用命名参数。

也就是说,解析请求时,Yii从左往右使用这个数组;而生成URL时Yii从右往左使用这个数组。

至于具体实现过程,我们马上就会讲。

首先是 yii\web\UrlRule 的代码,让我们来大致看一看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class UrlRule extends Object implements UrlRuleInterface
{
    // 用于 $mode 表示路由规则的2种工作模式:仅用于解析请求和仅用于生成URL。
    // 任意不为1或2的值均表示两种模式同时适用,
    // 一般未设定或为0时即表示两种模式均适用。
    const PARSING_ONLY = 1;
    const CREATION_ONLY = 2;

    // 路由规则名称
    public $name;

    // 用于解析请求或生成URL的模式,通常是正则表达式
    public $pattern;

    // 用于解析或创建URL时,处理主机信息的部分,如 http://www.digpage.com
    public $host;

    // 指向controller 和 action 的路由
    public $route;

    // 以一组键值对数组指定若干GET参数,在当前规则用于解析请求时,
    // 这些GET参数会被注入到 $_GET 中去
    public $defaults = [];

    // 指定URL的后缀,通常是诸如 ".html" 等,
    // 使得一个URL看起来好像指向一个静态页面。
    // 如果这个值未设定,使用 UrlManager::suffix 的值。
    public $suffix;

    // 指定当前规则适用的HTTP方法,如 GET, POST, DELETE 等。
    // 可以使用数组表示同时适用于多个方法。
    // 如果未设定,表明当前规则适用于所有方法。
    // 当然,这个属性仅在解析请求时有效,在生成URL时是无效的。
    public $verb;

    // 表明当前规则的工作模式,取值可以是 0, PARSING_ONLY, CREATION_ONLY。
    // 未设定时等同于0。
    public $mode;

    // 表明URL中的参数是否需要进行url编码,默认是进行。
    public $encodeParams = true;

    // 用于生成新URL的模板
    private $_template;

    // 一个用于匹配路由部分的正则表达式,用于生成URL
    private $_routeRule;

    // 用于保存一组匹配参数的正则表达式,用于生成URL
    private $_paramRules = [];

    // 保存一组路由中使用的参数
    private $_routeParams = [];

    // 初始化
    public function init() {...}

    // 用于解析请求,由UrlRequestInterface接口要求
    public function parseRequest($manager, $request) {...}

    // 用于生成URL,由UrlRequestInterface接口要求
    public function createUrl($manager, $route, $params) {...}
}

从上面代码看, UrlRule 的属性(可配置项)比较多。各属性的意义在注释中已经写清楚了,这里就不再复述。 但是我们要着重分析一下初始化函数 yii\web\UrlRule::init() ,来加深对这些属性的理解:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
public function init()
{
    // 一个路由规则必定要有 pattern ,否则是没有意义的,
    // 一个什么都没规定的规定,要来何用?
    if ($this->pattern === null) {
        throw new InvalidConfigException('UrlRule::pattern must be set.');
    }

    // 不指定规则匹配后所要指派的路由,Yii怎么知道将请求交给谁来处理?
    // 不指定路由,Yii怎么知道这个规则可以为谁创建URL?
    if ($this->route === null) {
        throw new InvalidConfigException('UrlRule::route must be set.');
    }

    // 如果定义了一个或多个verb,说明规则仅适用于特定的HTTP方法。
    // 既然是HTTP方法,那就要全部大写。
    // verb的定义可以是字符串(单一的verb)或数组(单一或多个verb)。
    if ($this->verb !== null) {
        if (is_array($this->verb)) {
            foreach ($this->verb as $i => $verb) {
                $this->verb[$i] = strtoupper($verb);
            }
        } else {
            $this->verb = [strtoupper($this->verb)];
        }
    }

    // 若未指定规则的名称,那么使用最能区别于其他规则的 $pattern
    // 作为规则的名称
    if ($this->name === null) {
        $this->name = $this->pattern;
    }

    // 删除 pattern 两端的 "/",特别是重复的 "/",
    // 在写 pattern 时,虽然有正则的成分,但不需要在两端加上 "/",
    // 更不能加上 "#" 等其他分隔符
    $this->pattern = trim($this->pattern, '/');

    // 如果定义了 host ,将 host 部分加在 pattern 前面,作为新的 pattern
    if ($this->host !== null) {
        // 写入的host末尾如果已经包含有 "/" 则去掉,特别是重复的 "/"
        $this->host = rtrim($this->host, '/');
        $this->pattern = rtrim($this->host . '/' . $this->pattern, '/');

    // 既未定义 host ,pattern 又是空的,那么 pattern 匹配任意字符串。
    // 而基于这个pattern的,用于生成的URL的template就是空的,
    // 意味着使用该规则生成所有URL都是空的。
    // 后续也无需再作其他初始化工作了。
    } elseif ($this->pattern === '') {
        $this->_template = '';
        $this->pattern = '#^$#u';
        return;

    // pattern 不是空串,且包含有 '://',以此认定该pattern包含主机信息
    } elseif (($pos = strpos($this->pattern, '://')) !== false) {
        // 除 '://' 外,第一个 '/' 之前的内容就是主机信息
        if (($pos2 = strpos($this->pattern, '/', $pos + 3)) !== false) {
            $this->host = substr($this->pattern, 0, $pos2);

        // '://' 后再无其他 '/',那么整个 pattern 其实就是主机信息
        } else {
            $this->host = $this->pattern;
        }

    // pattern 不是空串,且不包含主机信息,两端加上 '/' ,形成一个正则
    } else {
        $this->pattern = '/' . $this->pattern . '/';
    }

    // route 也要去掉两头的 '/'
    $this->route = trim($this->route, '/');

    // 从这里往下,请结合流程图来看

    // route 中含有 <参数> ,则将所有参数提取成 [参数 => <参数>]
    // 存入 _routeParams[],
    // 如 ['controller' => '<controller>', 'action' => '<action>'],
    // 留意这里的短路判断,先使用 strpos(),快速排除无需使用正则的情况
    if (strpos($this->route, '<') !== false &&
        preg_match_all('/<(\w+)>/', $this->route, $matches)) {
        foreach ($matches[1] as $name) {
            $this->_routeParams[$name] = "<$name>";
        }
    }

    // 这个 $tr[] 和 $tr2[] 用于字符串的转换
    $tr = [
        '.' => '\\.',
        '*' => '\\*',
        '$' => '\\$',
        '[' => '\\[',
        ']' => '\\]',
        '(' => '\\(',
        ')' => '\\)',
    ];
    $tr2 = [];

    // pattern 中含有 <参数名:参数pattern> ,
    // 其中 ':参数pattern' 部分是可选的。
    if (preg_match_all('/<(\w+):?([^>]+)?>/', $this->pattern, $matches,
        PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
        foreach ($matches as $match) {
            // 获取 “参数名”
            $name = $match[1][0];

            // 获取 “参数pattern” ,如果未指定,使用 '[^\/]' ,
            // 表示匹配除 '/' 外的所有字符
            $pattern = isset($match[2][0]) ? $match[2][0] : '[^\/]+';

            // 如果 defaults[] 中有同名参数,
            if (array_key_exists($name, $this->defaults)) {
                // $match[0][0] 是整个 <参数名:参数pattern> 串
                $length = strlen($match[0][0]);
                $offset = $match[0][1];

                // pattern 中 <参数名:参数pattern> 两头都有 '/'
                if ($offset > 1 && $this->pattern[$offset - 1] === '/'
                    && $this->pattern[$offset + $length] === '/') {
                    // 留意这个 (?P<name>pattern) 正则,这是一个命名分组。
                    // 仅冠以一个命名供后续引用,使用上与直接的 (pattern) 没有区别
                    // 见:http://php.net/manual/en/regexp.reference.subpatterns.php
                    $tr["/<$name>"] = "(/(?P<$name>$pattern))?";
                } else {
                    $tr["<$name>"] = "(?P<$name>$pattern)?";
                }

            // defaults[]中没有同名参数
            } else {
                $tr["<$name>"] = "(?P<$name>$pattern)";
            }

            // routeParams[]中有同名参数
            if (isset($this->_routeParams[$name])) {
                $tr2["<$name>"] = "(?P<$name>$pattern)";

            // routeParams[]中没有同名参数,则将 参数pattern 存入 _paramRules[] 中。
            // 留意这里是怎么对  参数pattern  进行处理后再保存的。
            } else {
                $this->_paramRules[$name] = $pattern === '[^\/]+' ? '' :
                    "#^$pattern$#u";
            }
        }
    }

    // 将 pattern 中所有的 <参数名:参数pattern> 替换成 <参数名> 后作为 _template
    $this->_template = preg_replace('/<(\w+):?([^>]+)?>/', '<$1>', $this->pattern);

    // 将 _template 中的特殊字符及字符串使用 tr[] 进行转换,并作为最终的pattern
    $this->pattern = '#^' . trim(strtr($this->_template, $tr), '/') . '$#u';

    // 如果指定了 routePrams 还要使用 tr2[] 对 route 进行转换,
    // 并作为最终的 _routeRule
    if (!empty($this->_routeParams)) {
        $this->_routeRule = '#^' . strtr($this->route, $tr2) . '$#u';
    }
}

上面的代码难点在于pattern等的转换过程,有点翻来覆去,转换过去、转换回来的感觉,这里我们先放一放, 秋后再找他们来算帐,注意力先放在 init() 的前半部分,这些代码提醒我们:

  • 规则的 $pattern$route 是必须配置的。
  • 规则的名称 $name 和主机信息 $host 在未配置的情况下,可以从 $pattern 来获取。
  • $pattern 虽然含有正则的成分,但不需要在两端加入 / ,更不能使用 # 等其他分隔符。 Yii会自动为我们加上。
  • 指定 $pattern 为空串,可以使该规则匹配任意的URL。此时基于该规则所生成的所有URL也都是空串。
  • $pattern 中含有 :\\ 时,Yii会认为其中包含了主机信息。此时就不应当再指定 host 。 否则,Yii会将 host 接在这个 pattern 前,作为新的pattern。这会造成该pattern 两段 :\\ , 而这显然不是我们要的。

接下来要啃稍硬点的骨头了,就是 init() 的后半段, 我们以一个普通的 ['post/<action:\w+>/<id:\d+>' => 'post/<action>'] 为例。 同时,我们假设这个路由规则默认有 $defaults['id'] = 100 ,表示在未指定 post 的 id 时, 使用100作为默认的id。那么这个UrlRule的初始过程如 UrlRule路由规则初始化过程示意图 所示。

UrlRule路由规则初始化过程示意图

UrlRule路由规则初始化过程示意图

后续的初始化过程具体如下:

  1. ['post/<action:\w+>/<id:\d+>' => 'post/<action>'] , 我们有 $pattern = '/post/<action:\w+>/<id:\d+>/'$route = 'post/<action>'
  2. 首先从 $route 中提取出由 < > 所包含的部分。这里可以得到 ['action' => '<action>'] 。 将其存入 $_routeParams[ ] 中。
  3. 再从 $pattern 中提取出由 < > 所包含的部分,这里匹配2个部分,1个 <action:\w+> 和1个 <id\d+> 。下面对这2个部分进行分别处理。
  4. 对于 <action:\w+> 由于 $defaults[ ] 中不存在下标为 action 的元素,于是向 $tr[ ] 写入 (?P<$name>$pattern) 形式的元素,得到 $tr['<action>'] = '(?P<$name>\w+)' 。 而对于 <id:\d+> ,由于 $defaults['id'] = 100 ,所以写入 $tr[ ] 的元素形式有所不同, 变成 (/(?P<$name>$pattern))? 。于是有 $tr['<id>'] = (/(?P<$id>\d+))?
  5. 由于在第1步只有 $_routeParams['action'] = '<actoin>' ,而没有下标为 id 的元素。 所以对于 <action:\w+> ,往 tr2[ ] 中写入 ['<action>' => '(?P<action>\w+)'] , 而对于 <id:\d+> 则往 $_paramRules[ ] 中写入 ['id' => '#^\d+$#u']
  6. 上面只是准备工作,接下来开始各种替换。首先将 $pattern 中所有 <name:pattern> 替换成 <name> 并作为 $_template 。因此, $_template = '/post/<action>/<id>/'
  7. 接下来用 $tr[ ]$_template 进行替换,并在两端加上分隔符作为 $pattern 。 于是有 $pattern = '#^post/(?P<action>\w+)(/(?P<id>\d+))?$#u'
  8. 最后,由于第1步中 $_routeParams 不为空,所以需要使用 $tr2[ ]$route 进行替换, 并在两端加上分隔符后,作为 $_routeRule ,于是有 $_routeRule = '#^post/(?P<action>\w+)$#'

这些替换的意义在于方便开发者以简洁的方式在配置文件中书写路由规则,然后将这些简洁的规则, 再替换成规范的正则表达式。让我们来看看这个 init() 的成果吧。仍然以上面的 ['post/<action:\w+>/<id:\d+>' => 'post/<action>'] 为例,经过 init() 处理后,我们最终得到了:

1
2
3
4
5
6
7
$urlRule->route = 'post/<action>';
$urlRule->pattern = '#^post/(?P<action>\w+)(/(?P<id>\d+))?$#u';
$urlRule->_template = '/post/<action>/<id>/';
$urlRule->_routeRule = '#^post/(?P<action>\w+)$#';
$urlRule->_routeParams = ['action' => '<action>'];
$urlRule->_paramRules = ['id' => '#^\d+$#u'];
// $tr 和 $tr2 作为局部变量已经完成历史使命光荣退伍了

下面我们来讲讲 UrlRule 是如何创建和解析URL的。

创建URL

URL的创建就 UrlRule 层面来讲,是由 yii\web\UrlRule::createUrl() 负责的, 这个方法可以根据传入的路由和参数创建一个相应的URL来。具体代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
public function createUrl($manager, $route, $params)
{
    // 判断规则是否仅限于解析请求,而不适用于创建URL
    if ($this->mode === self::PARSING_ONLY) {
        return false;
    }
    $tr = [];

    // 如果传入的路由与规则定义的路由不一致,
    // 如 post/view 与 post/<action> 并不一致
    if ($route !== $this->route) {

        // 使用 $_routeRule 对 $route 作匹配测试
        if ($this->_routeRule !== null && preg_match($this->_routeRule,
            $route, $matches)) {

            // 遍历所有的 _routeParams
            foreach ($this->_routeParams as $name => $token) {
                // 如果该路由规则提供了默认的路由参数,
                // 且该参数值与传入的路由相同,则可以省略
                if (isset($this->defaults[$name]) &&
                    strcmp($this->defaults[$name], $matches[$name]) === 0) {
                    $tr[$token] = '';
                } else {
                    $tr[$token] = $matches[$name];
                }
            }
        // 传入的路由完全不能匹配该规则,返回
        } else {
            return false;
        }
    }

    // 遍历所有的默认参数
    foreach ($this->defaults as $name => $value) {
        // 如果默认参数是路由参数,如 <action>
        if (isset($this->_routeParams[$name])) {
            continue;
        }

        // 默认参数并非路由参数,那么看看传入的 $params 里是否提供该参数的值。
        // 如果未提供,说明这个规则不适用,直接返回。
        if (!isset($params[$name])) {
            return false;

        // 如果 $params 提供了该参数,且参数值一致,则 $params 可省略该参数
        } elseif (strcmp($params[$name], $value) === 0) {
            unset($params[$name]);

            // 且如果有该参数的转换规则,也可置为空。等下一转换就消除了。
            if (isset($this->_paramRules[$name])) {
                $tr["<$name>"] = '';
            }

        // 如果 $params 提供了该参数,但又与默认参数值不一致,
        // 且规则也未定义该参数的正则,那么规则无法处理这个参数。
        } elseif (!isset($this->_paramRules[$name])) {
            return false;
        }
    }

    // 遍历所有的参数匹配规则
    foreach ($this->_paramRules as $name => $rule) {

        // 如果 $params 传入了同名参数,且该参数不是数组,且该参数匹配规则,
        // 则使用该参数匹配规则作为转换规则,并从 $params 中去掉该参数
        if (isset($params[$name]) && !is_array($params[$name])
            && ($rule === '' || preg_match($rule, $params[$name]))) {
            $tr["<$name>"] = $this->encodeParams ?
                urlencode($params[$name]) : $params[$name];
            unset($params[$name]);

        // 否则一旦没有设置该参数的默认值或 $params 提供了该参数,
        // 说明规则又不匹配了
        } elseif (!isset($this->defaults[$name]) || isset($params[$name])) {
            return false;
        }
    }

    // 使用 $tr 对 $_template 时行转换,并去除多余的 '/'
    $url = trim(strtr($this->_template, $tr), '/');

    // 将 $url 中的多个 '/' 变成一个
    if ($this->host !== null) {
        // 再短的 host 也不会短于 8
        $pos = strpos($url, '/', 8);
        if ($pos !== false) {
            $url = substr($url, 0, $pos) . preg_replace('#/+#', '/',
                substr($url, $pos));
        }
    } elseif (strpos($url, '//') !== false) {
        $url = preg_replace('#/+#', '/', $url);
    }

    // 加上 .html 之类的假后缀
    if ($url !== '') {
        $url .= ($this->suffix === null ? $manager->suffix : $this->suffix);
    }

    // 加上查询参数们
    if (!empty($params) && ($query = http_build_query($params)) !== '') {
        $url .= '?' . $query;
    }
    return $url;
}

我们以上面提到 ['post/<action:\w+>/<id:\d+>' => 'post/<action>'] 路由规则来创建一个URL, 就假设要创建路由为 post/viewid=100 的URL吧。具体的流程如 UrlRule创建URL的流程示意图 所示。

UrlRule创建URL的流程示意图

UrlRule创建URL的流程示意图

结合代码 UrlRule创建URL的流程示意图 ,URL的创建过程大体上分4个阶段:

第一阶段

调用 createUrl(Yii::$app->urlManager, 'post/view', ['id'=>101])

传入的路由为 post/view 与规则定义的路由 post/<action> 不同。 但是, post/view 可以匹配路由规则的 $_routeRule = '#^post/(?P<action>\w+)$#' 。 所以,认为该规则是适用的,可以接着处理。而如果连正则也匹配不上,那就说明该规则不适用,返回 false

遍历路由规则的所有 $_routeParams ,这个例子中, $_routeParams['action' => '<action>'] 。 这个我们称为路由参数规则,即出现在路由部分的参数。

对于这个路由参数规则,我们并未为其设置默认值。但实际使用中,有的时候可能会提供默认的路由参数, 比如对于形如 post/index 之类的,我们经常想省略掉 index ,那么就可以为 <action> 提供一个默认值 index

对于有默认值的情况,我们的头脑要清醒,目前是要用路由规则来创建URL, 规则所定义的默认值并非意味着可以处理不提供这个路由参数值的路由, 而是说在处理路由参数值与默认值相等的路由时,最终生成的URL中可以省略该默认值。

即默认是体现在最终的URL上,是体现在URL解析过程中的默认,而不是体现在创建URL的过程中。 也就是说,对于 post/index 类的路由,如果默认 index ,则生成的URL中可以不带有 index

这里没有默认值,相对简单。由于 $_routeRule 正则中,使用了命名分组,即 (?P<action>...) 。 所以,可以很方便地使用 $matches['action'] 来捕获 \w+ 所匹配的部分,这里匹配的是 view 。 故写入 $tr['<action>'] = view

第二阶段

接下来遍历所有的默认参数,当然不包含路由参数部分,因为这个在前面已经处理过了。这里只处理余下的参数。 注意这个默认值的含义,如同我们前面提到的,这里是创建时必须提供,而生成出来的URL可以省略的意思。 因此,对于 $params 中未提供相应参数的,或提供了参数值但与默认值不一致,且规则没定义参数的正则的, 均说明规则不适用。 只有在 $params 提供相应参数,且参数值与默认值一致或匹配规则时方可进行后续处理。

这里我们有一个默认参数 ['id' => 100] 。传入的 $params['id'] => 100 。两者一致,规则适用。

于将该参数从 $params 中删去。

接下来,看看是否为参数 id 定义了匹配规则。还真有 $_paramRules['id'] => '#^\d+$#u' 。 但这也用不上了,因为这是在创建URL,该参数与默认值一致,等下的 id 是要从URL中去除的。 因此,写入 $tr['<id>'] = ''

第三阶段

再接下来,就是遍历所有参数匹配规则 $_paramRules 了。对于这个例子, 只有 $_paramRules = ['id' => '#^\d+$#u']

如果 $params 中并未定义该 id 参数,那么这一步什么也不用做,因为没有东西要写到URL中去。

而一旦定义了 id ,那么就需要看看当前路由规则是否适用了。 判断的标准是所提供的参数不是数组,且匹配 $_paramRules 所定义的规则。 而如果 $parasm['id'] 是数组,或不与规则匹配,或定义了 id 的参数规则却没有定义其默认值而 $params['id'] 又未提供,则规则不适用。

这里,我们在是在前面处理默认参数时,已经将 id$params 中删去。 但判断到规则为 id 定义了默认值的,所以认为规则仍然适用。只是,这里实际上不用做任何处理。 如果需要处理的情况,也是将该参数从 $params 中删去,然后写入 $tr['<id>'] = 100

第四阶段

上面一切准备就绪之后,就可以着手生成URL了。主要用 $tr 对路由规则的 $_template 进行转换。 这里, $_template = '/post/<action>/<id>/' ,因此,转换后再去除多余的 / 就变成了 $url = 'post/view' 。其中 id=100 被省略掉了。

最后再分别接上 .html 的后缀和查询参数,一个URL post/view.html 就生成了。 其中,查询参数串是 $params 中剩下的内容,使用PHP的 http_build_query( ) 生成的。

从创建URL的过程来看,重点是完成这么几项工作:

  • 看规则是否适用,主要标准是路由与规则定义的是否匹配,规则通过默认值或正则所定义的参数,是否都提供了。
  • 看看当前要创建的URL是否与规则定义的默认的路由参数和查询参数一致,对于一致的,可以省略。
  • 看将这些与默认值一致的,规则已经定义了的参数从 $params 删除,余下的,转换成最终URL的查询参数串。

解析URL

说完了路由规则生成URL的过程,再来看看其逻辑上的逆过程,即URL的解析。

先从路由规则 yii\web\UrlRule::parseRequest() 的代码入手:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public function parseRequest($manager, $request)
{
    // 当前路由规则仅限于创建URL,直接返回 false。
    // 该方法返回false表示当前规则不适用于当前的URL。
    if ($this->mode === self::CREATION_ONLY) {
        return false;
    }

    // 如果规则定义了适用的HTTP方法,则要看当前请求采用的方法是否可以接受
    if (!empty($this->verb) && !in_array($request->getMethod(),
        $this->verb, true)) {
        return false;
    }

    // 获取URL中入口脚本之后、查询参数 ? 号之前的全部内容,即为PATH_INFO
    $pathInfo = $request->getPathInfo();
    // 取得配置的 .html 等假后缀,留意 (string)null 转成空串
    $suffix = (string) ($this->suffix === null ? $manager->suffix :
        $this->suffix);

    // 有假后缀且有PATH_INFO
    if ($suffix !== '' && $pathInfo !== '') {
        $n = strlen($suffix);

        // 当前请求的 PATH_INFO 以该假后缀结尾,留意 -$n 的用法
        if (substr_compare($pathInfo, $suffix, -$n, $n) === 0) {
            $pathInfo = substr($pathInfo, 0, -$n);

            // 整个PATH_INFO 仅包含一个假后缀,这是无效的。
            if ($pathInfo === '') {
                return false;
            }

        // 应用配置了假后缀,但是当前URL却不包含该后缀,返回false
        } else {
            return false;
        }
    }

    // 规则定义了主机信息,即 http://www.digpage.com 之类,那要把主机信息接回去。
    if ($this->host !== null) {
        $pathInfo = strtolower($request->getHostInfo()) .
            ($pathInfo === '' ? '' : '/' . $pathInfo);
    }

    // 当前URL是否匹配规则,留意这个pattern是经过 init() 转换的
    if (!preg_match($this->pattern, $pathInfo, $matches)) {
        return false;
    }

    // 遍历规则定义的默认参数,如果当前URL中没有,则加入到 $matches 中待统一处理,
    // 默认值在这里发挥作用了,虽然没有,但仍视为捕获到了。
    foreach ($this->defaults as $name => $value) {
        if (!isset($matches[$name]) || $matches[$name] === '') {
            $matches[$name] = $value;
        }
    }
    $params = $this->defaults;
    $tr = [];

    // 遍历所有匹配项,注意这个 $name 的由来是 (?P<name>...) 的功劳
    foreach ($matches as $name => $value) {
        // 如果是匹配一个路由参数
        if (isset($this->_routeParams[$name])) {
            $tr[$this->_routeParams[$name]] = $value;
            unset($params[$name]);

        // 如果是匹配一个查询参数
        } elseif (isset($this->_paramRules[$name])) {
            // 这里可能会覆盖掉 $defaults 定义的默认值
            $params[$name] = $value;
        }
    }

    // 使用 $tr 进行转换
    if ($this->_routeRule !== null) {
        $route = strtr($this->route, $tr);
    } else {
        $route = $this->route;
    }
    Yii::trace("Request parsed with URL rule: {$this->name}", __METHOD__);
    return [$route, $params];
}

我们以 http://www.digpage.com/post/view.html 为例,来看看上面的代码是如何解析成路由 ['post/view', ['id'=>100]] 的。注意,这里我们仍然假设路由规则提供了 id=100 默认值。 而如果路由规则未提供该默认值,则请求形式要变成 http://www.digapge.com/post/view/100.html 。 同时,规则的 $pattern 也会不同:

1
2
3
4
5
// 未提供默认值,id 必须提供,否则匹配不上
$pattern = '#^post/(?P<action>\w+)/(?P<id>\d+)?$#u';

// 提供了默认值,id 可以不提供,照样可以匹配上
$pattern = '#^post/(?P<action>\w+)(/(?P<id>\d+))?$#u';

这个不同的原因在于 UrlRule::init() ,读者朋友们可以回头看看。

在讲URL的解析前,让我们先从请求的第一个经手人Web Server说起,在 附录2:Yii的安装 中讲到Web Server的配置时, 我们将所有未命中的请求转交给入口脚本来处理:

location / {
    try_files $uri $uri/ /index.php?$args;
}

# fastcgi.conf
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

以Nginx为例, try_files 会依次尝试处理:

  1. /post/view.html ,这个如果真有一个也就罢了,但其实多半不存在。
  2. /post/view.html/ ,这个目录一般也不存在。
  3. /index.php ,这个正是入口脚本,可以处理。至此,Nginx与Yii顺利交接。

由于请求最终交给入口脚本来处理,且我们隐藏了URL中入口脚本名,上述请求还原回来的话, 应该是 http://www.digapge.com/index.php/post/view.html 。 自然,这 post/view.html 就是PATH_INFO了。 有关PATH_INFO的更多知识,请看 Web应用Request 部分的内容。

好了,在Yii从Web Server取得控制权之后,就是我们大显身手的时候了。在解析过程中,UrlRule主要做了这么几件事:

  • 通过 PATH_INFO 还原请求,如去除假后缀,开头接上主机信息等。还原后的请求为 post/view
  • 看看当前请求是否匹配规则,这个匹配包含了主机、路由、参数等各方面的匹配。如不匹配,说明规则不适用, 返回 false 。在这个例子中,规则并未定义主机信息方面的规则, 规则中 $pattern = '#^post/(?P<action>\w+)(/(?P<id>\d+))?$#u' 。这与还原后的请求完全匹配。 如果URL没有使用默认值 id = 100 ,如 post/view/101.html ,也是同样匹配的。
  • 看看请求是否提供了规则已定义了默认值的所有参数,如果未提供,视为请求提供了这些参数,且他的值为默认值。 这里URL中并未提供 id 参数,所以,视为他提供了 id = 100 的参数。简单粗暴而有效。
  • 使用规则定义的路由进行转换,生成新的路由。 再把上一步及当前所有参数作为路由的参数,共同组装成一个完整路由。

具体的转换流程可以看看 UrlRule路由规则解析URL的过程示意图

UrlRule解析URL过程示意图

UrlRule路由规则解析URL的过程示意图

如果觉得《深入理解Yii2.0》对您有所帮助,也请帮助《深入理解Yii2.0》。 谢谢!