lolychanks 03 ноября 2019

Устройство фреймворка Symfony: от запроса до ответа

Рассматриваем устройство фреймворка Symfony – одного из самых популярных и сложных PHP фреймворков.
Устройство фреймворка Symfony: от запроса до ответа

Немного истории

Наверно, каждый начинающий веб-разработчик пытался сделать свой фреймворк, услышав про модель MVC, и каждый поступал одинаково: создавал полностью статичную Модель, где прятал соединение с базой; простой View, который минимально работал с буфером и максимально работал с require/include шаблонов, ну и фронт-контроллер, в котором в лучшем случае инициализировался какой-то роутинг и создавался контроллер, а в худшем – все было построено на switch..case или if..else. И вроде бы все хорошо, каждый занят своим делом, вы уже не пишете php код в html, из ваших урлов пропало окончание .php, но избавились ли вы от всех проблем расширения и написания кода и построили ли тот самый MVC? Конечно нет. Сейчас ваши контроллеры и модели полностью пустые, в них нельзя передать зависимости, а если вы и передадите их, то не сможете решить проблему автоматической подгрузки и чтения аргументов, вам придется передавать одни и те же зависимости во все контроллеры, даже тем, кто их не ждет. Чтобы вы понимали, о чем я, вот самый популярный пример создания контроллера:

<?php
...
foreach($this->routes as $route){
if($route['uri'] === $uri){
$controllerName = $route['controller'];
$actionName = $route['action'];
$controllerFileName = $controllerName . 'Controller.php';
include ROOT . '/controllers/'.$controllerFileName;
$controllerObject = new $controllerName;
return $controllerObject->$actionName();
}
}
...
view raw index.php hosted with ❤ by GitHub

Во-первых, все ваши контроллеры обязаны содержать постфикс Controller, что немного неудобно. А во-вторых, ваши контроллеры не принимают зависимости и не могут работать с ними. А что, если вам понадобится какой-то сервис, работающий с API, а тот, в свою очередь, принимает http-клиент Guzzle? Не проблема, делаем так:

index.php
        <?php

...

$controllerObject = new $controllerName(new ClientService(new \GuzzleHttp\Client));
    

И вам или придется всем контроллерам передавать зависимость от этого сервиса, или разруливать if'ами, какому контроллеру и что передать. Не очень MVC-шно, не находите? Хотя тут дело даже не в MVC, а в простом удобстве. Если подумать, у самописных фреймворков сравнительно мало преимуществ перед остальными велосипедами. Решение проблемы довольно простое: научиться на примере.

Request/Response

И если в MVC разбираются не все, то урок о том, что "Глобальные переменные – зло", легко усваивает любой новичок и перестает писать global $db. И все бы хорошо, но $_POST, $_GET, $_FILES, $_REQUEST – это же тоже глобальные массивы, содержащие данные текущего запроса, и вот их каждый новичок продолжает писать везде, где они ему нужны. В Symfony вы такого не увидите, в точке создания приложения (index.php) фреймворк инициализирует собственный класс Request, куда передает эти самые глобальные массивы:

index.php
index.php
Request.php
Request.php

Так в чем разница, если Symfony тоже используют глобальные массивы? Только в ОО-стиле? Не только. Symfony используют этот Request везде в своем коде, не обращаясь к глобальным переменным, что не даст возможности разработчику перебить какие-либо значения, которые Symfony получила в точке инициализации, потому что фреймворк получил значения раньше, чем дело дойдет до исполнения вашего кода. Таким образом, вы можете и дальше продолжать пользоваться глобальными массивами, а можете инжектить в свои экшены класс Request, что намного удобнее и безопаснее:

SomethingController.php
        public function something(Request $request)
{
    $request->request->get('key');
    $request->query->get('key');
    $request->files->get('key');
    $request->headers->get('key')
}
    

Также в Symfony есть класс Response, который, как вы могли догадаться, занимается отправкой ответа пользователю, отправкой заголовков и установкой кук.

Response.php
Response.php

Строгость фреймворка обязывает вас из всех контроллеров возвращать инстанс класса Response. Это может быть не простой Response, а JsonResponse, если вы пишете апи, RedirectResponse, если вам нужно сделать редирект, BinaryFileResponse, если нужно вернуть файл, и многое другое.

А что посередине?

На самом деле, Request/Response – это простые классы, которые могут работать и без Symfony, в них самих ничего необычного нет. Интересно то, что находится между точками запрос и ответ.

В первую очередь, мы создаем экземпляр Kernel'а, передавая туда переменные окружения (в каком окружении находимся – dev или prod – и нужен ли нам дебаг), и вызываем метод handle, куда отправляем текущий Request. Метод handle загружает (boot) бандлы (о них чуть ниже) и инициализирует контейнер (о котором тоже чуть ниже):

Kernel.php
Kernel.php

Когда контейнер готов, Symfony достает из него HttpKernel и вызывает у него метод handle, куда передает текущий запрос, тип (MASTER_REQUEST соответствует основному запросу, который пришел от пользователя) и нужно ли ловить ошибку или нет: если нет, Symfony просто выплюнет ошибку вам на экран, если да, то сработают слушатели, подписавшиеся на событие kernel.exception (о слушателях так же ниже), и ответ вернется уже из одного из них.

Контейнер

Теперь настало время поговорить о том, что же такое контейнер и зачем он нужен. Если коротко, то контейнер – это объект, который знает, как создать конкретный класс и как настроить его зависимости. В случае простого MVC, который писал каждый разработчик, вам приходилось или руками инжектить все эти зависимости, или не использовать вовсе, отдавая предпочтение неуправляемым и жестким статическим методам класса. Контейнер же справляется с этой задачей, располагая в результате компиляции всей информацией о всех необходимых сервисах, которые когда-либо может запросить ваше приложение. Таким образом, когда Symfony уже знает, какой контроллер вам нужен, и если этот контроллер требует зависимости, фреймворк использует контейнер для автовайринга (автозагрузки) некоторых сервисов (например, если вы используете Connection, EntityManager или что-то еще). Если вы хотите подробнее почитать про контейнер и внедрение зависимостей, вы можете прочитать статью Мартина Фаулера о принципах внедрения зависимостей, о типах внедрения (через конструктор или сеттер), о контейнерах и многом другом.

Бандлы

Как написано в документации к фреймворку, бандлы – это что-то очень похожее на плагины. У бандлов достаточно широкая конфигурация, их можно включить в любом окружении или отключать вовсе, бандлы могут повлиять на компиляцию контейнера, а также добавляют больше информации о внедряемых сервисах. Объяснять, как писать бандлы, я не буду, эта тема для отдельной статьи.

Посредники

Вероятно, многие слышали про посредники (или middleware): это классы-фильтры, обрабатывающие http-запрос до того, как он попадет в контроллер. Посредники могут не пропустить запрос, если не прошла валидация или аутентификация, и тогда именно посредники возвращают ответ, а не контроллер. Если же все хорошо, один посредник передает запрос другому посреднику, и так по цепочке, пока запрос не попадет в контроллер. А теперь забудьте, что я написал, потому что в Symfony нет посредников, там в качестве оных могут выступать и выступают события. Существует как ряд встроенных событий, которыми обменивается фреймворк в процессе обработки запроса, так и кастомные события, которые вы сделаете сами. Вот список встроенных событий, на которые подписаны слушатели фреймворка и на которые могут подписаться ваши слушатели, чтобы как-то повлиять на работу фреймворка в любой момент:

<?php
final class KernelEvents
{
/**
* The REQUEST event occurs at the very beginning of request
* dispatching.
*
* This event allows you to create a response for a request before any
* other code in the framework is executed.
*
* @Event("Symfony\Component\HttpKernel\Event\GetResponseEvent")
*/
const REQUEST = 'kernel.request';
/**
* The EXCEPTION event occurs when an uncaught exception appears.
*
* This event allows you to create a response for a thrown exception or
* to modify the thrown exception.
*
* @Event("Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent")
*/
const EXCEPTION = 'kernel.exception';
/**
* The VIEW event occurs when the return value of a controller
* is not a Response instance.
*
* This event allows you to create a response for the return value of the
* controller.
*
* @Event("Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent")
*/
const VIEW = 'kernel.view';
/**
* The CONTROLLER event occurs once a controller was found for
* handling a request.
*
* This event allows you to change the controller that will handle the
* request.
*
* @Event("Symfony\Component\HttpKernel\Event\FilterControllerEvent")
*/
const CONTROLLER = 'kernel.controller';
/**
* The CONTROLLER_ARGUMENTS event occurs once controller arguments have been resolved.
*
* This event allows you to change the arguments that will be passed to
* the controller.
*
* @Event("Symfony\Component\HttpKernel\Event\FilterControllerArgumentsEvent")
*/
const CONTROLLER_ARGUMENTS = 'kernel.controller_arguments';
/**
* The RESPONSE event occurs once a response was created for
* replying to a request.
*
* This event allows you to modify or replace the response that will be
* replied.
*
* @Event("Symfony\Component\HttpKernel\Event\FilterResponseEvent")
*/
const RESPONSE = 'kernel.response';
/**
* The TERMINATE event occurs once a response was sent.
*
* This event allows you to run expensive post-response jobs.
*
* @Event("Symfony\Component\HttpKernel\Event\PostResponseEvent")
*/
const TERMINATE = 'kernel.terminate';
/**
* The FINISH_REQUEST event occurs when a response was generated for a request.
*
* This event allows you to reset the global and environmental state of
* the application, when it was changed during the request.
*
* @Event("Symfony\Component\HttpKernel\Event\FinishRequestEvent")
*/
const FINISH_REQUEST = 'kernel.finish_request';
}

В аннотациях к константам имен событий указаны сами классы событий, которые может принять ваш слушатель, когда на них подпишется. Например, вот так:

<?php
...
class FinishedRequestListener implements EventSubscriberInterface
{
/**
* @param FilterResponseEvent $event
*
* @return void
*/
public function onKernelResponse(FilterResponseEvent $event): void
{
$event->getResponse()->headers->set('Cache-Control', 'No-Cache');
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => 'onKernelResponse'
];
}
}

Вашему слушателю достаточно заимплементить интерфейс EventSubscriberInterface и в методе getSubscribedEvents вернуть массив вида событие => метод, который занимается его обработкой. Таким образом, в метод onKernelResponse Symfony передаст событие FilterResponseEvent, где уже будет сформированный и готовый к отправке объект респонса, который мы можем модифицировать, добавив, к примеру, заголовок Cache-Control. Это удобно, однако у всего есть своя плата: если хотя бы один слушатель словит ошибку, все остальные слушатели не отработают.

Таким образом, самое первое событие, которое кидает Symfony, – это событие KernelEvents::REQUEST:

HttpKernel.php
HttpKernel.php

Уже даже на этом этапе Symfony может вернуть Response. Теперь самое интересное для любителей MVC: знаете, какие слушатели подписаны на данное событие? Все перечислять не стану, но среди них есть слушатель RouterListener, который как раз и матчит запрос на существующие роуты. Однако этот слушатель не напрямую возвращает результат, а помещает его в request->attributes, откуда его и достает ControllerResolver, который вступает в работу аккурат после фрагмента кода, представленного выше.

HttpKernel.php
HttpKernel.php
ControllerResolver.php
ControllerResolver.php
ControllerResolver.php
ControllerResolver.php

Контроллер может быть массивом (контроллер:метод), callable, объектом и даже функцией. На данном этапе определяется его тип и создается инстанс, который возвращает ControllerResolver.

Дальше бросается событие KernelEvents::CONTROLLER:

Устройство фреймворка Symfony: от запроса до ответа

На это событие подписаны некоторые слушатели, важные из которых – это ControllerListener и ParamConverterListener, работающие с аннотациями над классом и методом контроллера и инициализирующие различные парам-конвертеры, например, DoctrineParamConverter, который достает для вас сущность из базы, если она требуется контроллеру. Проще говоря, парам-конвертеры превращают аргументы запроса в объекты. Подробнее можно прочитать в документации. На данном этапе вы можете подписаться на событие и даже заменить контроллер, который дальше будет обрабатывать запрос.

Теперь настало время достать аргументы контроллера. Для этого в Symfony есть класс ArgumentResolver и интерфейс ArgumentValueResolverInterface, реализовав который, вы можете написать свой резолвер для аргументов: например, инжектить в метод не Request, а какой-нибудь DTO, и Symfony, вызвав ваш резолвер, сможет определить аргумент и передать его вызываемому контроллеру:

HttpKernel.php
HttpKernel.php

Сам ArgumentResolver выглядит так:

ArgumentResolver.php
ArgumentResolver.php

Здесь перебираются все зарегистрированные резолверы, собираются аргументы и возвращаются обратно. Вот для примера два важных резолвера — RequestValueResolver и ServiceValueResolver:

RequestValueResolver.php
RequestValueResolver.php
ServiceValueResolver.php
ServiceValueResolver.php

Последний резолвер (ServiceValueResolver) как раз и занимается тем, что достает аргументы контроллера из контейнера, если их не получилось достать другими резолверами.

На данном этапе бросается событие Kernel_Events::CONTROLLER_ARGUMENTS, которое позволяет вам подписаться на него и заменить аргументы, передаваемые в контроллер:

        $event = new FilterControllerArgumentsEvent($this, $controller, $arguments, $request, $type);
$this->dispatcher->dispatch(KernelEvents::CONTROLLER_ARGUMENTS, $event);
    

Дальше собирается и вызывается контроллер:

HttpKernel.php
        $controller = $event->getController();
$arguments = $event->getArguments();

$response = $controller(...$arguments);
    

Контроллер отработал, и теперь нужно вернуть Response. Если же из вашего контроллера не вернулся Response, Symfony кидает событие KernelEvents::VIEW, на которое подписан TemplateListener. Этот слушатель отрабатывает только в том случае, если вы используете аннотацию Template, подробнее можно прочитать в документации. Если же и тогда не будет Response, фреймворк выкинет знакомую многим ошибку: The controller must return a "Symfony\Component\HttpFoundation\Response".

Кроме этого, Symfony бросает оставшиеся 4 события:

  1. KernelEvents::EXCEPTION,
  2. KernelEvents::RESPONSE,
  3. KernelEvents::FINISH_REQUEST,
  4. KernelEvents::TERMINATE.

Вы можете подписаться на любое из этих событий и по-прежнему повлиять на работу фреймворка. Например, вы можете подписаться на событие EXCEPTION, словить AccessDeniedException, который вы выкинете в любом из контроллеров, и отрендерить шаблон с ошибкой 403:

<?php
class AccessDeniedListener implements EventSubscriberInterface
{
/**
* @var Environment
*/
private $view;
public function __construct(Environment $view)
{
$this->view = $view;
}
public function onKernelException(GetResponseForExceptionEvent $event)
{
$exception = $event->getException();
if (!$exception instanceof AccessDeniedHttpException) {
return;
}
$errorTemplate = $this->view->render('errortemplate.html.twig', ['errorCode' => 403]);
$response = new Response($errorTemplate);
$event->setResponse($response);
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => 'onKernelException'
];
}
}

Или же вы можете переложить всю работу на аннотации, закрыть ею все защищенные маршруты и выкидывать исключение из слушателя, который будет смотреть, определена ли конкретная аннотация над ней или нет. Будет выглядеть это следующим образом:

<?php
...
use Doctrine\ORM\Mapping\Annotation;
/**
* @Annotation
*/
class Permissions
{
public $adminPermissions;
}
...
/**
* @Route("/admin/", name="admin_some")
* @Permissions(adminPermissions=true)
*/
public function me()
{
$user = $this->getUser();
return $this->render('template.html.twig', compact('user'));
}
class PermissionsListener implements EventSubscriberInterface
{
/**
* @var Reader
*/
private $reader;
/**
* @var TokenStorageInterface
*/
private $token;
public function __construct(Reader $reader, TokenStorageInterface $token)
{
$this->reader = $reader;
$this->token = $token;
}
/**
* @param FilterControllerEvent $event
* @throws \ReflectionException
*/
public function onKernelController(FilterControllerEvent $event): void
{
if (!is_array($controller = $event->getController())) {
return;
}
$object = new \ReflectionObject($controller[0]);
$method = $object->getMethod($controller[1]);
/** @var Permissions $annotation */
foreach ($this->reader->getMethodAnnotations($method) as $annotation) {
if (isset($annotation->adminPermissions) && $annotation->adminPermissions === true) {
/** @var User $user */
$user = $this->token->getToken()->getUser();
if (!$user instanceof UserInterface) {
throw new NotFoundHttpException();
}
if (!in_array($user->getRoles()[0], Role::premiumRoles())) {
throw new NotFoundHttpException();
}
}
}
}
public static function getSubscribedEvents()
{
return [
KernelEvents::CONTROLLER => 'onKernelController'
];
}
}

Сервисы

Несмотря на то, что Symfony может разрешить практически любую зависимость, есть случаи, когда это невозможно. Например, вы пишете клиент по работе с API какого-либо сервиса, требующий авторизации по токену. Не будете же вы жестко прописывать такой токен в файле класса (или будете?). Любые ключи необходимо хранить в переменных окружения. Когда вы находитесь в дев-режиме, Symfony берет переменные из .env файла, на проде – из $_ENV массива. Именно из переменных окружения Symfony берет настройки для базы, дебаг-режима, почтовика и многих других сервисов. Там же вы должны определить и свой токен:

.env
        API_TOKEN=asd4329852dasf598fs
    

После этого идем в файл config/services.yaml и начинаем описывать наш сервис. По умолчанию этот файл выглядит следующим образом:

# config/services.yaml
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
public: false # Allows optimizing the container by removing unused services; this also means
# fetching services directly from the container via $container->get() won't work.
# The best practice is to be explicit about your dependencies anyway.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
# ...
view raw services.yaml hosted with ❤ by GitHub

Какая настройка и для чего нужна, я описывать не буду, комментарии напротив каждой из них достаточно подробные.

Предположим, вы написали клиент, который в конструктор принимает токен и GuzzleHttp для запросов к API. Для описания такого сервиса нам нужно определить имя сервиса (в новых версиях фреймворка имена соответствуют FQCN класса), его аргументы и вызываемые методы, если они есть:

parameters:
locale: 'en'
api_token: %env(API_TOKEN)%'
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
public: false # Allows optimizing the container by removing unused services; this also means
# fetching services directly from the container via $container->get() won't work.
# The best practice is to be explicit about your dependencies anyway.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
# ...
App\Service\Api\ApiClient:
arguments:
- '@GuzzleHttp\Client'
- '%api_token%'
view raw services.yaml hosted with ❤ by GitHub

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

services.yaml
        App\Service\Api\ApiClient:
        arguments:
            $client: '@GuzzleHttp\Client'
            $token: '%api_token%'
    

Давайте усложним задачу. Теперь у вас JWT авторизация, и перед совершением запросов к API необходимо получить access_token, и так постоянно. Однако вы не хотите зависеть от этого фактора в своем коде и не хотите рисковать забыть сделать запрос на получение токена перед запросом. Symfony может вам помочь. Для этого вам необходимо в секции calls перечислить методы, какие нужно вызвать перед тем, как фреймворк отдаст вам сервис:

services.yaml
        App\Service\Api\ApiClient:
    arguments:
        $client: '@GuzzleHttp\Client'
        $token: '%api_token%'
    calls:
       - method: auth
    

Теперь при запросе своего клиента вы получите уже полностью сконфигурированный класс.

Вместо завершения

Как видите, Symfony достаточно удобный и легко расширяемый фреймворк. Кроме того, он состоит из компонентов, которые вы можете использовать отдельно от него. Например, самыми популярными из них являются компонент для создания консольных команд, роутинг, dependency injection и некоторые другие.

Если кому-либо понравилось читать про устройство Symfony, дополнительно могу написать про интересные приемы работы с фреймворком и личные (и немного общие) best practice.

Комментарии

 
 

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

Подпишись

на push-уведомления