lolychanks 03 ноября 2019

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

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

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

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

Во-первых, все ваши контроллеры обязаны содержать постфикс 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 нет посредников, там в качестве оных могут выступать и выступают события. Существует как ряд встроенных событий, которыми обменивается фреймворк в процессе обработки запроса, так и кастомные события, которые вы сделаете сами. Вот список встроенных событий, на которые подписаны слушатели фреймворка и на которые могут подписаться ваши слушатели, чтобы как-то повлиять на работу фреймворка в любой момент:

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

Вашему слушателю достаточно заимплементить интерфейс 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:

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

Сервисы

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

.env
        API_TOKEN=asd4329852dasf598fs
    

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

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

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

В секции 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.

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию

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