Lumen路由实现

一、Lumen路由的使用

在了解实现之前,我们先了解其使用的方法以及其作用也是很重要的。Lumen路由对象是在构建Application对象的时候声明的Router对象。Router对象包含几个常用的方法。

$app->get($uri, $callback);//get方法路由
$app->post($uri, $callback);//post方法路由
$app->put($uri, $callback);//put方法路由
$app->patch($uri, $callback);//patch方法路由
$app->delete($uri, $callback);//delete方法路由
$app->option($uri, $callback);//option方法路由

这是最基础的用法。其中$uri是路径,$callback可以是一个闭包,可以是一个字符串,也可以是一个数组,当是一个数组的时候,我们可以为当前路由设置相关的属性,包括设置路由中间件,路由命名,指定相关的动作等等。
举个例子:

//当我们使用get方法访问http://localhost/的时候,会打印出hello world
$app->get('/', function () use ($app) {
    return 'hello world';
});
//当我们使用get方法访问http://localhost/user的时候,会访问到UserController下的index方法(这里默认的命名空间是App\Http\Controllers)
$app->get('/user', ['UserController@index']);

其中,还有一个常用的group方法

//这个方法是为了当一组路由中出现了一些公共属性的时候,可以统一定义这些路由的属性,其中$callback必须是闭包类型
$app->group($attributes, $callback);

二、添加路由的实现

(1) 路由方法的原型

在第一部分里面,我们所说的get,post,put等一些列http动作的方法,他们的原型都是路由中的addRoute方法。比如get方法

public function get($uri, $action)
{
    $this->addRoute('GET', $uri, $action);

    return $this;
}

从中我们可以看到,它实际上调用的是路由对象中的addRoute方法。其他的几个http动作的相关的方法,都类似。

(2) addRoute方法的作用

简单来说,addRoute方法的功能就是把我们当前配置的路由以及其相关的属性,都整合到一个路由数组中。

public function addRoute($method, $uri, $action)
{
    $action = $this->parseAction($action);

    $attributes = null;

    if ($this->hasGroupStack()) {
        $attributes = $this->mergeWithLastGroup([]);
    }
    //这里还有其他实现
    ……
}

addRoute方法第一行,就是解析了我们定义的路由的行为。即第一小节中的我们定义路由的方法中的$callback做一个解析。我们来看看,parseAction方法具体做了什么。

protected function parseAction($action)
{
    if (is_string($action)) {
        return ['uses' => $action];
    } elseif (! is_array($action)) {
        return [$action];
    }

    if (isset($action['middleware']) && is_string($action['middleware'])) {
        $action['middleware'] = explode('|', $action['middleware']);
    }

    return $action;
}

parseAction方法中我们可以看到,当我们定义的路由动作是一个字符串的时候,也就是我们很常用的$app->get(‘/’, ‘Controller@action’)这种形式的时候,会封装成一个数组形式[‘uses’ => ‘Controller@action’]。这种形式其实就是在当前没有定义任何有关路由属性的时候的一个简写。当定义的动作不是字符串和不是数组的时候,会把当前的对象封装到一个数组中。当当前定义的路由动作是一个数组的时候,会把当前的路由中间件拆成一个数组。总而言之,当通过该方法解析后,我们得到的就是一个路由属性的数组。

addRoute方法中的前几行,还有一个hasGroupStack的方法,这个方法是干嘛的呢?我们来看看这个方法的内部实现

//判断当前是否定义了公共属性
public function hasGroupStack()
{
    return ! empty($this->groupStack);
}

从实现中我们可以看出来,这个方式就是只是简单的判断了一下当前对象中的groupStack属性是否为空,那么这个属性是在哪里操作的呢?这就引出了我们的group方法中的作用了。我们来看看group方法的实现

//定义路由下的公共属性
public function group(array $attributes, \Closure $callback)
{
    if (isset($attributes['middleware']) && is_string($attributes['middleware'])) {
        $attributes['middleware'] = explode('|', $attributes['middleware']);
    }

    $this->updateGroupStack($attributes);

    call_user_func($callback, $this);

    array_pop($this->groupStack);
}

//更新当前的公共属性
protected function updateGroupStack(array $attributes)
{
    if (! empty($this->groupStack)) {
        $attributes = $this->mergeWithLastGroup($attributes);
    }

    $this->groupStack[] = $attributes;
}

group方法中首先也是将middleware属性拆分成数组的形式,然后根据新加的属性更新当前路由对象中的groupStack这个属性。更新的时候,是与当前groupStack中最后一个元素进行合并,然后将合并后的属性添加到groupStack的尾部。这种情况只会在group嵌套的时候发生,子group会拥有父group的所有属性,兄弟group之间,他们的属性之间没有任何关系。当更新好当前的groupStack后,会立即调用当前group所定义的闭包,在这个闭包中我们通常就是调用相关的路由方法,或者定义子group。这样,在当前group中所定义的路由,都会拥有group所定义的路由属性。

当获取完当前分组的属性之后,会将当前分组的属性与解析好的action属性做一个合并,将分组的属性应用到action上,这当前包括middleware,namespace,as等属性。

当当前的路由相关属性处理好之后,就将当前的属性加入到路由数组中其中,在addRoute方法中路由数组的定义如下:

$this->routes[$method.$uri] = ['method' => $method, 'uri' => $uri, 'action' => $action];

三、路由解析

当定义好一系列路由之后,我们的服务有关路由的准备工作就做的差不多了。在index.php方法里面我们可以看到这样一行:

$app->run();

运行到这里我们的服务就开始进入解析阶段了。

在run方法里,有这样一行代码:

$response = $this->dispatch($request);

dispatch的核心代码:

return $this->sendThroughPipeline($this->middleware, function () use ($method, $pathInfo) {
    if (isset($this->router->getRoutes()[$method.$pathInfo])) {
        return $this->handleFoundRoute([true, $this->router->getRoutes()[$method.$pathInfo]['action'], []]);
    }

    return $this->handleDispatcherResponse(
        $this->createDispatcher()->dispatch($method, $pathInfo)
    );
});

在这个方法里,会通过当前的$request对象得到请求的方法$method以及请求的$uri,然后通过第二小节中的定义好的路由数组,找到对应的路由的属性。然后通过当前的路由属性中找到对应的控制器和方法(如果是对象的,直接执行该函数),通过容器来调用控制器中的方法(通过容易解决依赖的问题),得到返回值,然后根据不同的返回值类型做出不同的动作。
这里我只是简单描述了一下流程,真实的流程中,还需要通过应用的中间件,路由中间件才会到达对应的控制器方法中。中间件的验证是通过Lumen中的Pipeline对象(管道对象)来进行验证的。其实现原理其实就是通过array_reduce方法来实现的,这里不详细说明。

细心的同学会发现,如何我定义了一个动态路由,例如我定义一个动态路由如下:

$app->get(‘/user/{id}’, function () use ($app) {
    return ‘hello world’;
});

理论上说:当我访问的$uri/user/1时,也应会输出hello world,但按照上面的流程其实是找不到当前的路由的。

的确,按照我们目前说的流程,肯定是找不到这个路由的。于是就引出了我接下来要说的动态路由的添加。

四、动态路由的添加

动态路由的添加是在解析路由过程中添加的。
注意到dispatch方法中的这一行

return $this->handleDispatcherResponse(
        $this->createDispatcher()->dispatch($method, $pathInfo)
    );

这个方法是在第一次没有找到对应路由之后才会执行的。其中
$this->createDispatcher()就添加动态路由中的过程,我们看到这个方法里的实现

protected function createDispatcher()
{
    return $this->dispatcher ?: \FastRoute\simpleDispatcher(function ($r) {
        foreach ($this->router->getRoutes() as $route) {
            $r->addRoute($route['method'], $route['uri'], $route['action']);
        }
    });
}

我们看到,这个方法就是返回了一个分派器,如果没有指定分派器,则默认使用Lumen当前的分派器。这里框架允许我们自定义分派器。(PS:路由的依赖注入就是通过获取这个自定义的分派器实现的)

我们直接看默认的路由分配器的实现。
simpleDispatcher是一个辅助函数,里面定义了包括路由解析器,数据生成器,路由收集器,以及分派器等所对应的类。

function simpleDispatcher(callable $routeDefinitionCallback, array $options = [])
{
    $options += [
        'routeParser' => 'FastRoute\\RouteParser\\Std',
        'dataGenerator' => 'FastRoute\\DataGenerator\\GroupCountBased',
        'dispatcher' => 'FastRoute\\Dispatcher\\GroupCountBased',
        'routeCollector' => 'FastRoute\\RouteCollector',
    ];

    /** @var RouteCollector $routeCollector */
    $routeCollector = new $options['routeCollector'](
        new $options['routeParser'], new $options['dataGenerator']
    );
    $routeDefinitionCallback($routeCollector);

    return new $options['dispatcher']($routeCollector->getData());
}

simpleDispatcher函数中:

$routeCollector = new $options['routeCollector'](
        new $options['routeParser'], new $options['dataGenerator']
    );
$routeDefinitionCallback($routeCollector);

这两行是添加路由的核心代码。

createDispatcher中闭包中的内容,即调用$routeCollector(路由收集器)的addRoute方法,重新添加路由。我们重点看该方法。

public function addRoute($httpMethod, $route, $handler)
{
    $route = $this->currentGroupPrefix . $route;
    $routeDatas = $this->routeParser->parse($route);
    foreach ((array) $httpMethod as $method) {
        foreach ($routeDatas as $routeData) {
            $this->dataGenerator->addRoute($method, $routeData, $handler);
        }
    }
}

addRoute方法中,首先做的便是解析当前$uriparse的核心代码如下:

//这个地方循环是因为配置的$uri中可以有可选参数,比如我配置的$uri为:/user/{id}[/interes]
这里就相当于定义了两条除了$uri不同之外其他都相同的路由,一条为/user/{id},一条为/user/{id}/interes,具体细节请看这个方法的整个实现。
foreach ($segments as $n => $segment) {
    if ($segment === '' && $n !== 0) {
        throw new BadRouteException('Empty optional part');
    }

    $currentRoute .= $segment;
    $routeDatas[] = $this->parsePlaceholders($currentRoute);
}

其中

$routeDatas[] = $this->parsePlaceholders($currentRoute);

这一行,是最重要的一行代码,这个方法的功能就是把当前$uri中的参数进行分割
举个例子:假设我们定义的路由的$uri/user/{id}/goods/{goods_id}
这个方法返的就是[‘/user/’,[‘id’, ‘[^/]+’],’/goods/’, [‘goods_id’, ‘[^/]+’]]
其中返回的参数中数组类型的即为动态参数,第二个参数是一个正则表达式,这是一个默认的正则表达式,如果是自己配置了参数的匹配规则,则会覆盖默认的正则表达式。

$uri中对应的动态参数及其正则表达式解析出来之后,接着会调用dataGenerator中的addRoute方法添加对应的路由。

public function addRoute($httpMethod, $routeData, $handler)
{
    if ($this->isStaticRoute($routeData)) {
        $this->addStaticRoute($httpMethod, $routeData, $handler);
    } else {
        $this->addVariableRoute($httpMethod, $routeData, $handler);
    }
}

可以看到,在这个方法中动态路由和静态路由的添加方式是不一样的,而判断是不是动态路由的唯一方式就是当前解析的路由的数组数量大于1,如果看到后面,会发现静态路由和动态路由是添加在不同的数组中的。且路由不允许重复添加,且路由之间不允许有包含关系,否则,会报异常。

private function isStaticRoute($routeData)
{
    return count($routeData) === 1 && is_string($routeData[0]);
}

我们重点看addVariableRoute方法。

private function addVariableRoute($httpMethod, $routeData, $handler)
{
    list($regex, $variables) = $this->buildRegexForRoute($routeData);

    if (isset($this->methodToRegexToRoutesMap[$httpMethod][$regex])) {
        throw new BadRouteException(sprintf(
            'Cannot register two routes matching "%s" for method "%s"',
            $regex, $httpMethod
        ));
    }

    $this->methodToRegexToRoutesMap[$httpMethod][$regex] = new Route(
        $httpMethod, $handler, $regex, $variables
    );
}

addVariableRoute方法中,第一行便是构建路由表达式,提取当前路由表达式的参数。拿上面的例子来说,当执行完$this->buildRegexForRoute($routeData)这个方法后
得到的返回值为[‘/user/([^/]+)/goods/([^/]+)’,[‘id’, ‘goods_id’]],然后构建一个Route对象加入到动态路由的methodToRegexToRoutesMap数组中。

五、动态路由的分派

添加完动态路由之后,我们就可以基于当前的$method$uri去找到对应的路由了

simpleDispatcher函数里有这样一行

return new $options['dispatcher']($routeCollector->getData());

其中$routeCollector->getData()即获取当前的静态路由和动态路由。在获取静态路由的时候,是直接将静态路由的数组返回,而获取动态路由的时候,做了相应的处理。
dataGenerator所属的对象中可以看到有一个generateVariableRouteData方法。我们看看这个方法的具体实现:

private function generateVariableRouteData()
{
    $data = [];
    foreach ($this->methodToRegexToRoutesMap as $method => $regexToRoutesMap) {
        $chunkSize = $this->computeChunkSize(count($regexToRoutesMap));
        $chunks = array_chunk($regexToRoutesMap, $chunkSize, true);
        $data[$method] = array_map([$this, 'processChunk'], $chunks);
    }
    return $data;
}

在这个方法下,将对应$method下的路由分成若干组,每组有$chunkSize个路由,然后将每组的$chunkSize个路由经过processChunk方法处理成一个正则表达式,以及一个路由的映射数组。

我们可以到对应的类中查看processChunk的实现

protected function processChunk($regexToRoutesMap)
{
    $routeMap = [];
    $regexes = [];
    $numGroups = 0;
    foreach ($regexToRoutesMap as $regex => $route) {
        $numVariables = count($route->variables);
        $numGroups = max($numGroups, $numVariables);

        $regexes[] = $regex . str_repeat('()', $numGroups - $numVariables);
        $routeMap[$numGroups + 1] = [$route->handler, $route->variables];

        ++$numGroups;
    }

    $regex = '~^(?|' . implode('|', $regexes) . ')$~';
    return ['regex' => $regex, 'routeMap' => $routeMap];
}

这里在建立routeMap的时候,是以当前路由中捕获分组中的最大值以及当前路由需要捕获的分组数量中取最大值,如果当前路由需要捕获的分组少于当前路由中捕获分组中的最大值,不足的部分以空的捕获数组填充。这样做的目的是为了在路由匹配的过程中能过通过捕获的数量快速定位到某个路由。可以看到,这几个路由的正则匹配处理之后的大正则表达式中是一个或的关系。

解释起来有点绕,我举个例子:
假设动态路由数组中有五个动态路由分别为:

$regex                         $vars
R1: /a/([^/]+)/b/([^/]+)  [‘a1’, ‘a2’]
R2:/aa/([^/]+)/bb/([^/]+)/cc/([^/]+)/([^/]+) [‘aa1’,’bb2’,’cc1’,’cc2’]

R3:/f/([^/]+)/i/([^/]+)/j/([^/]+)/k/([^/]+) [‘f1’,’i1’,’j1’,’k1’]
R4:/ff/([^/]+)/ii/([^/]+) [‘ff1’, ‘ii1’]

R5:/c/([^/]+) [‘c1’]

两两为一组:
则前两组的routeMap[3]为R1routeMap[5]R2
$regex为~^(?| /a/([^/]+)/b/([^/]+) | /aa/([^/]+)/bb/([^/]+)/cc/([^/]+)/([^/]+))$~
中间两组的routeMap[5]R3routeMap[6]R4
$regex为~^(?|/aa/([^/]+)/bb/([^/]+)/cc/([^/]+)/([^/]+) | /ff/([^/]+)/ii/([^/]+)()()())$~
最后dispatch方法,dispatch方法会首先找静态路由,找到即返回,然后才找动态路由。我们重点看dispatch方法。

$result = $this->dispatchVariableRoute($varRouteData[$httpMethod], $uri);
if ($result[0] === self::FOUND) {
    return $result;
}

该方法调用了dispatchVariableRoute方法,我们看看具体实现:

protected function dispatchVariableRoute($routeData, $uri)
{
    foreach ($routeData as $data) {
        if (!preg_match($data['regex'], $uri, $matches)) {
            continue;
        }

        list($handler, $varNames) = $data['routeMap'][count($matches)];

        $vars = [];
        $i = 0;
        foreach ($varNames as $varName) {
            $vars[$varName] = $matches[++$i];
        }
        return [self::FOUND, $handler, $vars];
    }

    return [self::NOT_FOUND];
}

该方法通过遍历该方法(指的是get,posthttp动作)下的分组的路由信息,若某分组下有匹配,则通过匹配的参数快速找到对应的具体的Route对象,然后通过分组提取当前匹配中动态参数的值。找到对应的路由信息之后,就和第三小节中找到路由后的处理过程是一样的了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容