从零开始搭建自己的Swoole框架(五)为路由模块添加解析路径参数功能

前言

上文已经实现简单路由,但还没办法解析路由参数。

例如:

1
http://www.huotublog.com/article/1

包含了路径参数,即 article 后面的 1。

本章将为路由模块添加解析路径参数的功能。

另外,Query 参数不叫路由参数,例如:

1
http://www.huotublog.com/article/1?a=222

这里的 a 是 query 参数,不是路径参数。

匹配原理

假设路径参数可以有无限多个,在这样无法确定数量情况,

无法使用一般的 if-else 来获取。

这个时候就需要用到正则表达式了。

正则表达式可以按照某种规则来匹配特殊的字符串,包括替换字符串。

1
2
3
4
5
6
7
<?php

$url = '/article/123';
$pattern = '/article\/(\d+)/';
preg_match_all($pattern, $url, $res);

var_dump($res);

以上示例输出结果:123

正则表达式不仅可以匹配特定的规则,而且只要加上括号就可以把匹配规则视为一个变量单独取出来。

如果不了解正则表达式可以自行搜索,这里不扩展。

因为我的路由配置参考 Laravel,所以要实现 Laravel 一样的效果:

1
2
/article/{id}
/article/{id}/edit

用花括号包起来的部分视为变量,即有如下匹配规则:

1
2
/article/{id}       =>  /article/1
/article/{id}/edit => /article/1/edit

还要获取路径上面的变量,也就是说 {id} 部分视为一个路径参数。

既要匹配路由规则,又要获得路径参数,一共需要两步才能实现这样的效果。

第一步是把 /article/{id} 转化为一个正则表达式,

第二步是拿上一步得到的正则表达式去匹配 URI。

路径参数不只是数字,也有可能是别的什么,甚至是中文都可以当做路径参数。

1
2
# 这是一个正确的 URI
http://www.huotublog.com/article/我的swoole框架

只不过你在浏览器输入上面的地址,然后再复制下来,会被 urlencode。

结果就会变成这样:

1
https://huotublog.com/article/%E6%88%91%E7%9A%84swoole%E6%A1%86%E6%9E%B6

所以如果我们只匹配数字是不行的,而是要用全匹配 .

.(点)在正则表达式里是匹配除了换行之外所有字符串

例如有一个包括两个路径参数的路由:

1
2
3
4
5
6
7
8
$url = '/article/{id}/show/{classify}';
$pattern = '/.*?\/(\{.*?\})/';

preg_match_all($pattern, $url, $result);

$transform = str_replace($result[1], '(.*?)', $url);

var_dump($transform);

这里的规则 .*? 是三个正则表达式符号,意思是说尽可能多的匹配字符串。

加上括号就可以取出匹配的字符串了,匹配到的结果存入 $result 变量,

也就是说一共会得到两个值:{id}{classify}

然后用 PHP 的 str_replace 函数进行简单的替换:

1
string(25) "/article/(.*?)/show/(.*?)"

如果不懂正则表达式,直接套用 (.*?) 即可,既简单又粗暴

替换后的字符串还不是表达式,只是加入了正则表达式符号而已。

就跟 json 字符串不是 json 对象一个道理,还需要转换一下才能变成表达式

斜杠是正则表达式里比较敏感的字符,过滤掉干扰字符串,修改为完整的正则表达式:

1
2
3
4
$transform = '/article/(.*?)/show/(.*?)'
$pattern = '/' . str_replace('/', '\/', $transform) . '$/';

var_dump($pattern);

rtrim 去掉右侧的斜杠,防止匹配不到

输出结果:

1
string(32) "/\/article\/(.*?)\/show\/(.*?)$/"

转换得到的正则表达式,用它就可以匹配出路由规则中的变量:

1
2
3
4
5
6
7
8
// 用户请求的 URI
$requestUri = '/article/123/show/abc';

// 通过匹配得到参数
preg_match_all('/\/article\/(.*?)\/show\/(.*?)$/', $requestUri, $params);

var_dump($params);

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
array(3) {
[0]=>
array(1) {
[0]=>
string(21) "/article/123/show/abc"
}
[1]=>
array(1) {
[0]=>
string(3) "123"
}
[2]=>
array(1) {
[0]=>
string(3) "abc"
}
}

路由参数解析原理已经搞清楚了,接下来就在路由解释器里实现这个功能。

实现解析路由功能

有一个特殊的路由,即 /(首页的地址),

所有的路由都带有 /,导致无法正确匹配,因此这个路由需要单独判断。

RouteParams:解析参数传给控制器

RouteParams 增加一个新的变量用来保存替换后的正则表达式:

1
2
3
4
5
/**
* 正则表达式匹配规则
* @var string
*/
public $pattern;

修改

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
public function createResponse($request, $response)
{
// 判断请求方法是否正确
if ($this->method != RequestMethod::ANY && $request->server['request_method'] != $this->method) {
(new MethodErrorResponse())->response($request, $response, $this);
return;
}

// 判断方法是否存在
$controllerName = $this->getFullControllerName();
if (!class_exists($controllerName)) {
(new ClassNotFoundResponse())->response($request, $response, $this);
}

$action = $this->action;

// 不存在方法则返回404
if (!method_exists($controllerName, $action)) {
(new ActionNotFoundResponse())->response($request, $response, $this);
return;
}

// 实例化类
$controllerObject = new $controllerName($request, $response, $this->name);

$uri = rtrim($request->server['request_uri'], '/');

if ($uri != '') {
// 匹配路径参数
preg_match_all($this->pattern, $uri, $result);

if (count($result[0]) != 0) {

$params = [];

for ($i = 1; $i < count($result); $i++) {
$params[] = $result[$i][0];
}

$controllerObject->setRouteParams($params);
}
}

// ... 以后的中间件写在这里

// 执行方法
$controllerObject->$action();
}

在 RouteParams 创建出控制器对象的时候解析出路径参数,把路径参数传给控制器。

Controller:接收路径参数

控制器还没有 setRouteParams 方法,修改控制器的基类:

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
<?php

namespace FireRabbitEngine\Module\Controller;

class Controller
{
protected $request, $response, $route, $routeParams;

public function __construct($request, $response, $route)
{
$this->request = $request;
$this->response = $response;
$this->route = $route;
}

public function showMessage($message)
{
$this->response->header("Content-Type", "text/html; charset=utf-8");
$this->response->end($message);
}

public function setRouteParams($params)
{
$this->routeParams = $params;
}
}

如此一来控制器也能够获取到路径参数了。

Router:赋值匹配规则

Router 在生成配置的时候,可以同时生成路由的正则匹配规则。

修改生成路由配置的方法:

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
/**
* 将路由加入配置数组
* @param $method
* @param $route
* @param $controller
* @throws RouteParamException
*/
protected function addRoute($method, $route, $controller)
{
$param = new RouteParams();

$param->method = $method;
$param->route = $route;

// 格式为:控制器@方法名
$actions = explode('@', $controller);

// 如果不按照规则设置控制器和方法名则抛出异常
if (count($actions) != 2) {
throw new RouteParamException('控制器和方法名称错误,应该为:控制器名称@方法名称');
}

$param->controller = $actions[0];
$param->action = $actions[1];
$param->namespace = $this->namespace;
$param->pattern = $this->getPattern($route);

self::$routes[] = $param;
}

protected function getPattern($route)
{
if ($route == '/') {
return '';
}

$pattern = '/.*?\/(\{.*?\})/';

preg_match_all($pattern, $route, $result);

// 如果第一个数组的个数为0,表示没有匹配到路径参数
if (count($result[0]) == 0) {
return '/' . str_replace('/', '\/', $route) . '/';
}

$transform = str_replace($result[1], '(.*?)', $route);
$transform = '/' . str_replace('/', '\/', $transform) . '$/';

return $transform;
}

getPattern 方法将路由中自定义的规则解析成对应的正则表达式,

然后赋值给 RouteParams 对象。

Router:修改匹配规则

现在已经可以获取到正则表达式了,接下来修改路由匹配规则。

将之前 Router 直接匹配的方法改成正则匹配,

PHP 有一个用来测试正则匹配结果的函数:preg_match

没有匹配到正则表达式的字符,这个函数会返回 0,以此来判断是否符合路由规则。

修改 findRoute 方法:

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
 /**
* 寻找路由
* @param $request
* @return mixed|null
*/
public function findRoute($request)
{
$uri = rtrim($request->server['request_uri'], '/');

foreach (self::$routes as $route) {

if (empty($uri)) {

if ($route->route != '/') {
continue;
}

return $route;

} else if ($route->pattern != '' && preg_match($route->pattern, $uri) != 0) {

return $route;
}
}

return null;
}

路由解释器的解析功能也改完了,接下来创建一个新的控制器来测试是否能获取到参数。

测试结果

在 app/controller/Home 创建 ArticleController:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

namespace App\controller\Home;

use FireRabbitEngine\Module\Controller\Controller;

class ArticleController extends Controller
{
public function show()
{
$this->showMessage(json_encode($this->routeParams));
}
}

然后编辑 web.php 添加一条路由配置:

1
$router->get('/article/{id}', 'ArticleController@show');

然后访问地址:http://firerabbit-engine.ht/article/1

可以看到输出了一个数组,且只有一个值:1。

防止路由冲突

由于匹配规则具有先后级,就是书写的顺序。

定义具有歧义的路由时要注意先后顺序:

1
2
$router->get('/user/{id}', 'UserController@test');
$router->get('/user/home', 'UserController@home');

上面将 / 后面的参数视为 id 变量,

所以会匹配到第一个路由,第二个路由就被忽略了。

在配置路由的时候要注意先后顺序,最好不要定义具有歧义的路由。

防止路由重名

在 Laravel 里面可以有相同匹配规则但是请求方法不同的路由,

例如:

1
2
$router->get('/user/home', 'UserController@homeGet');
$router->post('/user/home', 'UserController@homePost');

我设计的路由模块不支持这个功能,不能定义同名路由。

可能存在的问题

路由解析是框架里面最核心且使用频率最高的一个部分,

正则表达式的性能可能会比较低。

有一个优化的方法就是增加路由缓存,将匹配成功的路由记录下来,

下次访问先检测这条记录是不是在缓存里,如果是的话就不去正则匹配而是直接从缓存取出记录过的路由对象。

现在还不需要考虑到性能优化,如果到时候真的有问题了再优化也不迟。

文章作者: 火烧兔子
文章链接: http://huotuyouxi.com/2021/02/09/my-swoole-framework-5/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 火兔游戏工作室