shaggy8871 / yurly
Yurly 是一个用于 PHP 7 的轻量级 MVC 路由库
Requires
- php: >=7.2.0
- psr/container: ^1.0
- twig/twig: ^2.0
Requires (Dev)
- php-di/php-di: ^6.0
- phpunit/phpunit: ^8
This package is auto-updated.
Last update: 2024-09-11 23:38:54 UTC
README
Yurly 是一个用于 PHP 7 的轻量级 Web MVC 路由库。它易于上手,几乎无需配置,可以在现有项目中运行而无需大规模重写。
它还支持开箱即用的多站实施。
目录
安装
在 composer.json 中
{
"require": {
"shaggy8871/yurly": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^8"
},
"autoload": {
"psr-4": {
"Myapp\\": "./src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "./tests/"
}
}
}
将 Myapp
替换为您的项目名称。
然后运行
composer install
创建您的项目
一旦 composer 依赖项安装完成,运行以下命令以创建基本的项目文件结构,以及您的第一个控制器和测试套件
vendor/bin/ymake project
如果一切按计划进行,您应该会看到以下输出
Creating project structure:
✅ Created ./src
✅ Created ./src/Controllers
✅ Created ./src/Models
✅ Created ./src/Views
✅ Created ./src/Views/cache
✅ Created ./src/Views/base.html.twig
✅ Created ./tests
✅ Created ./tests/Bootstrap.php
Creating Index controller:
✅ Created ./src/Controllers/Index.php
✅ Created ./src/Models/Index.php
✅ Created ./src/Views/Index
✅ Created ./src/Views/Index/default.html.twig
Creating test Index controller:
✅ Created ./tests/Controllers/IndexTest.php
Creating document root:
✅ Created public
✅ Created public/index.php
要测试网站,请运行以下命令
php -S localhost:8000 -t public/
然后在浏览器中打开https://:8000/
要运行单元测试
vendor/bin/phpunit
基本路由
默认情况下,路由是根据控制器类名和方法名确定的。
控制器必须扩展 Yurly\Core\Controller
类,并且必须包含至少一个以单词 route
开头的方法名。
Request
和Response
类可以注入到任何路由方法中。第一个找到的Response
类将被用于渲染从route*
方法返回的任何值。
示例 src/Controllers/Index.php 文件
<?php namespace Myapp\Controllers; use Yurly\Core\Controller; use Yurly\Inject\Request\Get; use Yurly\Inject\Response\{Twig, Json}; class Index extends Controller { /** * This is the home page */ public function routeDefault(Get $request, Twig $response): array { return [ 'title' => 'Welcome to Yurly', 'content' => 'You\'re on the home page. You can customize this view in Myapp/Views/Index/default.html.twig.' ]; } /** * This is an example about us page */ public function routeAbout(Get $request, Twig $response): array { return [ 'title' => 'About Us', 'content' => 'You can customize this page in <Yourapp>/Views/Index/about.html.twig.' ]; } /** * This is an example route with JSON response */ public function routeJson(Get $request, Json $response): array { return [ 'title' => 'JSON', 'content' => 'This will be output in JSON format', 'params' => $request->toArray(), // be aware - unsanitised! ]; } }
路由参数
可以使用位于路由上方的 @canonical
docblock 语句指定路由参数。
<?php namespace Myapp\Controllers; use Yurly\Core\Controller; use Yurly\Inject\Request\RouteParams; use Yurly\Inject\Response\Json; class Example extends Controller { /** * @canonical /example/:requiredParam(/:optionalParam) */ public function routeDefault(RouteParams $request, Json $response) { return $request->toArray(); /** * You can also access route parameters via: * - $request->requiredParam * - $request->optionalParam */ } }
在上面的示例中,调用 /example/hello/world 将返回以下 JSON 响应
{ "requiredParam":"hello", "optionalParam":"world" }
接受多种请求类型
通用的 Request
类具有可用于提取多种请求类型的辅助函数。
<?php namespace Myapp\Controllers; use Yurly\Core\Controller; use Yurly\Inject\Request\Request; use Yurly\Inject\Response\Json; class Example extends Controller { public function routeDefault(Request $request, Json $response) { // GET parameters $getParams = $request->get(); // POST parameters $postParameters = $request->post(); /** * You can access get/post parameters via: * - $getParams->paramName * - $postParams->paramName */ } }
中间件
Yurly 为中间件代码提供了在调用路由前后运行的选择。
@before
对于每个路由,在方法声明上方添加一个 @before
docblock 将在调用路由之前运行指定的方法。这可以用来将用户指向替代路由,或者在实际的路由代码运行之前查找额外的元数据。中间件可以指定为要调用的方法名,或者如果不在控制器中,可以指定为 Controller::method
的形式。可以指定多个方法,用逗号分隔,将按照提供的顺序运行。
要更改要渲染的路由,中间件应返回一个字符串形式的替代路由,例如 Controller::routeMethod
。
要在一个控制器中调用所有路由之前运行代码,将一个 beforeAllRoutes()
方法添加到控制器中。这将会在所有 @before
docblock 方法被调用之前运行。
@after
@after
docblock 会在路由运行之后调用指定的类方法。每个中间件方法将接收到响应的一个副本,并在将其渲染到浏览器之前可以修改它。可以指定多个方法,用逗号分隔,将按照提供的顺序运行。
要在一个控制器中调用所有路由之后运行中间件,将一个 afterAllRoutes()
方法添加到控制器中。这段代码将在所有 @after
docblock 方法被调用之前运行。
src/Myapp/Middleware/Auth.php
<?php namespace Myapp\Middleware; use Yurly\Core\{Url, Context}; trait Auth { public function isLoggedIn(Url $url, Context $context): ?string { $caller = $context->getCaller(); $annotations = $caller->getAnnotations(); // The @role docblock isn't required, it's a suggestion $roles = []; if (isset($annotations['role'])) { $roles = explode(', ', $annotations['role']); } if (!$this->authenticate($roles)) { return 'User::routeLogout'; } return null; } private function authenticate(array $roles): bool { // Add your auth code here } }
src/Myapp/Controllers/Admin.php
<?php namespace Myapp\Controllers; use Yurly\Core\Controller; use Yurly\Inject\Request\Get; use Yurly\Inject\Response\Twig; use Myapp\Middleware\Auth; class Admin extends Controller { use Auth; /** * @before isLoggedIn * @role admin */ public function routeDefault(Get $request, Twig $response): array { return [ 'message' => 'Welcome!' ]; } }
中间件状态
中间件方法可以使用名为 MiddlewareState
的类来检查前一个中间件方法的响应,并在需要时阻止路由器调用后续中间件方法。
调用
stop()
并不会阻止路由被调用。它只会阻止调用后续的中间件方法。
src/Myapp/Controllers/Admin.php
<?php namespace Myapp\Controllers; use Yurly\Core\Controller; use Yurly\Inject\Response\Twig; use Yurly\Middleware\MiddlewareState; use Myapp\Models\User; use Myapp\Models\AdminPermissions; class Admin extends Controller { private $user; private $permissions; /** * @before isLoggedIn, hasPermission */ public function routeDefault(Twig $response): array { return [ 'user' => $this->user, 'permissions' => $this->permissions, ]; } public function isLoggedIn(MiddlewareState $state): ?string { $this->user = User::getLoggedIn(); if (!($this->user instanceof User)) { // Don't call hasPermission, go straight to login route $state->stop(); return 'User::routeLogin'; } return null; } public function hasPermission(MiddlewareState $state): ?string { $this->permissions = AdminPermissions::getPermissions($this->user); if (empty($this->permissions)) { // Go to access denied route $state->stop(); return 'User::routeAccessDenied'; } return null; } }
自定义请求/响应类
可以创建自定义请求和响应类来提供内置类未提供的额外功能。例如,包括额外的输入源、改进的输入清理和输出数据映射。
模型查找和数据映射示例
src/Myapp/Models/User.php
<?php namespace Myapp\Models; class User { public $fname; public $lname; //... more props // Example find method public static function findById(int $id): ?self { if ($id == 1) { $instance = new static(); $instance->fname = 'First name'; $instance->lname = 'Last name'; return $instance; } return null; } }
src/Myapp/Inject/Request/UserFinder.php
<?php namespace Myapp\Inject\Request; use Yurly\Inject\Request\RouteParams; use Myapp\Models\User; class UserFinder extends RouteParams { public function find(): ?User { $userId = filter_var($this->id, FILTER_VALIDATE_INT); if ($userId) { return User::findById($userId); } return null; } }
src/Myapp/Inject/Response/UserJsonDataMapper.php
<?php namespace Myapp\Inject\Response; use Yurly\Inject\Response\Json; use Myapp\Models\User; class UserJsonDataMapper extends Json { /** * Render parameters with data mapping */ public function render($params = null): void { if ($params instanceof User) { parent::render([ 'first_name' => $params->fname, 'last_name' => $params->lname, ]); return; } parent::render([ 'error' => 'User Not Found' ]); } }
src/Myapp/Controllers/User.php
<?php namespace Myapp\Controllers; use Yurly\Core\Controller; use Myapp\Inject\Request\UserFinder; use Myapp\Inject\Response\UserJsonDataMapper; use Myapp\Models\User as UserModel; class User extends Controller { /** * @canonical /user/:id */ public function routeDefault(UserFinder $request, UserJsonDataMapper $response): ?UserModel { return $request->find(); } }
依赖注入参数
Yurly 不包括对 Request 和 Response 类之外的依赖注入的原生支持,但通过 composer 添加 PSR-11 兼容的 DI 解决方案非常容易。以下是一个使用 PHP-DI 的示例。
composer.json
composer require php-di/php-di
src/Myapp/Config.php
<?php namespace Myapp; use Yurly\Core\Project; use DI\Container; class Config { public function __construct(Project $project) { $container = new Container(); $project->addContainer($container); } }
src/Myapp/Controllers/Index.php
class Index extends Controller { /** * Yurly\Core\Project must always be the first parameter */ public function __construct(Project $project, \Myapp\Models\User $user) { // $user is instantiated and ready parent::__construct($project); } public function routeWithDI(\Myapp\Models\User $user) { // $user is instantiated and ready } }
自定义路由解析器
如果您有不符合控制器/方法方法的路由,可以轻松创建一个自定义路由解析类来处理自定义路由。
在项目目录的根目录下创建一个名为 RouteResolver
的类,并确保它 实现 RouteResolverInterface
接口。它必须包含一个名为 resolve
的方法,该方法返回格式为 Controller::method
的路由。任何其他返回值都将被忽略。
<?php namespace Myapp; use Yurly\Core\{Project, Url}; use Yurly\Core\Interfaces\RouteResolverInterface; use Yurly\Core\Utils\RegExp; class RouteResolver implements RouteResolverInterface { public function resolve(Project $project, Url $url) { $routes = [ [ 'match' => new RegExp('/^\/[a-z0-9]+\/product\/[a-z0-9]+\/?$/'), 'route' => 'Products::routeSearch', ], //... add your match/routes here ]; foreach($routes as $route) { if ($route['match']->matches($url->requestUri)) { return $route['route']; } } } }
多站设置
在 composer.json 中,为每个唯一的命名空间添加一个 psr-4
自动加载,例如
{ "name": "example", "require": { "php": ">=7.2.0", "shaggy8871/yurly": "^2.0", "twig/twig": "^2.0" }, "require-dev": { "phpunit/phpunit": "^8" }, "autoload": { "psr-4": { "Site1\\": "./src/Site1/", "Site2\\": "./src/Site2/" } } }
在 public/index.php 中,为每个唯一的域名输入一个项目行
<?php include_once "../vendor/autoload.php"; use Yurly\Core\{Project, Init}; $projects = [ new Project('www.site1.com', 'Site1', './src'), new Project('www.site1.com', 'Site2', './src'), ]; $app = new Init($projects); // Start 'em up $app->run();
在 /src/Site1
和 /src/Site2
中创建一个 Controllers
目录,并添加您的 Index.php 控制器类。您还可以创建 Models
、Views
和任何其他可能需要的目录。
如果您需要支持多个主机,可以传递一个主机数组,或者使用如下所示的 RegExp
辅助类
// Array of hosts per project: $projects = [ new Project(['www.site1.com', 'dev.site1.com'], 'Site1', './src'), new Project(['www.site2.com', 'dev.site2.com'], 'Site2', './src'), ];
或
use Yurly\Core\Utils\RegExp; // RegExp helper class: $projects = [ new Project(new RegExp('/^.*\.site1\.com$/'), 'Site1', './src'), new Project(new RegExp('/^.*\.site2\.com$/'), 'Site2', './src'), ];
使用 ymake
辅助工具
Yurly 随附一个名为 ymake
的辅助应用程序。您可以使用该助手创建项目、控制器、模型和视图文件,或索引.php 文件。
- 将自动加载命名空间添加到 composer.json 中,例如
"autoload": { "psr-4": { "Site1\\": "./src/Site1/", "Site2\\": "./src/Site2/" } }, "autoload-dev": { "psr-4": { "Tests\\Site1\\": "./tests/Site1/", "Tests\\Site2\\": "./tests/Site2/" } }
- 运行适当的命令来生成脚本
您将根据使用的命令提示输入更多详细信息。
单元测试
Yurly 通过添加额外的方法扩展了 PHPUnit 的 TestCase 类,以帮助测试路由。以下是一个简单的示例
<?php namespace Tests\Controllers; use Yurly\Test\TestCase; class ExampleTest extends TestCase { public function testRoute() { $response = $this ->setProjectNamespace('Myapp') ->setProjectPath('./src') ->setUrl('/') ->getRouteResponse(); $this->assertEquals($response, ['message' => 'Welcome!']); } }
如果您希望捕获完整的路由响应输出,只需按如下方式调用路由即可
<?php namespace Tests\Controllers; use Yurly\Test\TestCase; class ExampleTest extends TestCase { public function testRoute() { $this->expectOutputString('<h1>Welcome to Yurly!</h1>'); $response = $this ->setProjectNamespace('Myapp') ->setProjectPath('./src') ->setUrl('/') ->callRoute(); } }
您可以通过模拟请求类来测试控制器,并使用不同的输入。
在路由方法中声明的类类型无法更改。
<?php namespace Tests\Controllers; use Yurly\Test\TestCase; use Yurly\Inject\Request\Get; class ExampleTest extends TestCase { public function testRouteWithRequestMock() { $this ->setProjectNamespace('Myapp') ->setProjectPath('./src') ->setUrl('/'); $mockRequest = $this->getRequestMock(Get::class, function(Get $self) { $self->setProps(['hello' => 'World']); }); $response = $this ->getRouteResponse([ Get::class => $mockRequest ]); $this->assertEquals($response, ['message' => 'World']); } }
要测试通用的 Request
类(请求方法事先未知,或者单个请求中预期有多个输入),请使用 setTypeProps
方法为每个请求类型配置属性。要设置默认请求方法,请通过数组传递 requestMethod
到 $this->setUrl()
方法的第二个参数。
$this->setUrl('/', [ 'requestMethod' => 'POST' ]); $mockRequest = $this->getRequestMock(Request::class, function(Request $self) { $self->setTypeProps(Request::TYPE_POST, [ 'var1' => 'val1', 'var2' => 'val2', 'var3' => 'val3', ]); $self->setTypeProps(Request::TYPE_GET, [ 'query' => 'test' ]); });
您也可以模拟响应类,并在渲染前捕获输出。
您不能将模拟的 Request 类传递给
getRouteResponse
,因为它已经使用了一个来捕获输出。相反,请使用callRouteWithMocks
方法。
<?php namespace Tests\Controllers; use Yurly\Test\TestCase; use Yurly\Inject\Response\Twig; class ExampleTest extends TestCase { public function testRouteWithResponseMock() { $this ->setProjectNamespace('Myapp') ->setProjectPath('./src') ->setUrl('/'); $mockResponse = $this->getResponseMock(Twig::class, function(array $params) { $this->assertEquals($params, ['message' => 'Welcome!']); }); $this ->callRouteWithMocks([ Twig::class => $mockResponse ]); $mockResponse->assertOk(); $mockResponse->assertContentType('text/html'); } }