Немного истории
Наверно, каждый начинающий веб-разработчик пытался сделать свой фреймворк, услышав про модель MVC, и каждый поступал одинаково: создавал полностью статичную Модель, где прятал соединение с базой; простой View, который минимально работал с буфером и максимально работал с require/include
шаблонов, ну и фронт-контроллер, в котором в лучшем случае инициализировался какой-то роутинг и создавался контроллер, а в худшем – все было построено на switch..case
или if..else
. И вроде бы все хорошо, каждый занят своим делом, вы уже не пишете php код в html, из ваших урлов пропало окончание .php
, но избавились ли вы от всех проблем расширения и написания кода и построили ли тот самый MVC? Конечно нет. Сейчас ваши контроллеры и модели полностью пустые, в них нельзя передать зависимости, а если вы и передадите их, то не сможете решить проблему автоматической подгрузки и чтения аргументов, вам придется передавать одни и те же зависимости во все контроллеры, даже тем, кто их не ждет. Чтобы вы понимали, о чем я, вот самый популярный пример создания контроллера:
Во-первых, все ваши контроллеры обязаны содержать постфикс Controller, что немного неудобно. А во-вторых, ваши контроллеры не принимают зависимости и не могут работать с ними. А что, если вам понадобится какой-то сервис, работающий с API, а тот, в свою очередь, принимает http-клиент Guzzle? Не проблема, делаем так:
И вам или придется всем контроллерам передавать зависимость от этого сервиса, или разруливать if'ами
, какому контроллеру и что передать. Не очень MVC-шно, не находите? Хотя тут дело даже не в MVC, а в простом удобстве. Если подумать, у самописных фреймворков сравнительно мало преимуществ перед остальными велосипедами. Решение проблемы довольно простое: научиться на примере.
Request/Response
И если в MVC разбираются не все, то урок о том, что "Глобальные переменные – зло", легко усваивает любой новичок и перестает писать global $db
. И все бы хорошо, но $_POST
, $_GET
, $_FILES
, $_REQUEST
– это же тоже глобальные массивы, содержащие данные текущего запроса, и вот их каждый новичок продолжает писать везде, где они ему нужны. В Symfony вы такого не увидите, в точке создания приложения (index.php) фреймворк инициализирует собственный класс Request, куда передает эти самые глобальные массивы:
Так в чем разница, если Symfony тоже используют глобальные массивы? Только в ОО-стиле? Не только. Symfony используют этот Request везде в своем коде, не обращаясь к глобальным переменным, что не даст возможности разработчику перебить какие-либо значения, которые Symfony получила в точке инициализации, потому что фреймворк получил значения раньше, чем дело дойдет до исполнения вашего кода. Таким образом, вы можете и дальше продолжать пользоваться глобальными массивами, а можете инжектить в свои экшены класс Request, что намного удобнее и безопаснее:
Также в Symfony есть класс Response, который, как вы могли догадаться, занимается отправкой ответа пользователю, отправкой заголовков и установкой кук.
Строгость фреймворка обязывает вас из всех контроллеров возвращать инстанс класса Response. Это может быть не простой Response, а JsonResponse, если вы пишете апи, RedirectResponse, если вам нужно сделать редирект, BinaryFileResponse, если нужно вернуть файл, и многое другое.
А что посередине?
На самом деле, Request/Response – это простые классы, которые могут работать и без Symfony, в них самих ничего необычного нет. Интересно то, что находится между точками запрос и ответ.
В первую очередь, мы создаем экземпляр Kernel'а, передавая туда переменные окружения (в каком окружении находимся – dev или prod – и нужен ли нам дебаг), и вызываем метод handle, куда отправляем текущий Request. Метод handle загружает (boot) бандлы (о них чуть ниже) и инициализирует контейнер (о котором тоже чуть ниже):
Когда контейнер готов, 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:
Уже даже на этом этапе Symfony может вернуть Response. Теперь самое интересное для любителей MVC: знаете, какие слушатели подписаны на данное событие? Все перечислять не стану, но среди них есть слушатель RouterListener, который как раз и матчит запрос на существующие роуты. Однако этот слушатель не напрямую возвращает результат, а помещает его в request->attributes
, откуда его и достает ControllerResolver
, который вступает в работу аккурат после фрагмента кода, представленного выше.
Контроллер может быть массивом (контроллер:метод), callable, объектом и даже функцией. На данном этапе определяется его тип и создается инстанс, который возвращает ControllerResolver
.
Дальше бросается событие KernelEvents::CONTROLLER
:
На это событие подписаны некоторые слушатели, важные из которых – это ControllerListener
и ParamConverterListener
, работающие с аннотациями над классом и методом контроллера и инициализирующие различные парам-конвертеры, например, DoctrineParamConverter
, который достает для вас сущность из базы, если она требуется контроллеру. Проще говоря, парам-конвертеры превращают аргументы запроса в объекты. Подробнее можно прочитать в документации. На данном этапе вы можете подписаться на событие и даже заменить контроллер, который дальше будет обрабатывать запрос.
Теперь настало время достать аргументы контроллера. Для этого в Symfony есть класс ArgumentResolver
и интерфейс ArgumentValueResolverInterface
, реализовав который, вы можете написать свой резолвер для аргументов: например, инжектить в метод не Request
, а какой-нибудь DTO, и Symfony, вызвав ваш резолвер, сможет определить аргумент и передать его вызываемому контроллеру:
Сам ArgumentResolver выглядит так:
Здесь перебираются все зарегистрированные резолверы, собираются аргументы и возвращаются обратно. Вот для примера два важных резолвера — RequestValueResolver
и ServiceValueResolver
:
Последний резолвер (ServiceValueResolver
) как раз и занимается тем, что достает аргументы контроллера из контейнера, если их не получилось достать другими резолверами.
На данном этапе бросается событие Kernel_Events::CONTROLLER_ARGUMENTS
, которое позволяет вам подписаться на него и заменить аргументы, передаваемые в контроллер:
Дальше собирается и вызывается контроллер:
Контроллер отработал, и теперь нужно вернуть Response. Если же из вашего контроллера не вернулся Response, Symfony кидает событие KernelEvents::VIEW
, на которое подписан TemplateListener
. Этот слушатель отрабатывает только в том случае, если вы используете аннотацию Template, подробнее можно прочитать в документации. Если же и тогда не будет Response, фреймворк выкинет знакомую многим ошибку: The controller must return a "Symfony\Component\HttpFoundation\Response".
Кроме этого, Symfony бросает оставшиеся 4 события:
- KernelEvents::EXCEPTION,
- KernelEvents::RESPONSE,
- KernelEvents::FINISH_REQUEST,
- KernelEvents::TERMINATE.
Вы можете подписаться на любое из этих событий и по-прежнему повлиять на работу фреймворка. Например, вы можете подписаться на событие EXCEPTION, словить AccessDeniedException, который вы выкинете в любом из контроллеров, и отрендерить шаблон с ошибкой 403:
Или же вы можете переложить всю работу на аннотации, закрыть ею все защищенные маршруты и выкидывать исключение из слушателя, который будет смотреть, определена ли конкретная аннотация над ней или нет. Будет выглядеть это следующим образом:
Сервисы
Несмотря на то, что Symfony может разрешить практически любую зависимость, есть случаи, когда это невозможно. Например, вы пишете клиент по работе с API какого-либо сервиса, требующий авторизации по токену. Не будете же вы жестко прописывать такой токен в файле класса (или будете?). Любые ключи необходимо хранить в переменных окружения. Когда вы находитесь в дев-режиме, Symfony берет переменные из .env файла, на проде – из $_ENV массива. Именно из переменных окружения Symfony берет настройки для базы, дебаг-режима, почтовика и многих других сервисов. Там же вы должны определить и свой токен:
После этого идем в файл config/services.yaml и начинаем описывать наш сервис. По умолчанию этот файл выглядит следующим образом:
Какая настройка и для чего нужна, я описывать не буду, комментарии напротив каждой из них достаточно подробные.
Предположим, вы написали клиент, который в конструктор принимает токен и GuzzleHttp для запросов к API. Для описания такого сервиса нам нужно определить имя сервиса (в новых версиях фреймворка имена соответствуют FQCN класса), его аргументы и вызываемые методы, если они есть:
В секции arguments список аргументов может быть как упорядоченным, так и параметризированным. Если вы не хотите зависеть от порядка передаваемых аргументов, вы можете их явно привязать к именам аргументов ваших классов. Например:
Давайте усложним задачу. Теперь у вас JWT авторизация, и перед совершением запросов к API необходимо получить access_token, и так постоянно. Однако вы не хотите зависеть от этого фактора в своем коде и не хотите рисковать забыть сделать запрос на получение токена перед запросом. Symfony может вам помочь. Для этого вам необходимо в секции calls перечислить методы, какие нужно вызвать перед тем, как фреймворк отдаст вам сервис:
Теперь при запросе своего клиента вы получите уже полностью сконфигурированный класс.
Вместо завершения
Как видите, Symfony достаточно удобный и легко расширяемый фреймворк. Кроме того, он состоит из компонентов, которые вы можете использовать отдельно от него. Например, самыми популярными из них являются компонент для создания консольных команд, роутинг, dependency injection и некоторые другие.
Если кому-либо понравилось читать про устройство Symfony, дополнительно могу написать про интересные приемы работы с фреймворком и личные (и немного общие) best practice.
Комментарии