Сайт вебмастера

PHP-микрофреймворк своими руками. Практика

18-01-2022Время чтения ~ 17 мин.PHP 148

Сегодня попробуем собрать микрофреймворк по описанным принципам. Будем делать так, чтобы минимизировать свой код. Поэтому, в отличие от полноценных микрофреймворков, у нас будет что-то ближе к начальному «каркасу». Я намеренно не хочу «улучшать» код и тем более раскидывать его по файлам, чтобы сохранить ясность в понимании того, как код будет работать.

В последующем вы можете сами его модернизировать так, чтобы вынести часть кода внутрь фреймворка.

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

Эти библиотеки я подобрал по признаку удобства. Существуют и другие, более мощные, но для моего случая этот комплект оказался наиболее удобным.

Начальный каркас микрофреймворка

Структура проекта большого значения не имеет, поэтому я будут показывать на том же примере, что и в статье про Slim 4.

app/
    Middleware/ ...
    Modules/ ...
    bootstrap.php

src/ ...

index.php
composer.json
.htaccess

Файл index.php фронт-контроллер, который перекидывает управление в app/bootstrap.php — именно в нём мы и будем размещать весь код.

<?php
// index.php

define('BASE_DIR', dirname(realpath(__FILE__)) . DIRECTORY_SEPARATOR);
define('APP_BASEPATH', '/' . basename(__DIR__) . '/');

require BASE_DIR . 'app/bootstrap.php';

# end of file

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

Файл .htaccess нужен для ЧПУ:

Options +SymLinksIfOwnerMatch
Options -Indexes
DirectoryIndex index.php
AddDefaultCharset UTF-8

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
</IfModule>

Файл composer.json:

{
    "require": {
        "php": ">=7.4.0",
        "php-di/php-di": "^6.3",
        "nikic/fast-route": "^1.3",
        "nyholm/psr7": "^1.4",
        "nyholm/psr7-server": "^1.0",
        "mindplay/middleman": "^4.0",
        "laminas/laminas-httphandlerrunner": "^2.1"
    },
    "autoload": {
        "psr-4": {
			"App\\": "app/",
			"Lib\\": "src/"
		}
    }
}

Обратите внимание на ключ autoload, где мы указываем автозагрузку PSR-4. Ну и мы требуем как минимум PHP 7.4.

Запускаем команду Композитора:

composer install

Он подтягивает все нужные файлы в каталог vendor.

Для начала убедимся, что всё работает. Я буду размещать сайт по адресу http://demo/micro1, но у вас он может быть другим.

Разместим в app/bootstrap.php код:

<?php

require BASE_DIR . 'vendor/autoload.php';
require BASE_DIR . 'src/Core/functions.php'; // свои функции, если нужно

echo 'Hello!';

#  end of file

Запустим адрес в браузере: убедимся, что выводится «Hello!».

Последовательность выполнения кода

Наш начальный код будет будет состоять из следующих шагов:

  • Создание контейнера
  • Создание фабрики PSR-17
  • На её основе нужно будет создать $serverRequest — входящий http-запрос.
  • После этого мы подключим FastRoute, который выдаст нам имя исполняемой функции.

Дальше наш код может быть в двух вариантах.

Первый — это создание диспетчера Middleware в который мы будем внедрять функцию для роутинга. Также попутно создадим несколько Middleware, чтобы показать какими они могут быть.

Дальше мы запустим диспетчер и получим объект $response. Для итогового вывода будем использовать SapiEmitter от Laminas (бывший Zend Framework).

Другой вариант будет немного другой. Мы опять же создадим диспетчер, в который внедрим Middleware, но без роутинга. После этого запустим диспетчер и получим объект $response.

Дальше мы запустим функцию роутинга, которая, если нужно добавит данные в $response.

И последний вариант: если ли нам нужна общая шаблонизация сайта. Здесь мы пропускаем данные через layout. Это поведение аля-CMS.

Начальная часть

Я сразу добавил всю секцию use, хотя на начальном этапе не все они будут использоваться.

<?php

use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ServerRequestInterface;
use mindplay\middleman\Dispatcher;
use mindplay\middleman\ContainerResolver;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Psr\Http\Server\RequestHandlerInterface;

require BASE_DIR . 'vendor/autoload.php';
require BASE_DIR . 'src/Core/functions.php';

// контейнер
$container = new DI\Container();

// Фабрика PSR-17
$psr17Factory = new Psr17Factory();

$creator = new ServerRequestCreator(
    $psr17Factory, // ServerRequestFactory
    $psr17Factory, // UriFactory
    $psr17Factory, // UploadedFileFactory
    $psr17Factory  // StreamFactory
);

// Входящий http-запрос PSR-7
$serverRequest = $creator->fromGlobals();

После создания контейнера мы можем сразу его использовать. Например:

$container->set(Psr17Factory::class, \DI\create(Psr17Factory::class));
$container->set('debug', true);

Что касается фабрики PSR-17, то мы используем код инициализации самой библиотеки. На выходе получаем $serverRequest. Этот объект будет использоваться дальше по коду, но также мы можем получить из него информацию о входящем запросе.

Например (вместо pr можно использовать print_r):

pr($serverRequest -> getUri() -> getPath()); // /micro1/
pr($serverRequest -> getMethod()); // GET

Также мы можем создать новый Request с помощью $psr17Factory:

$psr17Factory->createResponse();

Это может пригодится в Middleware.

Теперь нужно создать маршруты роутинга. Делаем это так:

$routerDispatcher = FastRoute\simpleDispatcher(function (FastRoute\RouteCollector $r) {
    $r->addRoute('GET', '/', App\Modules\Home\Home::class);
    $r->addRoute('GET', '/hello', ['App\Modules\Hello\Hello', 'run']);
    $r->addRoute('GET', '/hello/{slug}', ['App\Modules\Hello\Hello', 'run']);
});

Это три маршрута, которые нам нужны для тестирования. Во всех случаях обработчики указывают на php-классы. Первый роутинг будет выполнен как класс с методом __invoke, остальные как явное указание массива класс/метод.

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

Сам по себе FastRoute выполняет только сравнение текущего запроса с заданными маршрутами. Поскольку у нас уже есть $serverRequest, то получим информацию через него:

$routerDispatcher = FastRoute\simpleDispatcher(function (FastRoute\RouteCollector $r) {
    $r->addRoute('GET', '/', App\Modules\Home\Home::class);
    $r->addRoute('GET', '/hello', ['App\Modules\Hello\Hello', 'run']);
    $r->addRoute('GET', '/hello/{slug}', ['App\Modules\Hello\Hello', 'run']);
});

$uri = $serverRequest->getUri()->getPath();
$uri = str_replace(APP_BASEPATH, '/', $uri);

$routeInfo = $routerDispatcher->dispatch($serverRequest->getMethod(), $uri);

$routerHandler = '';
$routerVars = '';
$routerResult = 'FOUND';

switch ($routeInfo[0]) {
    case FastRoute\Dispatcher::NOT_FOUND: // 404 Not Found
        $routerResult = 'Not Found';
        break;
    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED: // 405 Method Not Allowed
        $allowedMethods = $routeInfo[1];
        $routerResult = 'Method Not Allowed';
        break;
    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        $routerHandler = $handler;
        $routerVars = $vars;
        break;
}

// для отладки
pr($routerResult);
pr($routerHandler);
pr($routerVars);

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

После того, как роутер выполнит работу, он вернёт массив $routeInfo, который нужно проанализировать с помощью условий case (или if — на ваш выбор). На выходе нам нужны:

  • $routerResult — результат проверки — просто для тестирования
  • $routerHandler — это найденный обработчик маршрута — функция или класс
  • $routerVars — параметры маршрута

Запустите страницу на разных адресах. Например для главной получим такой вывод:

FOUND
App\Modules\Home\Home
Array()

Для адреса http://demo/micro1/hello:

FOUND
Array
(
    [0] => App\Modules\Hello\Hello
    [1] => run
)
Array()

Для http://demo/micro1/hello/about:

FOUND
Array
(
    [0] => App\Modules\Hello\Hello
    [1] => run
)
Array
(
    [slug] => about
)

И для любой несуществующей страницы:

Not Found

Обратите внимание, что у нас ещё нет класса App\Modules\Hello\Hello или функции App\Modules\Home\Home, но роутер всё равно пишет, что совпадение найдено. То есть второй параметр маршрута возвращается FastRoute как есть. Это может быть строка, массив — вообще всё, что угодно. Это позволяет задавать обработчик роута в удобном для пользователя формате. Например часто встречается такой вариант:

$r->addRoute('GET', '/hello', 'App\Modules\Hello\Hello@run');

Это строка, которая содержит имя класса и его метод через символ «@». Таким образом, перед тем, как использовать результат роутинга, нужно будет написать код, который будет парсить строку/массив и выполнять проверку на существование функций, классов и методов.

На этом начальная часть кода завершена. Дальше у нас может быть два варианта. Первый, когда роутер мы внедряем в Middleware — начнём с него.

Работаем с Middleware

Вначале нужно заполнить диспетчер функциям, а потом их запустить на выполнение. На выходе будет $response, который нужно вывести в браузер либо через эммитер, либо самостоятельно через echo.

Начальный код будет таким:

$dispatcherMiddleware = new Dispatcher(
    [
        App\Middleware\AfterMiddleware::class,  // invoke
        App\Middleware\DemoMiddleware::class,   // MiddlewareInterface
        App\Middleware\BeforeMiddleware::class, // invoke

        // routing
        function (ServerRequestInterface $request, RequestHandlerInterface $next) use ($routerHandler, $routerVars) {
            ... здесь будет код для роутинга
        },

        // последний
        function (ServerRequestInterface $request) use ($psr17Factory) {
            return $psr17Factory->createResponse();
        },
    ],
    new ContainerResolver($container)
);

// выполнение
$response = $dispatcherMiddleware->handle($serverRequest);

// вывод
(new SapiEmitter())->emit($response);

// или вывод только тела сообщения
// echo $response->getBody();

Диспетчер принимает массив функций, а также контейнер зависимостей. Именно благодаря ему, мы можем указывать имена классов как обычный текст, не создавая функции с new. На всякий случай уточню, что код

App\Middleware\AfterMiddleware::class

Это тоже самое, что и строка

'App\Middleware\AfterMiddleware'

Диспетчер будет запускать Middleware по очереди и есть два варианта поведения у разных библиотек. FIFO - первым пришёл, первым вышел; и FILO — первым пришёл, последним вышел. На самом деле нет особого смысла заморачиваться с точным алгоритмом, поскольку мы всегда можем изменить порядок выполнения Middleware просто поменять их местами в массиве. Конкретно в диспетчере middleman используется обратный порядок вывода: то есть вначале сработает последний в массиве Middleware.

Этот Middleware — обычная функция, которая просто возвращает новый объект response. Если убрать эту функцию, то следующий не получит response, который нужно дальше передавать по цепочке. Возникнет ошибка. В других диспетчерах работа может строиться по другому, поэтому сейчас мы рассматриваем работу именно с middleman.

Теперь рассмотрим как устроены классы Middleware. Если вы будете последовательно добавлять код, то неиспользуемые участки можно закомментировать.

Устройство классов Middleware

Для начала рассмотрим класс App\Middleware\DemoMiddleware:

<?php
namespace App\Middleware;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Server\MiddlewareInterface;

class DemoMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request); // 1
        $response->getBody()->write(' DemoMiddleware '); // 2

        return $response;
    }
}

# end of file

Это «настоящий» Middleware, поскольку он имеет тип MiddlewareInterface. Строго говоря именно так и должны строиться все посредники. Но из-за того, что диспетчер может принимать и другие формы записи, то можно несколько отступить от типа MiddlewareInterface.

На что нужно обратить внимание сейчас.

Есть метод process() с точной сигнатурой PSR-15. На входе будет: запрос и «обработчик», а на выходе готовый ответ $response.

Первая строчка позволяет получить текущий объект ответа. Вторая строчка — это запись в этот ответ своих данных. Сейчас мы пишем просто в тело http-сообщения.

Когда сработает следующий в очереди Middleware, он также сможет получить текущий response и добавить в него свои данные.

Если Middleware ничего не пишет в ответ, то он может просто передать дальше по цепочке $response. Это может быть актуальным для таких задач, как логирование.

Если мы запустим любую страницу сайта, то увидим текст «DemoMiddleware».

Второй Middleware — App\Middleware\AfterMiddleware:

<?php

namespace App\Middleware;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;

class AfterMiddleware
{
    public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        $response->getBody()->write('AFTER');

        return $response;
    }
}

# end of file

Он похож на DemoMiddleware, но не с типом MiddlewareInterface. Вместо метода process() используется магический метод __invoke() с такой же сигнатурой. Это позволяет выполнять данный класс как функцию.

За счёт того, что мы используем DI-контейнер, то диспетчер через него может получить из названия класса реальный объект. Если бы мы не использовали DI-контейнер, то классы пришлось бы задавать так:

$dispatcherMiddleware = new Dispatcher(
    [
        new App\Middleware\AfterMiddleware,
        new App\Middleware\DemoMiddleware,

        // последний
        function (ServerRequestInterface $request) use ($psr17Factory) {
            return $psr17Factory->createResponse();
        },
    ]
);

При этом, заметьте, что у нас два разных класса, но диспетчер сам смог определить, что один их них реализует MiddlewareInterface, поэтому сам нашёл исполняемый метод process(). А вот класс AfterMiddleware был проанализирован на наличие метода __invoke, поэтому диспетчер выполнил его как обычную функцию.

Этот пример показывает преимущество использования DI-контейнера. Мы можем передавать в диспетчер только имя класса без инстанцирования. DI-контейнер сам создаст нужный объект как lazy, то есть только тогда, когда он реально понадобится. Если какие-то Middleware работают с базой данных или выполняют ресурсоёмкие задачи, то lazy-загрузка положительно скажется на работе сайта.

Теперь рассмотрим App\Middleware\BeforeMiddleware

<?php

namespace App\Middleware;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;

use Nyholm\Psr7\Factory\Psr17Factory;
use DI\Container;

class BeforeMiddleware
{
    private $container;
    private $psr17Factory;

    public function __construct(Container $container)
    {
       $this->container = $container;
       $this->psr17Factory = $container->get(Psr17Factory::class);
    }

    public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        $existingContent = (string) $response->getBody();

        // новый response
        $response = $this->psr17Factory->createResponse();
        $response->getBody()->write('BEFORE ' . $existingContent);

        return $response;
    }
}

# end of file

Я намерено его усложнил, чтобы показать возможности. Сам по себе Middleware сделан через __invoke. Но внутри мы получаем текущий $response и сохраняем его тело. После этого создаём новый $response в который добавляем свой текст перед существующим. Таким образом текст «BEFORE» будет выводиться всегда перед любым другим выводом.

Для того, чтобы создать новый $response мы используем объект Psr17Factory, который получаем через контейнер. Чтобы всё это автоматизировать, мы используем конструктор, где указываем нужный класс как зависимость. Дальше сработает DI-контейнер, который сам найдет Psr17Factory. При этом нам не нужно где-то ещё специально добавлять существующий объект в контейнер.

При обновлении любой страницы мы получим текст «BEFORE DemoMiddleware AFTER». Попробуйте поменять порядок Middleware в массиве, чтобы проверить как они срабатывают.

Функция роутинга в Middleware

Как мы помним, роутинг нам вернул $routerHandler, которую нужно проанализировать. Вариант, если там имя функции я не буду приводить, при желании вы сможете это сделать самостоятельно.

Алгоритм будет примерно такой.

Если это строка, то будем считать, что это просто имя класса без указания вызываемого метода. Формально это может быть даже обычный конструктор. Главное, чтобы он возвращал ResponseInterface. Но по хорошему мы должны проверить этот класс на существование метода __invoke и если есть, то выполнить объект как функцию.

Если же это массив, то мы получаем имя класса и имя запускаемого метода.

Код функции может быть таким (без оптимизации, чтобы хорошо был виден алгоритм).

...
// routing
function (ServerRequestInterface $request, RequestHandlerInterface $next) use ($routerHandler, $routerVars) {
    if (is_string($routerHandler)) {
        $class = $routerHandler;
        $method = false;
    } else {
        $class = $routerHandler[0];
        $method = $routerHandler[1];
    }

    if (!class_exists($class)) return $next($request); // передаём управление следующему middleware

    if ($method) {
        $obj = new $class($routerVars);
        return $obj->$method($request, $next);
    } else {
        $obj = new $class($routerVars);

        if (method_exists($obj, '__invoke')) {
            return $obj($request, $next);
        } else {
            return new $class($request, $next);
        }
    }
},
...

Теперь проверим этот код на классе App\Modules\Home\Home, который мы указали в роутинге для главной.

<?php

namespace App\Modules\Home;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class Home
{
    public function __invoke(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        $response->getBody()->write(' Home ');

        return $response;
    }
}

# end of file

Как видно, это самый обычный Middleware, который мы рассмотрели выше.

Теперь посмотрим на роутинг с App\Modules\Hello\Hello. Там два варианта, где второй принимает аргументы маршрута (регулярка «slug»). Эти аргументы мы передаём в конструктор.

<?php

namespace App\Modules\Hello;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;

class Hello
{
    private $args;

    public function __construct($args = [])
    {
        $this->args = $args;
    }

    public function run(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        $response->getBody()->write(' HELLO ');

        if ($this->args) $response->getBody()->write(implode(',', $this->args));

        return $response;
    }
}

# end of file

Проверьте как выводятся адреса http://demo/micro1/hello и http://demo/micro1/hello/about. Если в роутинге мы добавим новые регулярки, то они автоматом выведутся на странице.

Первый вариант микрофреймворка

Собственно это и есть первый вариант нашего микрофреймворка. Здесь роутинг внедрён как обычный Middleware, единственное, что пришлось сделать, так это написать свой парсер результатов роутинга, чтобы получить нужный класс и объект.

Так же обратите внимание, что функция роутинга срабатывает перед остальными Middleware. Чтобы это изменить, достаточно поменять порядок элементов в массиве диспетчера.

В какой-то мере мы получили аналог Slim 4. Разница в том, что в Slim объекты роутинга и диспетчер Middleware спрятаны за фабрикой объекта приложения AppFactory. Поэтому там меньше кода, а здесь больше возможностей по управлению.

Например мы можем совершенно спокойно отследить 404-ошибку, которую определил роутинг и подключить произвольный класс для вывода сообщения в браузер. Если же делать через Middleware, то нужно генерировать и перехватывать исключение. Код получается намного сложней.

Второй вариант микрофреймворка

Второй вариант будет отличаться от первого только тем, что мы не будем внедрять роутинг как Middleware. Сделайте копию micro1 как micro2. В нём полностью удалите функцию роутинга из массива диспетчера Middleware.

То есть код с Middleware получится такой:

$dispatcherMiddleware = new Dispatcher(
    [
        App\Middleware\AfterMiddleware::class, // invoke
        App\Middleware\DemoMiddleware::class, // MiddlewareInterface
        App\Middleware\BeforeMiddleware::class, // invoke

        // последний
        function (ServerRequestInterface $request) use ($psr17Factory) {
            return $psr17Factory->createResponse();
        },
    ], new ContainerResolver($container)
);

$response = $dispatcherMiddleware->handle($serverRequest);

На выходе $response, который всегда сработает до роутинга.

Поскольку роутинг теперь не обязан следовать MiddlewareInterface или сигнатуре process(), то мы можем рассматривать исполняемую функцию как Controller в MVC или Action в ADR. На вход нам нужно будет только передать текущий $response и на выходе получить его же, чтобы вывести через эммитер.

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

// routing
function runRouting (Psr\Http\Message\ResponseInterface $response, $routerHandler, $routerVars) {
    if (is_string($routerHandler)) {
        $class = $routerHandler;
        $method = false;
    } else {
        $class = $routerHandler[0];
        $method = $routerHandler[1];
    }

    if (!class_exists($class)) return; // класс не найден

    if ($method) {
        $obj = new $class($response, $routerVars);
        return $obj->$method();
    } else {
        $obj = new $class($response, $routerVars);

        if (method_exists($obj, '__invoke')) {
            return $obj($response, $routerVars);
        } else {
            return new $class($response, $routerVars);
        }
    }
}

runRouting($response, $routerHandler, $routerVars);

// вывод
(new SapiEmitter())->emit($response);

Чтобы это работало, нужно переписать класс App\Modules\Home\Home:

<?php

namespace App\Modules\Home;
use Psr\Http\Message\ResponseInterface;

class Home
{
    public function __invoke(ResponseInterface $response, $routerVars)
    {
        $response->getBody()->write(' Home ');
    }
}

# end of file

Такой Action получит текущий $response и данные от роутинга.

Это простая схема, которая может использовать роутинг совершенно произвольно.

Третий вариант микрофреймворка с шаблонизатором сайта

Можно усложнить задачу. Например мы хотим формировать шаблон сайта из нескольких частей: content, header, footer, sidebar и т.д. Часть content будет формировать Controller/Action, который определил роутер, а остальные части будут формироваться какими-то другими частями приложения.

Поскольку content будет формироваться отдельно, то можно написать класс Layout, который будет хранить все составные части страницы. Дальше мы передаём этот класс в Action роутинга, который может добавить в него свой вывод. Дополнительно могут быть и другие классы, меняющие Layout, например для наполнения сайдбара, подвала и т.д.

На завершающем этапе нужно получить все данные из Layout и сформировать из него единый html-код (например через шаблонизатор) и вывести данные через эммитер.

Код будет таким. Класс Layout для простоты я размещаю прямо в файле bootstrap.php.

...

$response = $dispatcherMiddleware->handle($serverRequest);

class Layout
{
    public $header = '';
    public $content = '';
    public $sidebar = '';
    public $footer = '';
}

// routing2
function runRouting2($layout, $routerHandler, $routerVars)
{
    if (is_string($routerHandler)) {
        $class = $routerHandler;
        $method = false;
    } else {
        $class = $routerHandler[0];
        $method = $routerHandler[1];
    }

    if (!class_exists($class)) return; // класс не найден

    if ($method) {
        $obj = new $class();
        return $obj->$method($layout, $routerVars);
    } else {
        $obj = new $class();

        if (method_exists($obj, '__invoke')) {
            return $obj($layout, $routerVars);
        } else {
            return new $class($layout, $routerVars);
        }
    }
}

$layout = new Layout;

$layout->sidebar = 'Sidebar';
$layout->header = 'Header';
$layout->footer = 'Footer';

runRouting2($layout, $routerHandler, $routerVars);

$html = '<hr>' . $layout->header
    . '<hr>' . $layout->content
    . '<hr>' . $layout->sidebar
    . '<hr>' . $layout->footer;

$response->getBody()->write($html);

// вывод
(new SapiEmitter())->emit($response);

Теперь класс App\Modules\Home\Home получает объект Layout, который наполняет по своему усмотрению.

<?php

namespace App\Modules\Home;

class Home
{
    public function __invoke($layout, $routerVars)
    {
        $layout->content = 'Home content';
    }
}

# end of file

И класс App\Modules\Hello\Hello:

<?php

namespace App\Modules\Hello;

class Hello
{
    public function run($layout, $routerVars)
    {
        $slug = $routerVars['slug'] ?? '';
        $layout->content = 'hello content ' . $slug;
        $layout->header = ' hello header';
    }
}

# end of file

Понятно, что в реальном проекте Layout должен быть более «интелектуальным». Главное, что подобная схема позволяет разделить сайт на отдельные html-модули (верстальщики аплодируют!), а также то, что модуль отвечает и выводит только свою часть html-кода.

Итого

При написании своего микрофреймворка нужно сразу определиться какую задачу он должен решать. Если это работа с API, то будет удобней работать только с Middleware. Если предполагается вывод в браузер, то придётся использовать какую-то систему шаблонизации. Для небольших сайтов, где каждый Action отвечает за вывод целой страницы, можно использовать второй вариант фреймворка — добавление html-кода в текущий http-ответ.

Ну и если стоит задача написать сайт со сложным шаблоном, то можно использовать третий вариант, чтобы разделить шаблон на отдельные модули.

Приведённые примеры достаточно простые, я постарался их не усложнять. Если двигаться дальше, то я бы немного по другому сделал функции определения Action/Controller от роутинга. Сейчас варианты достаточно примитивные. Наверное было бы даже лучше сделать их с использованием DI-контейнера.

Так же может возникнуть ситуация, когда в Action/Controller нужно будет получить доступ к другим классам или контейнеру. Сейчас это сделать сложно,потому что в функция роутинга его не принимает.

Блок обработки Middleware логично было бы обернуть в try/catch, чтобы сразу ловить исключения, которые возникнут в работе Middleware.

Так же нужно решить как именно работать с 404-страницей. В теории, если роутинг выдаёт NotFound, то по идее не нужно запускать и слой Middleware. Но с другой стороны, можно было бы даже для такой страницы выполнять какие-то действия, например вести лог.

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

Большим плюсом будет ещё и то, что можно использовать разные библиотеки под задачи фреймворка. Наш фреймворк учебный, но хорошо показывает устройство уже более серьёзных проектов. Теперь разобраться в том как работают подобные PSR-7 фреймворки будет намного легче. :-)

Похожие записи
Комментарии (4) RSS
1 Bugo 2022-01-27 16:48:16

Полезная тема. Нашёл ещё такой конфигуратор https://docs.mezzio.dev/mezzio/ , в котором можно выбирать при установке используемые компоненты (хотя, конечно, в composer.json поменять никогда не поздно).


2 Admin 2022-01-27 18:37:53 admin

Ну это уже сложный и очень тяжелый вариант. :-)


3 Анто 2022-02-09 03:11:10

Вот в статьях все так заумно. Но я посмотрел код Albireo Framework. Контроллеров нет. Классов нет. HTML код в перемешку с PHP. Почему так?


4 Admin 2022-02-09 09:00:32 admin

Хороший вопрос! :-)

Albireo — фреймворк под конкрентную задачу. То есть вначале я поставил задачу, а потом написал под него код. Если бы я делал микрофреймворк под разные задачи, то скорее всего сделал бы универсальный вариант по стандартам PSR. Но в Albireo — этого просто не нужно. Именно поэтому я использую моноядро, в котором заключена вся функциональность. С практической точки зрения это позволяет облегчить поддержку проекта. Ну и кроме того, Albireo слишком маленький проект, чтобы раскидывать его на дополнительные файлы.