路由
路由的功能就是分发请求到不同的控制器,基于的原理就是正则匹配。接下来呢,我们实现一个简单的路由器,实现的能力是
- 对于静态的路由(没占位符的),正确调用callback
- 对于有占位符的路由,正确调用callback时传入占位符参数,譬如对于路由:/user/{id},当请求为/user/23时,传入参数$args结构为
[
'id' => '23'
]
大致思路
- 我们需要把每个路由的信息管理起来:http方法($method),路由字符串($route),回调($callback),因此需要一个addRoute方法,另外提供短方法get,post(就是把$method写好)
- 对于/user/{id}这样的有占位符的路由字符串,把占位符要提取出来,然后占位符部分变成正则字符串
代码讲解
路由分类
对于注册的路由,需要分成两类(下文提到的$uri是指$_SERVER['REQUEST_URI']去掉查询字符串的值)
- 静态路由(就是没有占位符的路由,例如/articles)
- 带参数路由(有占位符的路由,例如/user/{uid})
其实这是很明显的,因为静态的路由的话,我们只需要和$uri直接比较相等与否就行了,而对于带参数路由,譬如/user/{uid},我们需要在注册的时候,提取占位符名,把{**}这一部分替换为([a-zA-Z0-9_]+)这样的正则字符串,使用()是因为要做分组捕获,把占位符对应的值要取出来。
Dispatcher.php中有两个数组
- $staticRoutes
- $methodToRegexToRoutesMap
分别对应静态路由和带参数路由,另外要注意,这两个数组是二维数组,第一维存储http method,第二维用来存储正则字符串(静态路由自身就是这个值,而带参数路由是把占位符替换后的值),最后的value是一个Route对象
Route类
这个类很好理解,用来存储注册路由的一些信息
- $httpMethod:HTTP方法,有GET,POST,PUT,PATCH,HEAD
- $regex:路由的正则表达式,带参数路由是占位符替换后的值,静态路由自身就是这个值
- $variables:路由占位符参数集合,静态路由就是空数组
- $handler:路由要回调的对象
当然,这个类还可以多存储一点信息,譬如带参数路由最原始的字符串(/user/{uid}),这里简单做了
分发流程
- 根据http method取数据,因为第一维都是http method
- 一个个匹配静态路由
- 对于带参数路由,把所有的正则表达式合起来,形成一个大的正则字符串,而不是一个个匹配(这样效率低)
第一步很简单,主要说明第二步
对于三个独立的正则字符串(定界符是~):
~^/user/([^/]+)/(\d+)$~
~^/user/(\d+)$~
~^/user/([^/]+)$~
我们可以合起来,得到
~^(?:
/user/([^/]+)/(\d+)
| /user/(\d+)
| /user/([^/]+)
)$~x
?:是非捕获型分组
这个转化很简单,我们怎么知道那个正则被匹配了呢??
举个例子:
preg_match($regex, '/user/nikic', $matches);
=> [
"/user/nikic", # 完全匹配
"", "", # 第一个(空)
"", # 第二个(空)
"nikic", # 第三个(被使用)
]
可以看到,第一个非空的位置就可以推断出哪个路由被匹配了(第一个完全匹配要剔除),我们需要一个数组要映射它
[
1 => ['handler0', ['name', 'id']],
3 => ['handler1', ['id']],
4 => ['handler2', ['name']],
]
1是因为排除第一个匹配
3是因为第一个路由有两个占位符
4是因为第二个路由有一个占位符
上面的数组,我们可以注册的methodToRegexToRoutesMap这个量形成的,我是这么写的
$regexes = array_keys($this->methodToRegexToRoutesMap[$httpMethod]);
foreach ($regexes as $regex) {
$routeLookup[$index] = [
$this->methodToRegexToRoutesMap[$httpMethod][$regex]->handler,
$this->methodToRegexToRoutesMap[$httpMethod][$regex]->variables,
];
$index += count($this->methodToRegexToRoutesMap[$httpMethod][$regex]->variables);
}
最后
调用回调函数,返回一个数组,第一个值用来判断最终有没有找到
实现
Route.php类
<?php
namespace SalamanderRoute;
class Route {
/** @var string */
public $httpMethod;
/** @var string */
public $regex;
/** @var array */
public $variables;
/** @var mixed */
public $handler;
/**
* Constructs a route (value object).
*
* @param string $httpMethod
* @param mixed $handler
* @param string $regex
* @param array $variables
*/
public function __construct($httpMethod, $handler, $regex, $variables) {
$this->httpMethod = $httpMethod;
$this->handler = $handler;
$this->regex = $regex;
$this->variables = $variables;
}
/**
* Tests whether this route matches the given string.
*
* @param string $str
*
* @return bool
*/
public function matches($str) {
$regex = '~^' . $this->regex . '$~';
return (bool) preg_match($regex, $str);
}
}
Dispatcher.php
<?php
/**
* User: salamander
* Date: 2017/11/12
* Time: 13:43
*/
namespace SalamanderRoute;
class Dispatcher {
/** @var mixed[][] */
protected $staticRoutes = [];
/** @var Route[][] */
private $methodToRegexToRoutesMap = [];
const NOT_FOUND = 0;
const FOUND = 1;
const METHOD_NOT_ALLOWED = 2;
/**
* 提取占位符
* @param $route
* @return array
*/
private function parse($route) {
$regex = '~^(?:/[a-zA-Z0-9_]*|/\{([a-zA-Z0-9_]+?)\})+/?$~';
if(preg_match($regex, $route, $matches)) {
// 区分静态路由和动态路由
if(count($matches) > 1) {
preg_match_all('~\{([a-zA-Z0-9_]+?)\}~', $route, $matchesVariables);
return [
preg_replace('~{[a-zA-Z0-9_]+?}~', '([a-zA-Z0-9_]+)', $route),
$matchesVariables[1],
];
} else {
return [
$route,
[],
];
}
}
throw new \LogicException('register route failed, pattern is illegal');
}
/**
* 注册路由
* @param $httpMethod string | string[]
* @param $route
* @param $handler
*/
public function addRoute($httpMethod, $route, $handler) {
$routeData = $this->parse($route);
foreach ((array) $httpMethod as $method) {
if ($this->isStaticRoute($routeData)) {
$this->addStaticRoute($httpMethod, $routeData, $handler);
} else {
$this->addVariableRoute($httpMethod, $routeData, $handler);
}
}
}
private function isStaticRoute($routeData) {
return count($routeData[1]) === 0;
}
private function addStaticRoute($httpMethod, $routeData, $handler) {
$routeStr = $routeData[0];
if (isset($this->staticRoutes[$httpMethod][$routeStr])) {
throw new \LogicException(sprintf(
'Cannot register two routes matching "%s" for method "%s"',
$routeStr, $httpMethod
));
}
if (isset($this->methodToRegexToRoutesMap[$httpMethod])) {
foreach ($this->methodToRegexToRoutesMap[$httpMethod] as $route) {
if ($route->matches($routeStr)) {
throw new \LogicException(sprintf(
'Static route "%s" is shadowed by previously defined variable route "%s" for method "%s"',
$routeStr, $route->regex, $httpMethod
));
}
}
}
$this->staticRoutes[$httpMethod][$routeStr] = $handler;
}
private function addVariableRoute($httpMethod, $routeData, $handler) {
list($regex, $variables) = $routeData;
if (isset($this->methodToRegexToRoutesMap[$httpMethod][$regex])) {
throw new \LogicException(sprintf(
'Cannot register two routes matching "%s" for method "%s"',
$regex, $httpMethod
));
}
$this->methodToRegexToRoutesMap[$httpMethod][$regex] = new Route(
$httpMethod, $handler, $regex, $variables
);
}
public function get($route, $handler) {
$this->addRoute('GET', $route, $handler);
}
public function post($route, $handler) {
$this->addRoute('POST', $route, $handler);
}
public function put($route, $handler) {
$this->addRoute('PUT', $route, $handler);
}
public function delete($route, $handler) {
$this->addRoute('DELETE', $route, $handler);
}
public function patch($route, $handler) {
$this->addRoute('PATCH', $route, $handler);
}
public function head($route, $handler) {
$this->addRoute('HEAD', $route, $handler);
}
/**
* 分发
* @param $httpMethod
* @param $uri
*/
public function dispatch($httpMethod, $uri) {
$staticRoutes = array_keys($this->staticRoutes[$httpMethod]);
foreach ($staticRoutes as $staticRoute) {
if($staticRoute === $uri) {
return [self::FOUND, $this->staticRoutes[$httpMethod][$staticRoute], []];
}
}
$routeLookup = [];
$index = 1;
$regexes = array_keys($this->methodToRegexToRoutesMap[$httpMethod]);
foreach ($regexes as $regex) {
$routeLookup[$index] = [
$this->methodToRegexToRoutesMap[$httpMethod][$regex]->handler,
$this->methodToRegexToRoutesMap[$httpMethod][$regex]->variables,
];
$index += count($this->methodToRegexToRoutesMap[$httpMethod][$regex]->variables);
}
$regexCombined = '~^(?:' . implode('|', $regexes) . ')$~';
if(!preg_match($regexCombined, $uri, $matches)) {
return [self::NOT_FOUND];
}
for ($i = 1; '' === $matches[$i]; ++$i);
list($handler, $varNames) = $routeLookup[$i];
$vars = [];
foreach ($varNames as $varName) {
$vars[$varName] = $matches[$i++];
}
return [self::FOUND, $handler, $vars];
}
}
配置
nginx.conf重写到index.php
location / {
try_files $uri $uri/ /index.php$is_args$args;
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
composer.json自动载入
{
"name": "salmander/route",
"require": {},
"autoload": {
"psr-4": {
"SalamanderRoute\\": "SalamanderRoute/"
}
}
}
最终使用
index.php
<?php
include_once 'vendor/autoload.php';
use SalamanderRoute\Dispatcher;
$dispatcher = new Dispatcher();
$dispatcher->get('/', function () {
echo 'hello world';
});
$dispatcher->get('/user/{id}', function ($args) {
echo "user {$args['id']} visit";
});
// Fetch method and URI from somewhere
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];
// 去掉查询字符串
if (false !== $pos = strpos($uri, '?')) {
$uri = substr($uri, 0, $pos);
}
$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
switch ($routeInfo[0]) {
case Dispatcher::NOT_FOUND:
echo '404 not found';
break;
case Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];
$handler($vars);
break;
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。