vertilia/router

高效的多用途路由库

v2.1.0 2023-06-20 16:13 UTC

README

一个轻量级的http路由库,针对性能优化,可以将请求路径转换为控制器名称,并验证路径参数是否符合OpenAPI规范和(部分)符合URI模板

高效的路由机制特别适用于大型路由表,因为查找控制器的操作数取决于路由树级别,而不是列表中的总路由数。

简单来说,我们不是通过搜索表示为正则表达式列表的所有可用路径,而是从路径列表构建一个文件夹的路由树,并且只访问与当前请求对应的级别。这使我们逐步到达正确的路由,而不是盲目地扫描整个可用路由列表直到找到匹配的路由(或者没有找到)。此外,查找不正确路由的时间最小化,我们不需要扫描整个列表以找到路由缺失的情况。

那些经常研究他们的访问日志以发现最频繁请求并将它们放在路由列表更高位置的人会欣赏这个功能。这些研究现在已经成为历史,他们可能会把宝贵的时间投入到更重要的事情上。

路由算法是通用的,在定义路由规则时,你可以在叶子节点附加任何结构,当提供相应的路由时,它将被返回。这个叶子节点由一个包含至少controller元素(表示用户在找到路由时将启动的操作的名称)的数组表示。如果请求路径包含参数,它们将被检测并作为parameters元素注入返回数组中。

因为我们相信在请求处理上下文中基于ValidArrayHttpRequestInterface结构具有高度的有效性,所以我们还提供了一个HttpRequestRouter类,允许在请求对象中自动注册路由参数,通过叶子元素提供的过滤器进行验证,并通过数组表示法(如$request['param'])轻松访问。这些参数保证符合用户指定的格式(请参阅测试)。

过滤机制的高效性得到了php原生filter扩展的支持。

用法

通用路由器

在实例化通用路由器时,需要传递HttpParserInterface解析对象(它将参数占位符转换为正则表达式)以及路由文件列表。

<?php // public/index.php (v1)

$router = new Vertilia\Router\Router(
    new Vertilia\Parser\OpenApiParser(),
    [__DIR__ . '/../etc/routes.php']
);

HttpRequest路由器

在大多数情况下,你在CGI上下文中工作,所以你可能会使用HttpRequestRouter类。这个类默认使用OpenApiParser,所以你不需要注入这个类,而是提供一个HttpRequest,路由器将从中检索要查找的路由。

<?php // public/index.php (v2)

$router = new Vertilia\Router\HttpRouter(
    new Vertilia\Request\HttpRequest(),
    [__DIR__ . '/../etc/routes.php']
);

路由文件

每个路由文件都是一个有效的PHP脚本,返回一个包含路由信息的数组,其中每个条目指定与路由对应的叶子节点。在其最简单形式中,它只是特定路由的控制器名称。

<?php // etc/routes.php

return [
    'GET /'              => App\Controller\IndexController::class,
    'GET /products'      => App\Controller\ProductsController::class,
    'GET /products/{id}' => App\Controller\ProductsController::class,
];

更复杂的形式可以用于为特定路由提供过滤信息,或任何其他您可能需要的信息。在这种情况下,使用关联数组而不是控制器名称的字符串。这里唯一必需的元素是 controller,它将存储代码正在寻找的控制器名称。其他元素可以按需定义。

  • controller - 提供控制器名称(由 RouterHttpRequestRouter 使用)
  • filters - 提供检测参数的过滤器(由 HttpRequestRouter 使用)
  • responses - 提供针对每个状态码的响应数组(例如,以实现 OpenApi 响应
<?php // etc/routes.php

return [
    'GET /'              => App\Controller\IndexController::class,
    'GET /products'      => App\Controller\ProductsController::class,
    'GET /products/{id}' => [
        'controller' => App\Controller\ProductsController::class,
        'filters' => [
            'id' => FILTER_VALIDATE_INT,
        ],
    ],
    'PUT /products/{id}' => [
        'controller' => App\Controller\ProductsController::class,
        'filters' => [
            'id' => [
                'filter'  => FILTER_VALIDATE_INT,
                'options' => ['min_range' => 1],
            ],
            'description' => FILTER_SANITIZE_STRING,
            'image' => [
                'filter' => FILTER_VALIDATE_URL,
                'flags'  => FILTER_FLAG_HOST_REQUIRED,
            ],
        ],
    ],
];

如果您使用的是 HttpRequestRouter 版本的路由器,其中使用 filters 元素从叶元素存储和验证 HttpRequest 对象中检测到的路径参数,则需要此表单(包含 filters 元素)。

最后,路由过滤器不仅为路径参数 id 提供,还可能来自其他来源(如查询、cookie、http body 或头部)的 descriptionimage 参数提供。所有这些都将相应地进行过滤,并以例如 $request['description'] 的形式在 HttpRequest 对象内部访问。

如果没有明确提供路径参数的过滤器,则 parameters 元素仍然会被注入到包含所有检测到的路径参数的返回叶元素中,但这些参数既不会被验证也不会在 HttpResponse 对象中注册,并且用户需要通过其他方式验证它们的值。

是的,您需要实现控制器并通过 composer 自动加载器或其他机制使它们可用。这超出了路由库的范围,但下面有一个使用路由器的真实世界示例。

此外,您需要决定以何种形式向应用程序提供控制器名称,无论是我们示例中使用的类名,还是方法名、函数名,甚至是稍后将被完成的部分字符串。按您喜欢的任何方式实现它。我们更喜欢上面描述的方法,因为它有几个优点。通过 ::class 常量可引用的类名使使用 IDE 代码补全变得更加简单。它们也更容易出错,因为使用 IDE 重命名类将自动在路由文件中重命名控制器名称,或者至少在其中有非存在的显示。您无需等待集成或甚至部署阶段,就可以发现未定义的异常。是的,下面的优化阶段无论如何都会将其转换为字符串。

提供内容 MIME 类型

为了能够为不同的传入内容类型设置不同的控制器和验证,在定义路由时也可以提供内容 MIME 类型,如下所示:

GET /products/{id} application/json

在这种情况下,特定内容类型的路由优先于通用路由,如下所示:

GET /products/{id}

如果无法在内容类型感知的形式中找到路由,则会在通用路由中进行搜索。

路由解析优化

在加载路由表时,必须将每个路由拆分以识别其方法、路径和 MIME 类型(如果存在),然后分析路径以区分静态路径和带有变量的路径,然后将带有变量的路径替换为由正则表达式允许识别参数和捕获变量值的树结构。

此解析过程在每个请求上执行,因此当路由数量增加时,它可能会对性能产生重大影响。

为了更快,我们可以完全跳过每个请求的解析阶段,通过预编译路由表并将其保存为原生 php 文件来实现。使用活动 opcode 缓存加载它将花费零时间。

要使用预编译方法

  • 使用提供的 vendor/bin/routec 路由编译器脚本,该脚本接受路由文件列表,解析它们并将结果结构存储为 Router::setParsedRoutes 方法可理解的格式;
  • 将此结构保存到 .php 文件中,例如:routes-generated.php;
  • 在您的 index.php 中,在创建路由器实例时,省略构造函数中的 $routes 参数,并通过 $router->setParsedRoutes(include 'routes-generated.php') 加载预编译的路由树。

示例

每次更新路由文件时,都需要执行此脚本以将 etc/routes.php 文件转换为 cache/routes-generated.php

vendor/bin/routec etc/routes.php >cache/routes-generated.php

如果您的路由分布在几个文件中,您可以为它们提供几个输入文件。 routec 工具将输出一个包含所有路由的最终文件。

由于我们使用了 cache/routes-generated.php 中的已缓存结构,因此我们不需要在每次请求时解析整个路由列表。

<?php // www/index.php

require __DIR__ . '/../vendor/autoload.php';

use App\Controller\NotFoundController;
use Vertilia\Request\HttpRequest;
use Vertilia\Router\HttpRouter;

// construct route from the request
$request = new HttpRequest(
    $_SERVER,
    $_GET,
    $_POST,
    $_COOKIE,
    $_FILES,
    file_get_contents('php://input')
);

// instantiate HttpRouter without parsing
$router = new HttpRouter($request);

// set pre-compiled routes
$router->setParsedRoutes(include __DIR__ . '/../cache/routes-generated.php');

// get controller name and parameters from request
// use NotFoundController as default
$target = $router->getControllerFromRequest(NotFoundController::class);

// instantiate controller with request
$controller = new ($target['controller'])();

// let controller do its work and output corresponding response
$controller->render();

优化技术的局限性

在进行以下操作时,请注意以下注意事项:

  1. ⚠️ 您可能使用的 PHP 常量(如 FILTER_VALIDATE_INTFILTER_FLAG_HOST_REQUIRED 等)通常以它们的数值形式导出。 routec 工具试图从这些值中恢复常量名称。这些值可能随着 PHP 二进制版本的更新而变化,因此请尝试使用与 setParsedRoutes() 调用相同的版本生成导出的路由文件。 routec 将尽力尝试用相应的常量替换优化路由文件中的这些数值,但您最好在优化文件中验证结果。请注意具有相同整数值的标志,如 FILTER_FLAG_IPV4FILTER_FLAG_HOSTNAMEFILTER_FLAG_EMAIL_UNICODE,或者不在所有 PHP 版本中都有的标志,如 FILTER_FLAG_GLOBAL_RANGE

  2. ⚠️ 此外,如果您使用验证回调(如 FILTER_CALLBACKValidArray::FILTER_EXTENDED_CALLBACK 过滤器),则它们根本不会被导出,您需要手动从初始路由文件中复制这些回调。

样本 petstore.yaml 规范

此示例的 API 规范文件可在 OpenAPI GitHub 存储库中找到。

与规范相对应的路由文件如下

<?php // etc/routes.php

use Vertilia\ValidArray\ValidArray;

return [
    'GET /pets' => [
        'controller' => 'findPets',
        'filters' => [
            'limit' => FILTER_VALIDATE_INT,
            'tags'  => [
                'filter'  => ValidArray::FILTER_EXTENDED_CALLBACK,
                'flags'   => FILTER_REQUIRE_SCALAR,
                'options' => ['callback' => function($v) {return explode(',', $v);}],
            ],
        ],
    ],
    'POST /pets application/json' => [
        'controller' => 'addPet',
        'filters' => [
            'name' => FILTER_DEFAULT,
            'tag'  => FILTER_DEFAULT,
        ],
    ],
    'GET /pets/{id}' => [
        'controller' => 'find_pet_by_id',
        'filters' => [
            'id' => FILTER_VALIDATE_INT,
        ],
    ],
    'DELETE /pets/{id}' => [
        'controller' => 'deletePet',
        'filters' => [
            'id' => FILTER_VALIDATE_INT,
        ],
    ],
];

路由格式参考

支持的格式

  • [ROUTE => LEAF_STRUCTURE, …]

ROUTE 是一个包含 2 或 3 个部分,由空格字符分隔的字符串

  • METHOD PATH
  • METHOD PATH CONTENT_TYPE

ROUTE 的部分

  • METHOD 是一个 HTTP 请求方法
  • PATH 是一个 HTTP 请求路径组件,可能包含表示路径参数的 {variable} 占位符
  • CONTENT_TYPE 是一个 HTTP 请求 Content-Type 标头(如果请求中提供)

如果请求中提供了 Content-Type 标头,并且未找到对应的 3 部分路由,则搜索将重复进行,使用不带 CONTENT_TYPE 部分的对应 2 部分路由。

ROUTE 示例

  • GET /
  • POST /api/login application-json
  • GET /api/users/{id}/posts

当找到相应的路由时,返回 LEAF_STRUCTURE。可能是以下两种类型之一

  • 标量,例如:"UserResponse"
  • 数组,例如:["controller" => "LoginResponse", ...其他自定义元素]

如果使用标量形式用于 LEAF_STRUCTURE(如 "UserResponse"),则在解析阶段将其内部转换为具有单个 controller 元素的数组:["controller" => "UserResponse"]。当 LEAF_STRUCTURE 在识别路由后返回时,它始终以数组的形式返回。

正确路由的示例

[
    "GET /" => "IndexResponse",
    "POST /api/users/me/login application-json" => "UserLoginResponse",
    "GET /api/users/{id}/posts" => [
        "controller" => "UserPostsResponse",
        "filters" => [
            "id" => FILTER_VALIDATE_INT
        ]
    ]
]

对于相应请求返回的 LEAF_STRUCTURE