PHP-микрофреймворк своими руками. Основы
17-01-2022Reading time ~ 10 min.PHP 342
Тема микрофреймворков достаточно актуальна, особенно на фоне сильно «разжиревших» полнофункциональных фреймворков. У каждого разработчика наверняка стояли задачи сделать быстрый и небольшой сайт, но ставить полноценную CMS или десятки мегабайт от популярного php-фреймворка, как-то душа не лежит. Хочется какой-то простой каркас на который можно было бы быстро «налепить» сво функционал.
Кажется, что таких «велосипедов» очень много. И будете правы, поскольку на PHP можно очень быстро написать любой web-каркас и поделиться с ним на Гитхабе. Но так было раньше, поскольку не было стандартов PSR, и все делали что хотели.
Когда появился PSR-7, то количество микрофреймворков резко сократилось и продолжает уменьшаться с выходом новых PSR. Если внимательно присмотреться, то в принципе останется только один фреймворк Slim 4, который поддерживает типовой набор PSR.
Что я подразумеваю под словом «микрофреймворк»?
В первую очередь — это самый минимум возможностей. Когда фреймворк предлагает работу с базой данных или view-шаблонизатором, то это уже более высокий уровень абстракции. Точно также микрофреймворк не ограничивает разработчика в том, где располагать файлы php-классов, и не декларирует приверженность MVC.
Микрофреймворк состоит из нескольких базовых модулей. Если говорить о современных тенденциях, то такой фреймворк будет состоять из:
- DI-контейнер зависимостей PSR-11.
- Библиотека PSR-7/PSR-17/PSR-18, чтобы через неё работать с HTTP.
- Маршрутизатор/роутер для разных адресов.
- Посредники Middleware PSR-15 для создания цепочки http-ответов и обработки http-запросов.
- Автозагрузка PSR-4 через Composer.
То есть по сути микрофреймворк будет отвечать только за приём входящих http-запросов и определение для этого запроса действия (Action). Такое ядро будет присутствовать в любом современном фреймворке. Раньше было проще — не было PSR и каждый «колхозил» как хотел. Сейчас такие вещи стараются унифицировать.
Как работает микрофреймворк?
Если вы уже немного поработали со Slim 4, как я описал в предыдущей статье, то вам будет проще понять основные концепции.
У нас есть некий входящий http-запрос. Нужно его узнать и PHP для этого предлагает суперглобальные $_GET
, $_POST
, $_SERVER
, $_FILES
, $_COOKIE
и даже php://input
Анализируя эти переменные мы можем выяснить текущий адрес, сегменты, get-параметры, метод запроса и т.д.
Получив данные, мы сравним их с маршрутами роутинга. Задача роутинга найти соответствие определённому адресу какому-то действию. Обычно такое действие — это функция или класс с заданным методом. Таким образом роутинг вернёт информацию с именем функции (handler).
После этого можно было бы сразу запустить эту функцию, но иногда стоят задачи, которые нужно выполнить перед основным действием. Например это может быть базовая HTTPAuthentication, логирование, кэширование запросов, получение информации о клиенте (IP, язык, geo...), отладка, различные защитные действия, работа с сессиями и другие. Такие вещи выполняются с помощью посредников Middleware.
Именно они срабатывают перед основным handler, получая на входе текущий запрос и возвращая какой-то ответ. Middleware — это средний слой приложения, которые работают последовательно. Раньше к ним применялась другие термины, вроде фильтров. Также часто можно встретить их описание как pipeline (трубопровод) и pipe (трубка), что является хорошей абстракцией показывающей их суть.
И вот как только http-запрос прошёл через Middleware, выполняется функция роутинга. Эта функция в свою очередь передаёт управление уже в наш php-класс (или функцию, не суть), где мы формируем тело страницы для вывода в браузер.
Бывают ситуации, когда входящий url не найден в роутинге. В этом случае чаще всего создаётся php-исключение, которое срабатывает в определенном Middleware, который сможет его обработать и что-то сделать. Например вывести 404-страницу.
Почему PSR-7
Этот стандарт был принят только в 2015 года — совсем недавно с точки зрения развития PHP. Главная его задача — это предоставление работы с HTTP в виде объектов. То есть вместо работы с суперглобальными массивами, мы получаем некую оболочку, с помощью которой можно получить информацию в удобном виде.Из-за того, что PSR-7 лишь интерфейс, то все PSR-7-библиотеки совместимы между собой. Хотя это не означает, что они одинаковы в использовании. Но суть — получение информации из http-запроса у них стандартизирована.
Такой запрос (request) с точки зрения PSR-7 представляет собой объект с типом RequestInterface, либо ServerRequestInterface (который формируется из суперглобальных переменных).
Если же нам нужно сформировать http-ответ (response), то он должен соответствовать интерфейсу ResponseInterface.
Такая унификация идёт только на пользу php-разработчикам, но главное, что дал PSR-7, так это другой способ работы приложения.
Сам по себе PHP очень сильно упрощает жизнь программистам. Уже много лет мы привыкли строить страницы примерно по такой схеме:
require 'start.php'; // секция HEAD echo 'Привет!'; // основной текст require 'end.php'; // закрыть html
При этом, если у нас сайт состоит из множество файлов, модулей, виджетов, компонентов, то каждый из них вносит свою порцию «echo» — на уровне «ядра» определить это невозможно.
Теперь, если нам нужно задать http-заголовок или установить куки, сформировать сессии, то мы это можем сделать только до первого echo
. По правилам, http-body (а это и есть вывод в echo
) должен выводиться только после http-headers.
Если у нас какой-то модуль, который должен отправить header()
, подключается после модуля, который выводит текст, то возникнет ошибка. Тоже самое будет, если попытаться работать с куками или сессиями. Они часть http-заголовка, поэтому должны быть отправлены перед основным выводом.
Проконтролировать это очень сложно, поэтому подавляющее большинство проектов используют буферизацию вывода:
ob_start(); // вкл буферизацию вывода require 'start.php'; // секция HEAD echo 'Привет!'; // основной текст require 'end.php'; // закрыть html header(...); // можно отправить любой http-заголовок когда угодно ob_end_flush(); // вывели всё из буфера
С PSR-7 приложение уже строится по принципу запрос/ответ. При этом мы не используем echo
, а формируем объект Response — то есть добавляем в него http-заголовки и тело body. Примерно так:
... return $response->getBody()->write('my html'); ...
После того, как объект ResponseInterface пройдёт всю цепочку вызовов, нужно будет его вывести.
echo $response->getBody();
Но на практике вывод более сложен, поскольку могут быть http-headers, поэтому используют т.н. эммиторы, что-то вроде такого:
(new Laminas\HttpHandlerRunner\Emitter\SapiEmitter())->emit($response);
То есть оптимальным будет вывод в браузер из одной точки приложения. При этом каждый его модуль оперирует ResponseInterface и RequestInterface. Когда нужно что-то вывести, то делается это путём добавления в тело ответа.
Middleware PSR-15
Middleware теперь имеют особое значение, поскольку через них проходят запросы и ответы. Образуется цепочка вызовов, которая последовательно запускает каждый Middleware. Тот, в свою очередь может оперировать http-ответом.
Этот стандарт проще рассматривать прямо по его интерфейсам.
interface MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; } interface RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface; }
То есть любой Middleware будет типа MiddlewareInterface, где должен быть реализован метод process()
.
Этот метод принимает два параметра: входящий http-запрос ServerRequestInterface, а второй представляет собой «обработчик», который возвращает http-ответ ResponseInterface. Таким образом Middleware будет иметь доступ к текущему request и «следующему» response (через handler). Часто handler именуют как $next
, чтобы показать прохождение запроса в цепочке.
Для работы с Middleware нужен т.н. диспетчер, который и будет запускать их по очереди.
Сейчас я не даю примеров кода или ссылки на существующие библиотеки. Об этом пойдёт речь в следующей статье.
Роутинг
С роутингом всё достаточно просто, поскольку большинство использует FastRoute. Главный его плюс в том, что он выполняет только поиск соответствия uri — handler.
Другие библиотеки обычно накладывают на роутинг ещё и функцию запуска найденного обработчика и это в свою очередь создаёт проблемы интеграции в Middleware.
То есть идея в том, что роутинг — это тоже Middleware. В итоге цепочка вызовов будет примерно такой:
$dispatcher = new Dispatcher([ new MyMiddleware(...) new RouterMiddleware(...) use ($routerHandler), // сюда передаём данные от роутинга new NotFoundMiddleware(...), ]);
Именно по такой схеме и работает Slim 4. Его основной объект App содержит в себе FastRoute и диспетчер Middleware. Внутренний обработчик прячет от нас их фактическое разделение.
Здесь есть один минус — обработка 404-страницы. Если бы мы использовали роутер как есть, без цепочки Middleware, то спокойно смогли бы получить код ответа FastRoute\Dispatcher::NOT_FOUND
и подключить произвольный класс, который бы и вывел шаблон 404-страницы.
Но, поскольку роутинг часть Middleware, то нужно оперировать запрос/ответ, который всё равно продвигается по цепочке.
В итоге остаётся только отслеживать исключение, которое появится если метода роутинга не будет найдено. И уже только после этого можно будет перехватывать это исключение.
Я это показывал в статье про Slim 4.
Поэтому перед созданием приложения важно сразу определиться будет ли роутинг частью Middleware, или всё-таки путь работает уже после него. По своей сути Middleware должны работать не зависимо от роутинга и до него, поскольку анализируют входящий запрос для всего приложения. Но бывают задачи, когда Middleware должен работать только под определённый маршрут. И здесь логичным было бы просто передать результат роутинга в функцию Middleware, а там он пусть сам решает что с этим делать.
Такой вариант универсальный, но неудобный для использования. Поэтому роутинг оборачивают в объект, который и может цеплять к нему нужный Middleware (как это сделано в Slim 4).
Контейнер зависимостей PSR-11
Здесь тоже всё достаточно просто — используем PHP-DI. Основная его функция разрешение зависимостей. Это круто упрощает жизнь, когда нужно передать зависимость в конструктор какого-то класса. Например, если Middleware потребует контейнер, чтобы получить другой класс (например лог), то мы просто указываем это в параметрах конструктора.
... private $container; private $psr17Factory; public function __construct(Container $container) { $this->container = $container; $this->psr17Factory = $container->get(Psr17Factory::class); } ...
Другое назначение — это разрешение зависимостей в диспетчере Middleware. По умолчанию диспетчер принимает исполняемую функцию или объект MiddlewareInterface. Такой вариант потребует инстанцировать каждый объект для диспетчера. Но, если в диспетчер мы передадим DI-контейнер, то диспетчер сам к нему обратится и найдет класс по его имени. Это позволяет задавать Middleware в таком виде:
$dispatcher = new Dispatcher( [ App\Middleware\AfterMiddleware::class, App\Middleware\DemoMiddleware::class, App\Middleware\BeforeMiddleware::class, ...
Ну и сам по себе контейнер PHP-DI позволяет хранить не только объекты, но и простые типы. Поэтому его можно использовать как обычное хранилище конфигурации.
Шаблонизация
Обычно под шаблонизацией понимают рендеринг view-шаблонов. Сам по себе микрофреймворк не должен указывать как это делать, но изначально подразумевается, что рендеринг вьюшек происходит внутри класса обработчика запроса.
Простой пример — вывод формы.
Есть класс Form, который выводит html-код формы с помощью html-шаблона. То есть в http-ответ попадёт уже готовый и полностью сформированный html-код формы.
Нюанс здесь в том, что выводить форму просто так нельзя — она должна быть частью полноценного HTML-кода страницы. Именно поэтому в html-шаблон подключают секции HEAD и прочие элементы полноценной html-страницы. По другому это просто не работает, поскольку мы жестко привязываем адрес к контролёру MVC (или Action в ADR).
Но на практике может получиться так, что класс формы нужно использовать в каком-то другом месте страницы (в подвале или сайдбаре). Ведь по сути — это всего-лишь html-код. Если view-шаблон (или его контролёр) будет подключать всю html-страницу, то повторное использование кода будет невозможным.
Таким образом многое будет зависеть от того, как именно вы хотите делать шаблонизацию. Если каждый action будет возвращать полный html-код страницы, то вы можете использовать роутинг как Middleware. Но если action будет возвращать строго только свой функционал (код формы), то после получения итогового response недостаточно будет его сразу вывести в браузер — нужно будет ещё подключить в html-каркас страницы.
Теоретически можно попробовать всё сделать на «роутинг как Middleware», но возникнет проблема, что часть обработчиков должна будет сработать до, а другая после всех. Но со временем, когда вы станете развивать свой проект в сторону CMS, то возникнет проблема в том, что часть модулей должна получить данные до всех обработчиков, а вывод сделать в каком-то другом произвольном месте. Если бы каждый Action был изолированный, то проблему можно было бы решить, но на практике скорее всего будет взаимодействие между разными классами. И это нормально, поскольку упрощает код.
Поэтому если нужна будет шаблонизация на уровне всего приложения, то лучше Middleware выделить отдельным слоем. После этого уже выполнить handler роутинга, и после этого обернуть вывод в итоговый html-шаблон страницы.
И с другой стороны, если у вас сайт для примитивного html-кода или вообще для API, то будет проще всё считать Middleware, включая и роутинг.
В следующей части мы рассмотрим практические примеры по реализации своего микрофреймворка.