Revision: Mon, 28 Apr 2025 23:01:05 GMT

HTTP — 拦截器

Spiral 提供了用于 HTTP 请求的拦截器,允许你在请求生命周期的各个阶段拦截和修改请求和响应。

了解更多 可以在 框架 — 拦截器 章节中阅读更多关于拦截器的信息。

域核心构建器

框架提供了一个方便的 Bootloader,名为 Spiral\Bootloader\DomainBootloader,允许开发者注册拦截器,并为应用程序添加通用功能,如日志记录、错误处理和安全措施,只需在一个地方进行配置,而不是将它们添加到每个控制器中。

该 Bootloader 还提供了配置拦截器执行顺序的能力,使开发人员可以控制应用程序的流程。

php
app/src/Application/Bootloader/AppBootloader.php
namespace App\Application\Bootloader;

use App\Interceptor\CustomInterceptor;
use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class => [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        HandleExceptionsInterceptor::class,
        JsonPayloadResponseInterceptor::class,
    ];
}

示例

Cycle 实体解析

Cycle Bridge 包提供了 Spiral\Cycle\Interceptor\CycleInterceptor。 使用 CycleInterceptor 根据参数值自动解析实体注入:

要激活拦截器:

php
app/src/Application/Bootloader/DomainBootloader.php
namespace App\Application\Bootloader;

use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;
use Spiral\Cycle\Interceptor\CycleInterceptor;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class => [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        // ...
        CycleInterceptor::class,
    ];
}

你可以在你的 UserController 方法中使用任何 cycle 实体注入,<id> 参数将用作主键。 如果找不到实体,将抛出 404 异常。

php
app/src/Endpoint/Web/UserController.php
namespace App\Endpoint\Web;

use App\Domain\Blog\Entity\User;
use Spiral\Router\Annotation\Route;

final class UserController
{
    #[Route(route: '/users/<id>')]
    public function show(User $user)
    {
        dump($user);
    }
}

了解更多HTTP — 路由 章节中阅读更多关于基于注解的路由的信息。

如果你期望多个实体,你必须使用命名参数:

php
app/src/Endpoint/Web/BlogController.php
namespace App\Endpoint\Web;

use App\Domain\Blog\Entity\Blog;
use App\Domain\Blog\Entity\Author;
use Spiral\Router\Annotation\Route;

final class BlogController
{
    #[Route(route: '/blog/<author>/<post>')]
    public function show(Author $author, Blog $post)
    {
        dump($author, $blog);
    }
}

注意 方法的参数必须命名为路由参数。

Guard 拦截器

使用 Spiral\Domain\GuardInterceptor 实现 RBAC 预授权逻辑(确保安装并激活 spiral/security)。

php
app/src/Application/Bootloader/DomainBootloader.php
namespace App\Application\Bootloader;

use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;
use Spiral\Domain\GuardInterceptor;
use Spiral\Security\Actor\Guest;
use Spiral\Security\PermissionsInterface;
use Spiral\Security\Rule;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class => [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        // ...
        GuardInterceptor::class
    ];

    public function boot(PermissionsInterface $rbac): void
    {
        $rbac->addRole(Guest::ROLE);
        $rbac->associate(Guest::ROLE, 'home.*', Rule\AllowRule::class);
        $rbac->associate(Guest::ROLE, 'home.about', Rule\ForbidRule::class);
    }
}

你可以使用属性配置要应用于控制器操作的权限:

php
app/src/Endpoint/Web/HomeController.php
namespace App\Endpoint\Web;

use Spiral\Domain\Annotation\Guarded;

class HomeController
{
    #[Guarded(permission: 'home.index')]
    public function index(): string
    {
        return 'OK';
    }

    #[Guarded(permission: 'home.about')]
    public function about(): string
    {
        return 'OK';
    }
}

要指定当未检查权限时使用的后备操作,请使用 Guardedelse 属性:

php
app/src/Endpoint/Web/HomeController.php
#[Guarded(permission: 'home.about', else: 'notFound')]
public function about(): string
{
    return 'OK';
}

注意 允许的值:notFound (404), forbidden (401), error (500), badAction (400)。

使用属性 Spiral\Domain\Annotation\GuardNamespace 指定控制器 RBAC 命名空间并从每个操作中删除前缀。 你还可以在指定命名空间时跳过 Guarded 中的权限定义(安全组件将使用 namespace.methodName 作为权限名称)。

php
app/src/Endpoint/Web/HomeController.php
use Spiral\Domain\Annotation\Guarded;
use Spiral\Domain\Annotation\GuardNamespace;

#[GuardNamespace(namespace: 'home')]
class HomeController
{
    #[Guarded]
    public function index(): string
    {
        return 'OK';
    }

    #[Guarded(else: 'notFound')]
    public function about(): string
    {
        return 'OK';
    }
}

规则上下文

你可以将所有方法参数用作规则上下文,例如,我们可以创建一个规则:

php
app/src/Application/Security/SampleRule.php
namespace App\Application\Security;

use Spiral\Security\ActorInterface;
use Spiral\Security\RuleInterface;

class SampleRule implements RuleInterface
{
    public function allows(ActorInterface $actor, string $permission, array $context): bool
    {
        return $context['user']->getID() !== 1;
    }
}

要激活该规则:

php
app/src/Application/Bootloader/DomainBootloader.php
namespace App\Application\Bootloader;

use App\Application\Security\SampleRule;
use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;
use Spiral\Cycle\Interceptor\CycleInterceptor;
use Spiral\Domain\GuardInterceptor;
use Spiral\Security\Actor\Guest;
use Spiral\Security\PermissionsInterface;
use Spiral\Security\Rule;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class => [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        //...
        CycleInterceptor::class,
        GuardInterceptor::class
    ];

    public function boot(PermissionsInterface $rbac): void
    {
        $rbac->addRole(Guest::ROLE);
        $rbac->associate(Guest::ROLE, 'home.*', SampleRule::class);
        $rbac->associate(Guest::ROLE, 'home.about', Rule\ForbidRule::class);
    }
}

注意 确保该路由包含 <id><user> 参数。

并修改该方法:

php
#[Guarded] 
public function index(User $user): string
{
    return 'OK';
}

该方法将不允许使用用户 ID 1 调用该方法。

注意 确保在域核心中 GuardInterceptor 之前启用 CycleInterceptor

DataGrid 拦截器

你可以使用 DataGrid 属性和 GridInterceptor 自动将数据网格规范应用于可迭代输出。 此拦截器在端点调用后被调用,因为它使用输出。

php
app/src/Endpoint/Web/UsersController.php
use App\Domain\User\Repository\UserRepository;
use App\Intergarion\Keeper\View\UserGrid;
use Spiral\DataGrid\Annotation\DataGrid;
use Spiral\Router\Annotation\Route;

class UsersController
{
    #[Route(route: '/users', name: 'users')]
    #[DataGrid(grid: UserGrid::class)]
    public function list(UserRepository $userRepository): iterable
    {
        return $userRepository->select();
    }
}   

注意 grid 属性应该引用一个在构造函数中声明了规范的 GridSchema 类。

php
app/src/Intergarion/ViewKeeper/Keeper/UserGrid.php
namespace App\Intergarion\Keeper\View;

use Spiral\DataGrid\GridSchema;
use Spiral\DataGrid\Specification\Filter;
use Spiral\DataGrid\Specification\Pagination\PagePaginator;
use Spiral\DataGrid\Specification\Sorter;
use Spiral\DataGrid\Specification\Value;

class UserGrid extends GridSchema
{
    public function __construct()
    {
        $this->addSorter('email', new Sorter\Sorter('email'));
        $this->addSorter('name', new Sorter\Sorter('name'));
        $this->addFilter('status', new Filter\Equals('status', new Value\EnumValue(new Value\StringValue(), 'active', 'disabled')));
        $this->setPaginator(new PagePaginator(20, [10, 20, 50, 100]));
    }
}

(可选)你可以指定 view 属性,以指向每个记录的可调用 presenter。 如果没有指定,GridInterceptor 将在声明的网格中调用 __invoke

php
app/src/Intergarion/ViewKeeper/Keeper/UserGrid.php
namespace App\Application\View;

use Spiral\DataGrid\GridSchema;
use App\Database\User;

class UserGrid extends GridSchema
{
    //...
    
    public function __invoke(User $user): array
    {
        return [
            'id'     => $user->id,
            'name'   => $user->name,
            'email'  => $user->email,
            'status' => $user->status
        ];
    }
}

你可以通过 defaults 属性或在你的网格中使用 getDefaults() 方法来指定网格默认值(例如默认排序、过滤、分页):

php
app/src/Endpoint/Web/UsersController.php
#[DataGrid(
    grid: UserGrid::class,
    defaults: [
        'sort' => ['name' => 'desc'],
        'filter' => ['status' => 'active'],
        'paginate' => ['limit' => 50, 'page' => 10]
    ]
)]

默认情况下,网格输出将如下所示:

json
{
  "status": 200,
  "data": [
    {
      ...
    },
    {
      ...
    },
    {
      ...
    }
  ]
}

你可以重命名 data 属性或在网格中传递确切的 status 代码 optionsgetOptions() 方法:

php
app/src/Endpoint/Web/UsersController.php
#[DataGrid(grid: UserGrid::class, options: ['status' => 201, 'property' => 'users'])]
json
{
  "status": 201,
  "users": [
    ...
  ]
}

GridInterceptor 将创建一个 GridFactoryInterface 实例,以将给定的可迭代源包装在声明的网格模式中。 默认情况下使用 GridFactory,但是如果你需要更复杂的逻辑,例如使用自定义计数器或规范利用,你可以在注解中声明你自己的工厂:

php
app/src/Endpoint/Web/UsersController.php
#[DataGrid(grid: UserGrid::class, factory: InheritedFactory::class)]

Pipeline 拦截器

此拦截器允许使用 Pipeline 属性自定义端点拦截器。 当在域核心拦截器列表中声明时,此拦截器将指定注解的拦截器注入到声明 PipelineInterceptor 的位置。

php
app/src/Application/Bootloader/DomainBootloader.php
namespace App\Application\Bootloader;

use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;
use Spiral\DataGrid\Interceptor\GridInterceptor;
use Spiral\Domain;
use Spiral\Cycle\Interceptor\CycleInterceptor;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class => [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        CycleInterceptor::class,
        Domain\PipelineInterceptor::class, //all annotated interceptors go here
        Domain\GuardInterceptor::class,
        Domain\FilterInterceptor::class,
        GridInterceptor::class,
    ];
}

Pipeline 属性允许跳过后续拦截器:

php
#[Pipeline(pipeline: [OtherInterceptor::class], skipNext: true)]
public function action(): string
{
    //
}

使用先前的 bootloader,我们将获得下一个拦截器列表:

  • Spiral\Cycle\Interceptor\CycleInterceptor
  • OtherInterceptor

注意 PipelineInterceptor 之后的所有拦截器都将被省略。

用例

例如,当端点不应应用任何拦截器或并非所有拦截器当前都需要时,它可能很有用:

php
#[Route(route: '/show/<user:int>/email/<email:int>', name: 'emails')]
#[Pipeline(pipeline: [CycleInterceptor::class, GuardInterceptor::class], skipNext: true)]
public function email(User $user, Email $email, EmailFilter $filter): string
{
    $filter->setContext(compact('user', 'email'));
    if (!$filter->isValid()) {
        throw new ForbiddenException('Email doesn\'t belong to a user.');
    }
    //...
}

注意 由于上下文复杂,因此不应在此处应用 FilterInterceptor,因此我们手动设置它并调用自定义的 isValid() 检查。 此外,GridInterceptor 在此处是多余的。

要完全控制拦截器列表,你需要将 PipelineInterceptor 指定为第一个。

整合

一起使用所有拦截器来实现丰富的域逻辑和安全的控制器操作:

php
app/src/Application/Bootloader/DomainBootloader.php
namespace App\Application\Bootloader;

use Spiral\Bootloader\DomainBootloader;
use Spiral\Core\CoreInterface;
use Spiral\DataGrid\Interceptor\GridInterceptor;
use Spiral\Domain;
use Spiral\Cycle\Interceptor\CycleInterceptor;

class AppBootloader extends DomainBootloader
{
    protected const SINGLETONS = [
        CoreInterface::class => [self::class, 'domainCore']
    ];

    protected const INTERCEPTORS = [
        CycleInterceptor::class,
        Domain\GuardInterceptor::class,
        Domain\FilterInterceptor::class,
        GridInterceptor::class,
    ];
}

特定路由的拦截器

要为特定路由激活拦截器管道,你可以使用 Spiral\Interceptors\PipelineBuilder 类构建管道,并使用 withHandler() 方法将其注册到路由中。

php
// 为特定路由创建管道
$pipeline = (new PipelineBuilder())
    ->withInterceptors(new CustomInterceptor())
    ->build(new CallableHandler());

// 使用自定义管道注册路由
$router->setRoute(
    'home',
    new Route(
        '/home/<action>',
        (new Controller(HomeController::class))->withHandler($pipeline)
    )
);

参数类型转换

整型值转换

如果你想在控制器中使用类型化的路由参数注入,例如 function user(int $id),你需要创建一个拦截器来处理类型转换:

php
namespace App\Interceptor;

use Spiral\Interceptors\Context\CallContextInterface;
use Spiral\Interceptors\HandlerInterface;
use Spiral\Interceptors\InterceptorInterface;

/**
 * 将所有数字字符串参数转换为整数。
 */
class ParameterTypeCastInterceptor implements InterceptorInterface
{
    public function intercept(CallContextInterface $context, HandlerInterface $handler): mixed
    {
        $arguments = $context->getArguments();

        foreach ($arguments as $key => $value) {
            if (\is_string($value) && \ctype_digit($value)) {
                $arguments[$key] = (int) $value;
            }
        }

        return $handler->handle($context->withArguments($arguments));
    }
}

值对象转换

对于处理复杂类型转换,例如将字符串 UUID 转换为 UUID 对象:

php
namespace App\Interceptor;

use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Spiral\Interceptors\Context\CallContextInterface;
use Spiral\Interceptors\HandlerInterface;
use Spiral\Interceptors\InterceptorInterface;

final class UuidParameterConverterInterceptor implements InterceptorInterface
{
    public function intercept(CallContextInterface $context, HandlerInterface $handler): mixed
    {
        $arguments = $context->getArguments();
        $target = $context->getTarget();
        $reflection = $target->getReflection();

        if ($reflection === null) {
            // 如果反射不可用,直接调用处理程序
            return $handler->handle($context);
        }

        // 遍历所有操作参数
        foreach ($reflection->getParameters() as $parameter) {
            $paramName = $parameter->getName();
            $paramType = $parameter->getType();

            // 如果参数存在于参数列表中并且具有 UuidInterface 类型提示
            if (isset($arguments[$paramName]) &&
                $paramType !== null &&
                $paramType->getName() === UuidInterface::class
            ) {
                try {
                    // 用 Uuid 实例替换参数值
                    $arguments[$paramName] = Uuid::fromString($arguments[$paramName]);
                } catch (\Throwable $e) {
                    // 如果需要,处理无效的 UUID 格式
                }
            }
        }

        return $handler->handle($context->withArguments($arguments));
    }
}