Spiral 详细上手指南之路由规则

本文首发于云加社区我的个人专栏。受云加社区协议限制,未经许可不得转载。关于 Spiral 框架的这个系列文章,都会首发云加社区,之后过一段时间才会同步到个人博客。

在上一篇《Spiral 详细上手指南之安装与配置》中,我们已经基于官方的 WEB 项目模板创建了自己的本地项目 "myapp" 并且已经配置好了数据库连接和用于开发的进程参数。

通过这整个系列,最终将会开发完成一个简化版的博客 APP. 在这次的文章中,暂时不会涉及数据库操作和领域模型相关的开发,而是聚焦于 Spiral 框架的路由(route)和控制器(controller)部分。

实践目标

我们首先要为博客文章创建路由和控制器,包含以下的路由:

  • GET "/posts": 文章列表页
  • GET "/posts/<id>": 文章详情页
  • POST "/posts": 创建文章的 API
  • PUT "/posts": 保存文章修改的 API
  • DELETE "/posts/<id>": 删除文章的 API

这些路由都会指向我们创建的 PostController 控制器中的对应方法。

Spiral 路由绑定介绍

前文提到过,由 Spiral 的 WEB 项目模板创建的项目中,系统已经定义了两组路由规则:

  • /<action>.html 默认指向 HomeController 下对应的方法
  • /<controller>/<action> 指向对应的控制器和方法

两组路由都有默认值,controller 的默认值是 "HomeController",action 的默认值是 "index", 以上一节列出来要创建的路由为例,如果我们想另外定义路由,那么基于系统的默认路由,我们的路径会这样解析:

  • /blogs: 调用 BlogsControllerindex 方法(包括 GETPOSTPUTPATCHDELETE等所有动词都统一映射到这里)
  • /blogs/123: 无匹配

Spiral 的路由是不可变的,注册之后禁止修改,所以应该在引导程序中进行注册。我们项目下已经有一个专门负责注册路由的引导程序 RoutesBootloader,打开项目下的 app/src/Bootloader/RoutesBootloader.php 文件,就能看到系统默认注册的路由:


namespace App\Bootloader;

use App\Controller\HomeController;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Router\Route;
use Spiral\Router\RouteInterface;
use Spiral\Router\RouterInterface;
use Spiral\Router\Target\Controller;
use Spiral\Router\Target\Namespaced;

class RoutesBootloader extends Bootloader
{
    /**
     * @param RouterInterface $router
     */
    public function boot(RouterInterface $router): void
    {
        // named route
        $router->addRoute(
            'html',
            new Route('/<action>.html', new Controller(HomeController::class))
        );

        // fallback (default) route
        $router->setDefault($this->defaultRoute());
    }

    /**
     * Default route points to namespace of controllers.
     *
     * @return RouteInterface
     */
    protected function defaultRoute(): RouteInterface
    {
        // handle all /controller/action like urls
        $route = new Route(
            '/[<controller>[/<action>]]',
            new Namespaced('App\\Controller')
        );

        return $route->withDefaults([
            'controller' => 'home',
            'action'     => 'index'
        ]);
    }
}

可以看到通过 RouterInterface 提供的 addRoute 方法来定义路由规则。这里要说明一下,addRoute 这个方法已经弃用,应该使用 setRoute 替代。如果你使用时官方的项目模板还没更新,我们可以自己修改一下:

@@ -27,7 +27,7 @@ class RoutesBootloader extends Bootloader
     public function boot(RouterInterface $router): void
     {
         // named route
-        $router->addRoute(
+        $router->setRoute(
             'html',
             new Route('/<action>.html', new Controller(HomeController::class))
         );

RouterInterface 接口

Spiral 的路由规则是根据 PSR-15 规范来实现的,在任何一个引导程序中,我们都可以通过依赖 RouterInterface 这个接口,并借助它来注册新的路由规则。这个接口提供了以下方法:

  • setRoute(string $name, Spiral\Router\RouteInterface $route): void: 定义路由规则
  • setDefault(Spiral\Router\RouteInterface $route): void: 定义默认路由规则
  • getRoute(string $name): Spiral\Router\RouteInterface: 通过名称取回路由规则实例
  • getRoutes(): array: 取回所有已注册的路由规则集合
  • uri(string $name, array $parameters = []): Psr\Http\message\UriInterface: 生成 uri

可以看到其中setRoute 方法接受两个参数,第一个是字符串,指定路由的名称,第二个是 Spiral\Router\RouteInterface 接口的具体实现,在 Spiral 中 Spiral\Router\Route 类实现了这个接口,并且提供了一些方便使用的方法。

Route 类

RouteInterface 接口用来创建具体的路由规则,实现它的 Route 类的构造函数签名如下:

/**
  * @param string $pattern 网址路径匹配模式
  * @param string|callable|RequestHandlerInterface|TargetInterface $target 可调用的路由目标
  * @param array $defaults 匹配模式参数的默认值
  */
public function __construct(string $pattern, $target, array $defaults = [])

可以看到,第一个参数是字符串,用来匹配网址,第二个参数是路由目标,我们上面用到的是 TargetInterface 类型,但 Spiral 遵循 PSR-15 规范,因此这个参数可以是任何一个实现 Psr\Http\Server\RequestHandlerInterface 接口的对象。比如直接用闭包函数来实现:

new Route(
    '/<name>',
    function (ServerRequestInterface $request, ResponseInterface $response) {
        $response->getBody()->write("响应内容");

        return $response;
    }
)

但在实际项目中可能用得更多的是以下几种:

  • Spiral\Router\Target\Group: 控制器组(通常在 Restful API 中使用比较多)
  • Spiral\Router\Target\Controller: 控制器(之前被删掉的自带路由就是这种)
  • Spiral\Router\Target\Action: 控制器方法(我们前面添加的所有规则都是这种)
  • Spiral\Router\Target\Namespaced: 命名空间(系统自带的默认规则属于这种)

稍后会对这几种不同的路由目标分别介绍。在构造函数之外,Route 类还有几个比较常用的实例方法:

  • withDefaults(array $defaults): RouteInterface: 给路由设定参数默认值
  • withVerbs(string ...$verbs): RouteInterface: 指定路由可用的 HTTP 动词
  • withMiddleware(...$middleware): 给路由绑定中间件

所以如果需要让某个路由只用于特定的 HTTP 方法(动词),可以在创建了路由实例之后,用 withVerbs 方法实现:

$route = new Route('/foo', new Controller('App\Controller\FooController'));
$route = $route->withVerbs('post', 'PUT'); // 动词不区分大小写

路由匹配顺序

Spiral 的路由是按照定义它们的先后顺序依次匹配,一旦匹配到任何一条规则,就不再向下。因此务必把更具体的匹配模式放到前面,否则就会失效,比如有两条匹配路径的顺序如下:

  • "/<action>"
  • "/blog"

如果按照这样的顺序定义路由,那么 "/blog" 这个路径就会被第一条 "/<action>" 规则匹配,而第二条规则永远不会被命中。

路由参数

在路径匹配模式字符串中,用[] 来指定可选参数,用<> 来指定参数,参数可以用 : 接正则表达式来接参数的格式,例如:

  • "/<controller>/<action>": 匹配 "/user/add", "/blog/view", "/article/list" 这样的路径,controller 和 action 都是必须的,缺少任何一个不会匹配
  • "/<controller>[/<action>]: 同上,但是这里 action 是可选参数,通常这种情况下需要为 action 指定默认值,不指定的话系统默认是 index
  • "/[<controller>[/<action>]]": 同上,但这里 controller 和 action 都是可选的,请注意两个可选参数是嵌套定义的
  • "/article/<action:list|add|save>": 这个匹配 "/article/list", "/article/add", "/article/save",在 ":" 后面可以直接列出允许的值,用 "|" 分隔
  • "/articles/<id:\d+>": 这个匹配 "/articles/1", "/articles/22" 这样的路径,id 参数限制必须是数字
  • "/posts[/<id:\d+>]": 这个匹配 "/posts", "/posts/222" 这样的路径,跟上一个的区别在于 id 是可选参数

路由指向控制器

如果要把一条路由规则指向具体的控制器,就可以用到上面提到的 Spiral\Router\Target\Controller 这个 target,例如:

use Spiral\Router\Target\Controller;

$route = new Route(
    '/posts[/<action>[/<id:\d+>]', // 匹配模式
    new Controller(
        'App\Controller\PostController', // 目标控制器
        0, // 是否 Restful 风格(可选参数,默认值:0)
        "index" // 默认的 action,可选参数(默认值:"index")
    )
);

这个实例定义了一条路由规则,可以匹配以下路径:

  • "/posts": 会调用 PostController::index(int $id = null) 方法,传入参数 $id = null
  • "/posts/list": 会调用 PostController::list(int $id = null) 方法,传入参数 $id = null
  • "/posts/show/32": 会调用 PostController::show(int $id = null) 方法,传入参数 $id = 32

上面的代码中创建 Controller 的时候,一共传入了四个参数,后两个稍后再介绍。

路由指向控制器方法

如果希望把路由明确地指向具体的控制器方法而不是整个控制器,那么可以使用 Spiral\Router\Target\Action 这个目标:

use Spiral\Router\Target\Action;

// 匹配 "/posts/2019", "/posts/2019/12"
$route = new Route(
    '/posts/<year:\d{4}>[/<month:\d{2}>]', // 匹配模式
    new Action(
        PostController::class, // 目标控制器
        'archive', // 目标方法
        0 // 是否 Restful 模式(可选参数,默认值 0)
    )
);

// 匹配 "/posts/create", "/posts/edit", "/posts/save"
$route = new Route(
    '/posts/<action>', // 匹配模式
    new Action(
        PostController::class, // 目标控制器
        ['create', 'edit', 'save'], // action 参数的可用值
        0 // 是否 Restful 模式(可选参数,默认值 0)
    )
);

这里举了两种使用示例,第一种是直接指向明确的某一个控制器方法,第二种是同时制定多个控制器方法。

路由指向控制器组

这个有点像是把多个指向控制器的路由简化成一组的写法,使用的 target 是 Spiral\Router\Target\Group

use Spiral\Router\Target\Group;

// 匹配 "/home/*", "/demo/*"
$route = new Route(
    '/<controller>/<action>',
    new Group(
        [
           'home' => HomeController::class,
           'demo' => DemoController::class
        ],
        0, // 是否 Restful 风格(可选参数,默认值 0)
        'index' // 默认 action(可选参数,默认值 "index")
    )
);

所以这个基本上不用做多少解释,基本上就是跟指向控制器的定义一样的,只是可以一次定义多个控制器匹配而已,要说明的是最后一个参数(指定默认 action)是只有把 <action> 指定为可选参数才有意义。

指向命名空间

这个就是系统用来定义默认控制器的方法,通常借助这个,可以实现给自己的项目的路由划分 "module",从而实现 HMVC 结构。例如:

use Spiral\Router\Target\Namespaced;

// 匹配 "/foo/bar",指向 "App\Controller\FooController::bar()"
$route = new Route(
    '/<controller>[/<action>]',
    new Namespaced(
        'App\Controller', // 目标命名空间
        'Controller', // 控制器类的类名后缀(可选参数,默认值 "Controller")
        0, // 是否 Restful 风格(可选参数,默认值 0)
        'index' // 默认 action(可选参数,默认值 "index")
    )
);

// 匹配 "/admin/foo/bar",指向 "App\Controller\Admin\FooController::bar()"
$route = new Route(
    '/admin/<controller>[/<action>]',
    new Namespaced(
      'App\Controller\Admin', // 目标命名空间
      'Controller', // 控制器类名后缀(可选参数,默认值 "Controller")
      0, // 是否 Restful 风格(可选参数,默认值 0)
      'index' // 默认 action(可选参数,默认值 "index")
    )
);

可以看到,我们可以借助这个工具,给前端、后端的路由各设置不同的默认值。

Restful 风格控制器方法

前面一直有提到一个 "是否 Restful 风格" 的参数,这个参数主要为了方便实现 Restful 风格的路由(把相同路径的不同动词请求分开)。如果在创建路由实例的时候指定这个参数为 1,那么 Spiral 会在解析控制器方法的时候自动把 HTTP 动词加到方法名称前。比如要请求的控制器方法是 foo,那么 POST 请求会指向 postFoo,GET 请求会指向 getFoo.

为了演示这种用法,首先创建一个控制器:

namespace App\Controller;

class FooController
{
    public function getBar(int id) {}
    public function postBar(int id) {}
    public function putBar(int id) {}
    public function deleteBar(int id) {}
}

然后定义一个路由规则:

use App\Controller\FooController;
use Spiral\Router\Target\Controller;

$fooRoute = new Route(
    '/foo/<id:\d+>',
    new Controller(
        FooController::class,
        1, // 这里改为 1,或者 Controller::RESTFUL 常量
    ),
    ['action' => 'bar'] // 默认值
);

$router->setRoute(
    'foo.restful',
    $fooRoute
);

这样当我们以 GET 方法请求 /foo/222 的时候,会执行 getBar 方法,用 DELETE 方法请求 /foo/222 的时候,会请求 deleteBar 方法。

实现我们需要的路由

经过以上这么细致(或者说啰嗦)的介绍之后,回头来看我们要定义的路由,会发现在路径只有两种形式:/posts/posts/<id>,如果把 id 变成可选参数,那么就只有一种形式:/posts[/<id>],而动词有四种:GET, POST, PUT, DELETE. 很显然,有很多种方案可以实现我们的实践目标。不过个人觉得最简洁的当然是 “路由指向控制器 + Restful 风格”。

创建控制器

首先,创建 PostController,可以在 app/src/Controller 目录下自己创建这个类,也可以借助脚手架工具,在命令行执行:

$ php app.php create:controller post

控制器的代码如下:

<?php
/**
 * File: App\Controller\PostController.php
 */

declare(strict_types=1);

namespace App\Controller;

class PostController
{
    public function getPost(int $id = null): string
    {
        return is_int($id) ? "查看文章 $id" : "文章列表";
    }

    public function postPost($id = null): string
    {
        return "创建文章";
    }

    public function putPost(int $id = null): string
    {
        return "编辑文章";
    }

    public function deletePost(int $id = null): string
    {
        return is_int($id) ? "删除文章 $id" : "参数缺失";
    }
}

定义路由规则

然后打开 app/src/Bootloader/RoutesBootloader.php,在 boot 方法中注册我们的路由(注意要把我们的规则放到最前面):

--- a/app/src/Bootloader/RoutesBootloader.php
+++ b/app/src/Bootloader/RoutesBootloader.php
@@ -12,6 +12,7 @@ declare(strict_types=1);
 namespace App\Bootloader;

 use App\Controller\HomeController;
+use App\Controller\PostController;
 use Spiral\Boot\Bootloader\Bootloader;
 use Spiral\Router\Route;
 use Spiral\Router\RouteInterface;
@@ -26,8 +27,17 @@ class RoutesBootloader extends Bootloader
      */
     public function boot(RouterInterface $router): void
     {
+        $router->setRoute(
+            'posts',
+            new Route(
+                "/posts[/<id:\d+>]",
+                new Controller(PostController::class, Controller::RESTFUL),
+                ['action' => 'post']
+            )
+        );
+
         // named route
-        $router->addRoute(
+        $router->setRoute(
             'html',
             new Route('/<action>.html', new Controller(HomeController::class))
         );

重要提醒:如果应用服务器是运行中的,请执行 ./spiral http:reset 重设 HTTP 工作进程,或者直接停止再重新运行 spiral 应用服务器。

验证一下

脚手架提供了一个命令可以让我们查看所有已经注册了的路由规则:

$ php app.php route:list
+--------+----------------------------+------------------------------+
| Verbs: | Pattern:                   | Target:                      |
+--------+----------------------------+------------------------------+
| *      | /posts[/<id:\d+>]          | Controller\PostController->* |
| *      | /<action>.html             | Controller\HomeController->* |
| *      | /[<controller>[/<action>]] | Controller\*Controller->*    |
+--------+----------------------------+------------------------------+

然后我们可以通过 curl 来验证一下:

$ curl http://localhost:8080/posts
文章列表

$ curl http://localhost:8080/posts/2
查看文章 2

$ curl -X POST http://localhost:8080/posts
创建文章

$ curl -X PUT http://localhost:8080/posts
编辑文章

$ curl -X DELETE http://localhost:8080/posts/33
删除文章 33

不足之处

至此,我们本次的实践目标就达到了。当然,严格来说还有一点不足之处,POSTPUT 路由严格来说不应该支持 <id> 参数,但现在 [POST|PUT] /posts/333[POST|PUT] /posts 都是一样的。如果要严格限制的话,可以把我们的路由拆成两条,一条包含必备参数 <id>,一条不含 <id> 参数。或者直接不使用 Restful 风格的路由定义,通过 withVerbs 方法自行绑定路由允许的动词。

如果您有兴趣,可以自行尝试。

在本文中原计划是要把路由和控制器一并介绍给大家,但是写下来发现仅仅是路由的部分就占用了大量的篇幅,而控制器又涉及到了请求和响应两个方面的处理,同样篇幅不短,因此我决定把控制器的部分放到下一篇文章中,详细介绍 Spiral 框架中的请求和响应。