Компонент HttpKernel: Разрешитель контроллера

Дата обновления перевода 2023-09-01

Компонент HttpKernel: Разрешитель контроллера

Вы можете подумать, что наш фреймворк уже достаточно целостный и вы скорее всего правы. Но давайте всё равно посмотрим, как мы можем его улучшить.

Сейчас, все наши примеры используют процедурный код, но помните, что контроллеры могут быть любыми валидными обратными вызовами PHP. Давайте конвертируем наш контроллер в правильный класс:

1
2
3
4
5
6
7
8
9
10
11
class LeapYearController
{
    public function index($request): Response
    {
        if (is_leap_year($request->attributes->get('year'))) {
            return new Response('Yep, this is a leap year!');
        }

        return new Response('Nope, this is not a leap year.');
    }
}

Соответственно обновите определение маршрута:

1
2
3
4
$routes->add('leap_year', new Routing\Route('/is_leap_year/{year}', array(
    'year' => null,
    '_controller' => array(new LeapYearController(), 'indexAction'),
)));

Перемещение достаточно прямолинейно и имеет ясный смысл, как только вы создадите болше страниц, но вы могли заметить нежелательный побочный эффект... Класс LeapYearController всегда инстанциируется, даже если запрошенный URL не соответствует маршруту leap_year. Это плохо по одной главной причине: с точки зрения производительности, все контроллеры для всех маршрутов должны теперь быть инстанциированы на каждый запрос. Было бы лучше, если бы контроллеры имели ленивую загрузку, чтобы инстанциировался только контроллер, связанный с совпавшим маршрутом.

Чтобы решить эту проблему, а также кучу других, давайте установим и используем компонент:

1
$ composer require symfony/http-kernel

Компоненет HttpKernel имеетмного интересный функций, но те, которые нам нужны прямо сейчас - это разрешитель контроллера и разрешитель аргумента. Разрешитель контроллера знает, как определить контроллер для выполнения, а разрешитель аргумента определяет, в какие аргументы его передать, основываясь на объекте Запроса. Все разрешители контроллера реализуют следующий интерфейс:

1
2
3
4
5
6
7
namespace Symfony\Component\HttpKernel\Controller;

// ...
interface ControllerResolverInterface
{
    public function getController(Request $request);
}

Метод getController() полагается на то же соглашение, как и тот, что мы определили ранее: атрибут запроса _controller должен содержать контроллер, связанный с Запросом. Кроме встроенных обратных вызовов PHP, getController() также поддерживает строки, состоящие из имени класса, за которым идёт два двоеточие и имя метода, в качестве валидного обратного вызова, вроде 'class::method':

1
2
3
4
$routes->add('leap_year', new Routing\Route('/is_leap_year/{year}', array(
    'year' => null,
    '_controller' => 'LeapYearController::indexAction',
)));

Чтобы этот код работал, измените код фреймворка, чтобы он использовал разрешитель контроллера из HttpKernel:

1
2
3
4
5
6
7
8
9
use Symfony\Component\HttpKernel;

$controllerResolver = new HttpKernel\Controller\ControllerResolver();
$argumentResolver = new HttpKernel\Controller\ArgumentResolver();

$controller = $controllerResolver->getController($request);
$arguments = $argumentResolver->getArguments($request, $controller);

$response = call_user_func_array($controller, $arguments);

Note

В качестве дополнительного бонуса, разрешитель должным образом справляется с управлением ошибок за вас: когда вы забываете определить атрибут _controller для Маршрута, например.

Теперь, давайте посмотрим, как угадываются аргументы контроллера. getArguments() вникает в подпись контроллера, чтобы определить, какие аргументы передать ему, используя родной для PHP reflection. Этот метод определён в следующем интерфейсе:

1
2
3
4
5
6
7
namespace Symfony\Component\HttpKernel\Controller;

// ...
interface ArgumentResolverInterface
{
    public function getArguments(Request $request, $controller);
}

Метод indexAction() требует объект Запроса в качестве аргумента. getArguments() знает, когда его правильно внедрить, если он корректно типизирован:

1
2
3
4
public function indexAction(Request $request)

// не сработает
public function indexAction($request)

Более того, getArguments() также в состоянии внедрить любой атрибут Запроса; аргументу просто требуется иметь то же имя, что и у соответствующего атрибута:

1
public function index(int $year)

Вы также можете внедрить Запрос и некоторые атрибуты одновременно (так как сопоставление проходит на уровне имениа аргумента или типизирования, порядок аргументов не имеет значения):

1
2
3
public function index(Request $request, int $year)

public function index(int $year, Request $request)

Наконец, вы также можете оределить значения по умолчанию для любого аргумента, совпадаюего с необязательным атрибутом Запроса:

1
public function index(int $year = 2012)

Давайте просто внедрим атрибут запроса $year для нашего контроллера:

1
2
3
4
5
6
7
8
9
10
11
class LeapYearController
{
    public function index(int $year): Response
    {
        if (is_leap_year($year)) {
            return new Response('Yep, this is a leap year!');
        }

        return new Response('Nope, this is not a leap year.');
    }
}

Разрешители также берут на себя заботы по валидации вызываемого контроллера и его аргументов. В случае проблемы, он выдаёт исключение с красивым сообщением, объясняющим проблему (класс контроллера не существует, метод не определён, аргумент не имеет совпадающего атрибута, ...).

Note

С огромной гибкостью разрешителя контроллера и разрешителя аргумента по умолчанию, вам может быть интересно, почему кто-то может хотеть создавать ещё один (почему будет интерфейс, если этого не сделать?). Два примера: в Symfony, getController() усиливается для поддержки контроллеров в качестве сервисов; а getArguments() предоставляет точку расширения для изменения или улучшения разрешения аргументов.

Давайте заключим с новой версией нашего фреймворка:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing;
use Symfony\Component\HttpKernel;

function render_template(Request $request): Response
{
    extract($request->attributes->all(), EXTR_SKIP);
    ob_start();
    include sprintf(__DIR__.'/../src/pages/%s.php', $_route);

    return new Response(ob_get_clean());
}

$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';

$context = new Routing\RequestContext();
$context->fromRequest($request);
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);

$controllerResolver = new HttpKernel\Controller\ControllerResolver();
$argumentResolver = new HttpKernel\Controller\ArgumentResolver();

try {
    $request->attributes->add($matcher->match($request->getPathInfo()));

    $controller = $controllerResolver->getController($request);
    $arguments = $argumentResolver->getArguments($request, $controller);

    $response = call_user_func_array($controller, $arguments);
} catch (Routing\Exception\ResourceNotFoundException $e) {
    $response = new Response('Not Found', 404);
} catch (Exception $e) {
    $response = new Response('An error occurred', 500);
}

$response->send();

Подумайте об этом ещё раз: наш фреймворк более крепкий и более гибкий, чем когда-либо, и он всё ещё имеет менее 50 строк кода.