Глубокое погружение в Laravel Folio

Источник: «Laravel Folio deep dive»
Недавно Тейлор выложил бета-версию Laravel Folio на YouTube в видеоролике с ключевой конференции Laracon. В настоящее время документация состоит только из файла readme, что отражает простоту пакета. Благодаря своей простоте мы можем легко догадаться о его внутреннем устройстве. Я решил углубиться в эту тему, и в этой статье мы отправимся на поиски его внутренних механизмов.

Что такое Laravel Folio

Проще говоря, Laravel Folio — это страничный маршрутизатор для вашего Laravel-приложения. Все, что вам нужно сделать, — это создать blade-файл. Нет необходимости писать маршруты или создавать методы контроллера для возврата представления. Все просто и понятно.

Как его использовать

Я не буду вдаваться в подробности использования Folio, поскольку установка пакета composer и выполнение команды install занимает всего около 5 минут, как и установка любого стандартного пакета Laravel. Также следует учитывать, что Folio находится в стадии бета-версии, а значит, до выхода версии 1 в него могут быть внесены существенные изменения.

Однако я могу указать вам на LaravelFolioServiceProvider в каталоге app/Providers, где вы найдёте этот код в методе boot после установки:

public function boot(): void
{
Folio::route(resource_path('views/pages'), middleware: [
'*' => [
//
],
]);
}

Подробнее, что делает метод route, мы поговорим чуть позже. Но с первого взгляда понятно, что он сканирует все файлы в пределах view/pages и либо создаёт маршрут для каждого из них, либо отдельный обработчик, который обрабатывает запрашиваемую страницу.

Более пристальный взгляд на Folio

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

Базовая реплика Folio

Сначала создадим blade-файл по адресу resources/views/pages/profile.blade.php. Этот файл будет просто содержать строку "Hello World":

<?php
$message = 'Hello World';
?>

<div>
{{ $message }}
</div>

Далее мы создадим класс, аналогичный классу Folio. Назовём его Pager.

namespace App;

use Illuminate\Support\Facades\Route;

class Pager
{
public static function route(string $path): void
{
$files = collect(scandir(resource_path('views/'.$path)))
->skip(3) // ['.', '..', '.gitkeep']
->filter(fn ($file) => str_ends_with($file, '.blade.php'))
->map(fn($file) => str_replace('.blade.php', '', $file));

$files->each(function ($view) use($path) {
Route::get($view, fn () => view($path.'/'.$view));
});
}
}

А в методе boot AppServiceProvider мы можем использовать Pager, например

public function boot(): void
{
Pager::route('pages');
}

В методе route указывается путь к директории resources/views, содержащей все blade файлы, для которых мы хотим сгенерировать маршруты. Для каждого файла {something}.blade.php мы создаём маршрут с URI {something}, который возвращает view('pages/{something}'), как и любой обычный метод create внутри контроллера. Теперь, когда мы переходим по адресу app.test/profile, он будет возвращать строку "Hello World". Это и есть базовая, упрощённая реализация Folio.

Конечно, наш пример не учитывает более сложные сценарии, такие как вложенные каталоги или динамические страницы типа views/pages/users/[id].blade.php. В следующих разделах мы рассмотрим, как Folio справляется с такими ситуациями.

Ядро Folio

В Folio все начинается с FolioServiceProvider, расположенного в config/app.php вашего проекта.

public function boot(): void
{
Folio::route(resource_path('views/pages'), middleware: [
'*' => [
//
],
]);
}

Метод route отвечает за сканирование пути и реализацию всех определённых нами middleware-правил.

Теперь давайте разберёмся, что в нем содержится.

public function route(string $path = null, ?string $uri = '/', array $middleware = []): static
{
$path = $path ? realpath($path) : config('view.paths')[0].'/pages';

if (! is_dir($path)) {
throw new InvalidArgumentException("The given path [{$path}] is not a directory.");
}

$this->mountPaths[] = $mountPath = new MountPath($path, $uri, $middleware);

if ($uri === '/') {
Route::fallback($this->handler($mountPath))
->name($mountPath->routeName());
} else {
Route::get(
'/'.trim($uri, '/').'/{uri?}',
$this->handler($mountPath)
)->name($mountPath->routeName())->where('uri', '.*');
}

return $this;
}

Он начинает с определения $path, который будет использоваться, и имеет путь по умолчанию, если он не указан.

Далее он генерирует объект MountPath и добавляет его в массив mountPaths. Это говорит, что мы можем использовать метод route столько раз, сколько нам нужно, если наши представления распределены по нескольким каталогам.

После создания объекта MountPath проверяется, направлен ли URI на корневую страницу нашего приложения. Если да, то регистрируется обработчик в качестве резервного/fallback маршрута. Если нет, то определяется маршрут с целевым путём и передаётся тот же обработчик для разрешения всех маршрутов для наших представлений.

Скорее всего, мы будем использовать Folio для нашей корневой страницы, поэтому давайте сразу перейдём к вызову $this->handler и посмотрим, что он делает.

protected function handler(MountPath $mountPath): Closure
{
return function (Request $request, string $uri = '/') use ($mountPath) {
return (new RequestHandler(
$mountPath,
$this->renderUsing,
fn (MatchedView $matchedView) => $this->lastMatchedView = $matchedView,
))($request, $uri);
};
}

Метод handler возвращает замыкание, которое вызывается при обработке Laravel Router нового запроса.

Далее изучим класс RequestHandler.

class RequestHandler
{
/**
* Создание нового экземпляра обработчика запросов.
*/

public function __construct(protected MountPath $mountPath,
protected ?Closure $renderUsing = null,
protected ?Closure $onViewMatch = null)
{
}

/**
* Обработка входящего запроса с помощью Folio.
*/

public function __invoke(Request $request, string $uri): mixed
{
}

/**
* Получение middleware, которое должно быть применено к соответствующему представлению.
*/

protected function middleware(MatchedView $matchedView): array
{
}

/**
* Создание экземпляра ответа для заданного соответствующего представления.
*/

protected function toResponse(MatchedView $matchedView): Response
{
}
}

Рассмотрим метод __invoke

public function __invoke(Request $request, string $uri): mixed
{
$matchedView = (new Router(
$this->mountPath->path
))->match($request, $uri) ?? abort(404);

return (new Pipeline(app()))
->send($request)
->through($this->middleware($matchedView))
->then(function (Request $request) use ($matchedView) {
if ($this->onViewMatch) {
($this->onViewMatch)($matchedView);
}

return $this->renderUsing
? ($this->renderUsing)($request, $matchedView)
: $this->toResponse($matchedView);
});
}

Метод начинает работу с поиска подходящего представления, которое ссылается на blade-файл. Затем он создаёт конвейер/пайплайн для выполнения следующих задач:

Теперь перейдём к рассмотрению метода $this->middleware.

protected function middleware(MatchedView $matchedView): array
{
return Route::resolveMiddleware(
$this->mountPath
->middleware
->match($matchedView)
->prepend('web')
->merge($matchedView->inlineMiddleware())
->unique()
->values()
->all()
);
}

По сути, метод возвращает массив middleware. Особо следует рассмотреть метод $matchedView->inlineMiddleware().

Folio встроенный Middleware

Folio позволяет реализовать проверки middleware непосредственно в blade файле. Вот пример:

<?php
use function Laravel\Folio\middleware;

middleware(['auth']);

$message = 'Hello World';
?>

<div>
{{ $message }}
</div>

Обратите внимание, что мы должны размещать PHP-код внутри открывающих тегов <?php //здесь ?>, и мы не можем использовать директиву blade, например:

@php
use function Laravel\Folio\middleware;

middleware(['auth']);
@endphp

Если использовать директиву, то middleware не будет работать, поскольку рендеринг blade шаблонов происходит гораздо позже, чем шаг проверки middleware. Поэтому при использовании директивы она будет определяться при рендеринге blade файла, что фактически ничего не даёт.

Как работает функция Middleware

Если мы рассмотрим функцию middleware:

function middleware(Closure|string|array $middleware = []): PageOptions
{
Container::getInstance()->make(InlineMetadataInterceptor::class)->whenListening(
fn () => Metadata::instance()->middleware = Metadata::instance()->middleware->merge(Arr::wrap($middleware)),
);

return new PageOptions;
}

Она устанавливает замыкание в методе InlineMetadataInterceptor whenListening и передаёт ему замыкание, возвращающее массив middleware.

Обратите внимание, что Metadata::instance() здесь является объектом-синглтоном. Однако он очищается после каждого запроса, обработанного Folio. Так что даже в Laravel Octane об этом можно не беспокоиться.

InlineMetadataInterceptor

Вернёмся к $matchedView->inlineMiddleware() и посмотрим, чего она достигает:

public function inlineMiddleware(): Collection
{
return app(InlineMetadataInterceptor::class)->intercept($this)->middleware;
}

Теперь метод intercept выполняет магические действия по разрешению промежуточного модуля ['auth'], который мы определили в файле blade:

public function intercept(MatchedView $matchedView): Metadata
{
if (array_key_exists($matchedView->path, $this->cache)) {
return $this->cache[$matchedView->path];
}

try {
$this->listen(function () use ($matchedView) {
ob_start();

[$__path, $__variables] = [
$matchedView->path,
$matchedView->data,
];

(static function () use ($__path, $__variables) {
extract($__variables);

require $__path;
})();
});
} finally {
ob_get_clean();

$metadata = tap(Metadata::instance(), fn () => Metadata::flush());
}

return $this->cache[$matchedView->path] = $metadata;
}

Здесь происходит довольно много всего, но ключевой частью является статическое закрытие, которое выполняет blade-файл. По сути, он должен разрешить выполнение любого кода, определённого в открывающем и закрывающем PHP тегах. Таким образом, срабатывает что-то вроде функции middleware(['auth']).

И обратите внимание, что в строке 25 он получает экземпляр объекта metadata и затем стирает его.

Вернёмся к __invoke

public function __invoke(Request $request, string $uri): mixed
{
$matchedView = (new Router(
$this->mountPath->path
))->match($request, $uri) ?? abort(404);

return (new Pipeline(app()))
->send($request)
->through($this->middleware($matchedView))
->then(function (Request $request) use ($matchedView) {
if ($this->onViewMatch) {
($this->onViewMatch)($matchedView);
}

return $this->renderUsing
? ($this->renderUsing)($request, $matchedView)
: $this->toResponse($matchedView);
});
}

После запуска middleware выполняется подготовка содержимого blade к отображению.

Заключение

Laravel Folio — впечатляющий пакет, который я бы, скорее всего, использовал для быстрого MVP и более простых проектов. Мне нравится его концепция и внутреннее устройство.

Дополнительные материалы

Предыдущая Статья

Повышение уровня TypeScript с помощью типов Record

Следующая Статья

Руководство по использованию Websockets в Laravel