pmjones / auto-route
自动将HTTP请求路由到动作类。
Requires
- php: ^8.0
- pmjones/throwable-properties: ^1.0
- psr/log: ^3.0
Requires (Dev)
- pds/skeleton: ^1.0
- phpstan/phpstan: ^0.12.82
- phpunit/phpunit: ^9.0
README
AutoRoute自动将传入的HTTP请求(通过动词和路径)映射到指定命名空间中的PHP动作类,在该类中反射指定的动作方法以确定动态URL参数值。这些参数可能是典型的标量值(int、float、string、bool),或者数组,甚至是自定义的值对象。AutoRoute还可以帮助您根据动作类名称生成URL路径,并自动检查动态参数的类型提示。
使用Composer安装AutoRoute
composer require pmjones/auto-route ^2.0
AutoRoute维护成本低。只需将类添加到源代码中,在可识别的命名空间中,并使用可识别的动作方法名称,即可自动将其作为路由可用。无需再管理路由文件以保持与动作类同步!
AutoRoute速度快。实际上,在常见情况下,它比FastRoute快约2倍——即使在FastRoute使用缓存的路由定义时也是如此。
注意
在比较替代方案时,请将AutoRoute视为与AltoRouter、FastRoute、Klein等处于同一类别,而不是Aura、Laminas、Laravel、Symfony等。
内容
动机
正则表达式(regex)路由器通常会复制通过反射可以找到的重要信息。如果您更改路由针对的动作方法参数,则需要更改路由正则表达式本身。因此,正则表达式路由器使用可能违反DRY原则。对于只有少量路由的系统,将路由文件作为重复信息来维护并不是一件麻烦事。但对于有上百或更多路由的系统,保持路由与其目标动作类和方法同步可能非常困难。
同样,基于注释的路由器将路由指令放在注释中,通常复制已经在显式方法签名中存在的动态参数。
作为正则表达式和基于注释路由器的替代方案,这个路由器实现通过自动将HTTP动作类层次结构映射到HTTP方法动词和URL路径,并反射类型提示的动作方法参数来确定URL的动态部分,消除了路由定义的需要。它假设动作类名称符合良好的约定,并且动作方法参数指示URL的动态部分。这使得实现既灵活又相对易于维护。
示例
给定基本命名空间为Project\Http
和基本URL为/
,这个请求...
GET /photos
...自动路由到类Project\Http\Photos\GetPhotos
。
同样,这个请求...
POST /photo
...自动路由到类Project\Http\Photo\PostPhoto
。
给定具有方法参数的动作类,如下...
namespace Project\Http\Photo; class GetPhoto { public function __invoke(int $photoId) { // ... } }
...以下请求将路由到它...
GET /photo/1
... 承认 1
应该是 $photoId
的值。
AutoRoute 支持在 URL 上的静态 "尾" 参数。如果 URL 以与类名尾部分匹配的路径段结束,并且动作类方法与其父类或祖父类具有相同数量和类型的参数,它将路由到该类名。例如,给定一个具有如下方法参数的动作类...
namespace Project\Http\Photo\Edit; class GetPhotoEdit // parent: GetPhoto { public function __invoke(int $photoId) { // ... } }
... 以下请求将路由到它
GET /photo/1/edit
最后,对于根 URL 的请求 ...
GET /
... 将自动路由到类 Project\Http\Get
。
提示
任何 HEAD 请求都会自动路由到一个明确的
Project\Http\...\Head*
类,如果存在。如果不存在明确的Head
类,请求将隐式地自动路由到匹配的Project\Http\...\Get*
类,如果存在。
工作原理
类文件命名
动作类文件假定根据 PSR-4 标准;进一步
-
类名以响应的 HTTP 动词开头;
-
接着是前续子命名空间的连接名称;
-
以
.php
结尾。
因此,给定基本命名空间 Project\Http
,类 Project\Http\Photo\PostPhoto
将是 POST /photo[/*]
的动作。
同样,Project\Http\Photos\GetPhotos
将是 GET /photos[/*]
的动作类。
并且 Project\Http\Photo\Edit\GetPhotoEdit
将是 GET /photo[/*]/edit
的动作类。
显式 Project\Http\Photos\HeadPhotos
将是 HEAD /photos[/*]
的动作类。如果 HeadPhotos
类不存在,动作类将被推断为 Project\Http\Photos\HeadPhotos
。
最后,在 URL 根路径处,Project\Http\Get
将是 GET /
的动作类。
动态参数
Router 会尊重动作方法参数的类型提示。例如,以下动作 ...
namespace Project\Http\Photos\Archive; class GetPhotosArchive { public function __invoke(int $year = null, int $month = null) { // ... } }
... 将响应以下 ...
GET /photos/archive
GET /photos/archive/1970
GET /photos/archive/1970/08
... 但不会响应以下 ...
GET /photos/archive/z
GET /photos/archive/1970/z
... 因为 z
被识别为整数。 (方法参数的更精细验证必须在动作方法本身或更理想的情况下在领域逻辑中完成,而 Router 不能直观地完成。)
Router 可以识别 int
、float
、string
、bool
和 array
的类型提示。
对于 bool
,Router 将不区分大小写地将这些 URL 段值转换为 true
:1, t, true, y, yes
。同样,它将不区分大小写地将这些 URL 段值转换为 false
:0, f, false, n, no
。
对于 array
,Router 将使用 str_getcsv()
在 URL 段值上,以生成一个数组。例如,对于 a,b,c
的数组类型提示,将接收 ['a', 'b', 'c']
。
最后,Router 也会尊重尾随的可变参数。给定以下动作方法 ...
namespace Project\Http\Photos\ByTag; class GetPhotosByTag { public function __invoke(string $tag, string ...$tags) { // ... } }
... Router 将尊重这个请求 ...
GET /photos/by-tag/foo/bar/baz/
... 并将方法参数识别为 __invoke('foo', 'bar', 'baz')
。
扩展示例
通过一个扩展示例,这些类将被以下 URL 路由到
App/
Http/
Get.php GET / (root)
Photos/
GetPhotos.php GET /photos (browse/index)
Photo/
DeletePhoto.php DELETE /photo/1 (delete)
GetPhoto.php GET /photo/1 (read)
PatchPhoto.php PATCH /photo/1 (update)
PostPhoto.php POST /photo (create)
Add/
GetPhotoAdd.php GET /photo/add (form for creating)
Edit/
GetPhotoEdit.php GET /photo/1/edit (form for updating)
HEAD 请求
RFC 2616 要求 "GET 和 HEAD 方法 必须 由所有通用服务器支持"。
因此,AutoRoute 将自动回退到 Get*
动作类,如果找不到相关的 Head*
动作类。这让您不必为每个可能的 Get*
动作创建一个 Head*
类。
但是,您仍然可以定义任何您喜欢的 Head*
动作类,并且 AutoRoute 将使用它。
用法
使用顶级 HTTP 动作命名空间和该命名空间中类的目录路径实例化 AutoRoute 容器类
use AutoRoute\AutoRoute; $autoRoute = new AutoRoute( 'Project\Http', dirname(__DIR__) . '/src/Project/Http/' );
如果您愿意,可以使用命名构造函数参数。
use AutoRoute\AutoRoute; $autoRoute = new AutoRoute( namespace: 'Project\Http', directory: dirname(__DIR__) . '/src/Project/Http/', );
然后从容器中提取Router,并使用HTTP请求方法动词和路径字符串调用route()
来获取一个Route。
$router = $autoRoute->getRouter(); $route = $router->route($request->method->name, $request->url->path);
然后可以使用返回的Route信息来调度到操作类方法,或者处理错误。
use AutoRoute\Exception; switch ($route->error) { case null: // no errors! create the action class instance // ... and call it with the method and arguments. $action = Factory::newInstance($route->class); $method = $route->method; $arguments = $route->arguments; $response = $action->$method(...$arguments); break; case Exception\InvalidArgument::CLASS: $response = /* 400 Bad Request */; break; case Exception\NotFound::CLASS: $response = /* 404 Not Found */; break; case Exception\MethodNotAllowed::CLASS: $response = /* 405 Not Allowed */; /* N.b.: Examine $route->headers to find the 'allowed' methods for the resource, if any. */ break; default: $response = /* 500 Server Error */; break; }
调试
要查看Router如何到达目的地,请检查Route的$messages
属性。
$route = $router->route($request->method->name, $request->url->path); print_r($route->messages);
此外,您可以将自定义PSR-3 LoggerInterface实现工厂作为自定义配置的一部分注入。
生成路由路径
使用AutoRoute容器,提取Generator。
$generator = $autoRoute->getGenerator();
然后使用动作类名称以及任何动作方法参数作为可变参数调用generate()
方法。
use Project\Http\Photo\Edit\GetPhotoEdit; use Project\Http\Photos\ByTag\GetPhotosByTag; $path = $generator->generate(GetPhotoEdit::CLASS, 1); // /photo/1/edit $path = $generator->generate(GetPhotosByTag::CLASS, 'foo', 'bar', 'baz'); // /photos/by-tag/foo/bar/baz
提示:
使用动作类名称作为路由名称意味着AutoRoute中的所有路由都是自动命名路由。
Generator将自动检查参数值与动作方法签名的一致性,以确保这些值将被Router识别。这意味着您不能(或者至少,不应该!)生成Router无法识别的路径。
自定义配置
您可以在AutoRoute实例化时设置这些命名构造函数参数来配置其行为。
baseUrl
您可以使用以下命名构造函数参数指定基本URL(即URL路径前缀)。
$autoRoute = new AutoRoute( // ... baseUrl: '/api', );
Router在确定路由的目标动作类时将忽略基本URL,而Generator将使用基本URL作为所有路径的前缀。
ignore
某些UI系统可能使用共享的Request对象,在这种情况下,很容易将Request注入到动作构造函数中。但是,其他系统可能无法访问共享的Request对象,或者可能正在使用在动作被调用时才完全形成的Request,因此必须以其他方式传递。
通常,这些类型的参数是在动作被调用时传递的,这意味着它们必须是动作方法签名的一部分。但是,AutoRoute将看到该参数,并错误地将其解释为动态段;例如
namespace Project\Http\Photo; use SapiRequest; class PatchPhoto { public function __invoke(SapiRequest $request, int $id) { // ... } }
为了解决这个问题,AutoRoute可以跳过动作方法上的任意数量的前导参数。为此,请使用以下命名构造函数参数设置要忽略的参数数量
$autoRoute = new AutoRoute( // ... ignoreParams: 1, );
...然后任何新的Router和Generator都将忽略第一个参数。
请注意,您需要在调用动作时自己传递该第一个参数。
// determine the route $route = $router->route($request->method, $request->url[PHP_URL_PATH]); // create the action object $action = Factory::newInstance($route->class); // pass the request first, then any route params $response = call_user_func([$action, $route->method], $request, ...$route->arguments);
loggerFactory
要向Router注入自定义PSR-3 Logger实例,请使用以下命名构造函数参数。
$autoRoute = new AutoRoute( // ... loggerFactory: function () { // return a \Psr\Log\LoggerInterface implementation }, );
method
如果您使用除__invoke()
以外的动作方法名称,例如exec()
或run()
,可以使用以下命名构造函数参数告诉AutoRoute对其参数进行反射。
$autoRoute = new AutoRoute( // ... method: 'exec', );
Router和Generator现在将检查exec()
方法以确定URL路径的动态段。
suffix
如果您的代码库给所有动作类名称相同的后缀,例如"Action",您可以使用以下命名构造函数参数告诉AutoRoute忽略该后缀。
$autoRoute = new AutoRoute( // ... suffix: 'Action', );
Router和Generator现在将忽略类名称的后缀部分。
wordSeparator
默认情况下,Router和Generator将使用破折号作为单词分隔符将静态URL路径段从foo-bar
转换为FooBar
。如果您想使用不同的单词分隔符,例如下划线,可以使用以下命名构造函数参数来做到这一点。
$autoRoute = new AutoRoute( // ... wordSeparator: '_', );
这将导致Router和Generator从foo_bar
转换为FooBar
(以及反向转换)。
导出所有路由
您可以使用bin/autoroute-dump.php
命令行工具列出所有已识别的路由及其目标动作类。传递基本HTTP动作命名空间以及存储动作类的目录。
$ php bin/autoroute-dump.php Project\\Http ./src/Http
输出将类似于以下内容
GET /
Project\Http\Get
POST /photo
Project\Http\Photo\PostPhoto
GET /photo/add
Project\Http\Photo\Add\GetPhotoAdd
DELETE /photo/{int:id}
Project\Http\Photo\DeletePhoto
GET /photo/{int:id}
Project\Http\Photo\GetPhoto
PATCH /photo/{int:id}
Project\Http\Photo\PatchPhoto
GET /photo/{int:id}/edit
Project\Http\Photo\Edit\GetPhotoEdit
GET /photos/archive[/{int:year}][/{int:month}][/{int:day}]
Project\Http\Photos\Archive\GetPhotosArchive
GET /photos[/{int:page}]
Project\Http\Photos\GetPhotos
您可以使用以下命令行选项来指定替代配置
--base-url=
用于设置基本 URL--ignore-params=
用于忽略一些方法参数--method=
用于设置动作类的方法名称--suffix=
用于标记标准动作类后缀--word-separator=
用于指定替代单词分隔符
从路由创建类
AutoRoute 提供了对基于路由动词和路径创建类文件的简化支持,使用模板。
要这样做,请使用基本命名空间、该命名空间的目录、HTTP 动词和带有参数占位符的 URL 路径调用 autoroute-create.php
。
例如,以下命令...
$ php bin/autoroute-create.php Project\\Http ./src/Http GET /photo/{photoId}
... 将在 ./src/Http/Photo/GetPhoto.php
创建此类文件
namespace Project\Http\Photo; class GetPhoto { public function __invoke($photoId) { } }
此命令不会覆盖现有文件。
您可以使用以下命令行选项来指定替代配置
--method=
用于设置动作类的方法名称--suffix=
用于标记标准动作类后缀--template=
用于指定自定义模板的路径--word-separator=
用于指定替代单词分隔符
默认类模板文件是 resources/templates/action.tpl
。如果您决定编写自己的自定义模板,可用的字符串替换占位符包括
{NAMESPACE}
{CLASS}
{METHOD}
{PARAMETERS}
这些名称应该很容易理解。
注意
即使有自定义模板,您几乎肯定需要编辑新文件以添加构造函数、类型提示、默认值等。文件创建功能是必要的简化版本,无法考虑到您特定情况的所有可能的变体。
问题和解决方案
子资源
注意:深层嵌套的子资源目前被认为是一种不良做法,但它们足够常见,需要在这里加以注意。
深层嵌套的子资源受到支持,但它们的动作类方法参数必须符合“常规”签名,以便 Router 和 Generator 能够识别 URL 的哪些部分是动态的,哪些是静态的。
-
子资源动作必须至少与其“父”资源动作具有相同数量和类型的参数;或者,在静态尾部参数动作的情况下,必须与它的“祖父”资源动作具有完全相同的参数数量和类型。(如果没有父或祖父资源动作,则它不需要任何参数。)
-
子资源动作可以在此之后添加参数,无论是必需的还是可选的。
-
当 URL 路径包含任何可选参数段时,对该路径下进一步子资源动作的路由将被终止。
提示:
上述术语“父”和“祖父”是在 URL 路径意义上使用的,而不是在类层次意义上使用的。
/* GET /company/{companyId} # get an existing company */ namespace Project\Http\Company; class GetCompany // no parent resource { public function __invoke(int $companyId) { // ... } } /* POST /company # add a new company*/ class PostCompany // no parent resource { public function __invoke() { // ... } } /* PATCH /company/{companyId} # edit an existing company */ class PatchCompany // no parent resource { public function __invoke(int $companyId) { // ... } } /* GET /company/{companyId}/employee/{employeeNum} # get an existing company employee */ namespace Project\Http\Company\Employee; class GetCompanyEmployee // parent resource: GetCompany { public function __invoke(int $companyId, int $employeeNum) { // ... } } /* POST /company/{companyId}/employee # add a new company employee */ namespace Project\Http\Company\Employee; class PostCompanyEmployee // parent resource: PostCompany { public function __invoke(int $companyId) { // ... } } /* PATCH /company/{companyId}/employee/{employeeNum} # edit an existing company employee */ namespace Project\Http\Company\Employee; class PatchCompanyEmployee // parent resource: PatchCompany { public function __invoke(int $companyId, int $employeeNum) { // ... } }
细粒度输入验证
问题:如何指定类似于正则表达式路由 path('/foo/{id}')->token(['id' => '\d{4}'])
的内容?
答案:您不需要。(不过,请参阅下文的“值对象作为动作参数”主题。)
您的领域对输入进行了良好的验证,而不是您的路由系统(仅进行粗略验证)。AutoRoute 在将参数转换为参数时,将设置参数的类型,如果值无法正确类型转换,则可能引发 InvalidArgument 或 NotFound 异常。
例如,在动作
namespace Project\Http\Photos\Archive; use SapiResponse; class GetPhotosArchive { public function __invoke( int $year = null, int $month = null, int $day = null ) : SapiResponse { $payload = $this->domain->fetchAllBySpan($year, $month, $day); return $this->responder->response($payload); } }
然后,在领域
namespace Project\Domain; class PhotoService { public function fetchAllBySpan( ?int $year = null, ?int $month = null, ?int $day = null ) : Payload { $select = $this->atlas ->select(Photos::class) ->orderBy('year DESC', 'month DESC', 'day DESC'); if ($year !== null) { $select->where('year = ', $year); } if ($month !== null) { $select->where('month = ', $month); } if ($day !== null) { $select->where('day = ', $day); } $result = $select->fetchRecordSet(); if ($result->isEmpty()) { return Payload::notFound(); } return Payload::found($result); } }
值对象作为动作参数
问题:我能否使用对象(而不是标量或数组)作为动作参数?
答案:可以,但有几点注意事项。
尽管您无法在路由中指定输入验证,但您确实可以将值对象指定为参数,并在其构造函数中进行验证。这些值对象可能来自任何地方,包括领域。
例如,您的底层应用程序服务类可能需要领域值对象作为输入,动作本身创建这些值对象
namespace Project\Http\Company; use Domain\Company\CompanyId; class GetCompany { // ... public function __invoke(int $companyId) { // ... $payload = $this->domain->fetchCompany( new CompanyId($companyId) ); // ... } }
相应的值对象可能看起来像这样
namespace Domain\Company; use Domain\ValueObject; class CompanyId extends ValueObject { public function __construct(protected int $companyId) { } }
为了避免手动将动态路径段转换为值对象,您可以使用值对象类型本身作为动作参数,如下所示
namespace Project\Http\Company; use Domain\Company\CompanyId; class GetCompany { // ... public function __invoke(CompanyId $companyId) { // ... $payload = $this->domain->fetchCompany($companyId); // ... } }
给定HTTP请求GET /company/1
,路由器将注意到操作参数是CompanyId类型,并使用URL路径的相关部分来构建CompanyId参数。
此外,您可以尝试验证和/或清理值对象参数,在验证失败时抛出异常。例如:
namespace Domain\Photo; use Domain\Exception\InvalidValue; use Domain\ValueObject; class Year extends ValueObject { public function __construct(protected int $year) { if ($this->year < 0 || $this->year > 9999) { throw new InvalidValue("The year must be between 0000 and 9999."); } } }
检查Route::$error
是否包含这些异常,并发送适当的HTTP响应由您决定。
一些附加说明
-
您可以使用任意数量的值对象构造函数参数;每个参数将按顺序捕获一个路径段。
-
路径段将根据值对象构造函数参数的类型提示转换为正确的数据类型。
-
将类类型用作值对象参数将不会正确工作;仅使用标量和数组作为值对象参数类型。
-
在值对象中使用可选或可变参数可能不会总是按预期工作。如果您的值对象具有可选或可变参数,请将这些值对象保存为URL路径末尾的部分。
-
您可以在操作方法签名中将值对象参数与标量和数组参数组合。
使用值对象生成路径
当为使用值对象的操作生成路径时,您需要按在URL中出现的顺序传递单个参数,而不是按调用操作时出现的顺序。对于上述GetCompany操作,您不会实例化CompanyId;相反,您将传递整数值参数。
// wrong: $path = $generator->generate(GetCompany::CLASS, new CompanyId(1)); // right: $path = $generator->generate(GetCompany::CLASS, 1);
使用值对象导出路径
当您通过Dumper导出路由时,您会发现与值对象关联的动态段将按值对象构造函数参数命名。如果在操作方法签名中有多个值对象,并且这些值对象在其构造函数中使用相同的参数名称,您将在导出的路径中看到这些名称的重复。这不会对AutoRoute本身造成任何不良影响,但在审查路径字符串时可能会造成混淆。
捕获其他请求值
问:如何捕获主机名?标头?查询参数?体?
答:从您的请求对象中读取它们。
例如,在动作
namespace Project\Http\Foos; use SapiRequest; class GetFoos { public function __construct( SapiRequest $request, FooService $fooService ) { $this->request = $request; $this->fooService = $fooService; } public function __invoke(int $fooId) { $host = $this->request->headers['host'] ?? null; $bar = $this->request->get['bar'] ?? null; $body = json_decode($this->request->content, true) ?? []; $payload = $this->fooService->fetch($host, $foo, $body); // ... } }
然后,在领域
namespace Project\Domain; class FooService { public function fetch(int $fooId, string $host, string $bar, array $body) { // ... } }