shaggy8871/yurly

Yurly 是一个用于 PHP 7 的轻量级 MVC 路由库

2.1.8 2020-07-11 13:58 UTC

README

Build Status

Yurly 是一个用于 PHP 7 的轻量级 Web MVC 路由库。它易于上手,几乎无需配置,可以在现有项目中运行而无需大规模重写。

它还支持开箱即用的多站实施。

目录

  1. 安装
  2. 基本路由
  3. 路由参数
  4. 接受多种请求类型
  5. 中间件
  6. 自定义请求/响应类
  7. 依赖注入参数
  8. 自定义路由解析器
  9. 多站设置
  10. 使用 ymake 辅助工具
  11. 单元测试

安装

在 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 开头的方法名。

RequestResponse 类可以注入到任何路由方法中。第一个找到的 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 控制器类。您还可以创建 ModelsViews 和任何其他可能需要的目录。

如果您需要支持多个主机,可以传递一个主机数组,或者使用如下所示的 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 文件。

  1. 将自动加载命名空间添加到 composer.json 中,例如
    "autoload": {
        "psr-4": {
            "Site1\\": "./src/Site1/",
            "Site2\\": "./src/Site2/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\Site1\\": "./tests/Site1/",
            "Tests\\Site2\\": "./tests/Site2/"
        }
    }
  1. 运行适当的命令来生成脚本

您将根据使用的命令提示输入更多详细信息。

单元测试

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');
    }

}