Laravel: Request-Response
В этой статье мы рассмотрим внутренности Laravel. Попробуем разобраться, как устроен популярный фреймворк, из чего состоит и какие подходы использует. Чем отличается от того же Symfony, например. Конечно, чтобы использовать фреймворк, совсем необязательно знать, как он работает, однако это полезно, в первую очередь, потому, что так вы лучше понимаете инструмент, знаете, как его дебажить, легче определяете источник возникших багов и быстрее находите решения нестандартных проблем, выходящих за рамки документации.
Обзор будет состоять из нескольких частей: для начала мы рассмотрим полный цикл работы фреймворка, потом посмотрим на различные компоненты и — для лучшего понимания — напишем свои, если придется, а также обсудим, почему профессиональное php сообщество не очень любит Laravel (к слову, есть за что, но об этом позже).
Bootstrap приложения
Как это всегда бывает, наш веб-сервер перенаправляет все запросы на public/index.php
В нем Laravel подключает файл app.php из папки bootstrap в корне проекта, в котором создается контейнер внедрения зависимостей и биндятся (связываются интерфейсы с реализациями) некоторые важные зависимости: Kernel для консоли и веба и обработчик ошибок. Тут стоит немного остановиться и рассказать про контейнер зависимостей. Дело в том, что в Laravel он не только инжектит, но является самим приложением, которое тесно связано со всеми процессами, происходящими во фреймворке: определение роута, создание контроллера и так далее. Это первый минус в копилку Laravel, так как если вы хоть раз задумывались о том, чтобы поставить компонент фреймворка отдельно, вы неизбежно почти всегда потянете контейнер.
Давайте посмотрим на файл bootstrap/app.php, а потом вернемся обратно:
Обратите внимание на первые строки, в них мы связываем интерфейс Kernel с классом App\Http\Kernel, именно в этом классе вы чаще всего могли видеть и определять миддлвары, однако возможности его чуть шире, но об этом ниже. Также вы могли видеть, что он наследуется от Illuminate\Foundation\Http\Kernel из вендора. В нем расположен весь код по работе с запросом и ответом, однако фреймворк намеренно использует Kernel из папки приложения, чтобы у вас была возможность переопределить некоторые методы, убрать или добавить миддлвары и/или бутстрапперы.
Возвращаемся к index.php:
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);
$response->send();
$kernel->terminate($request, $response);
Контейнер создал нам Kernel (App\Http\Kernel) и вызвал handle для текущего запроса.
public function handle($request)
{
try {
$request->enableHttpMethodParameterOverride();
$response = $this->sendRequestThroughRouter($request);
} catch (Throwable $e) {
$this->reportException($e);
$response = $this->renderException($request, $e);
}
$this->app['events']->dispatch(
new RequestHandled($request, $response)
);
return $response;
}
Как мы видим, все наше приложение обернуто в try .. catch, что позволяет обрабатывать конкретные типы исключений по отдельности и не показывать пользователю на проде полную информацию о случившейся ошибке. Вся работа происходит в методе sendRequestThroughRouter():
protected function sendRequestThroughRouter($request)
{
$this->app->instance('request', $request);
Facade::clearResolvedInstance('request');
$this->bootstrap();
return (new Pipeline($this->app))
->send($request)
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
}
Поскольку Laravel дает возможность не только инжектить Request в методы контроллера, но и использовать его в качестве фасада и функции, необходимо, чтобы актуальный Request был доступен из контейнера, так как оба — фасад и функция — достают его именно из него. Дальше мы очищаем старые инстансы реквеста с помощью Facade::clearResolvedInstance, так как они хранятся в статическом свойстве $resolvedInstance с более продолжительным временем жизни.
Метод bootstrap, идущий следом, вызывает так называемые бутстрапперы - обработчики, которые будут вызваны прежде, чем запрос будет передан контроллеру: это загрузка и кэширование переменных окружения и конфигурации, установка параметров, таких как определение обработчика ошибок, обработчика исключений, настройка ini для показа или нет ошибок в зависимости от окружения, запуск провайдеров, которые регистрируют зависимости в контейнере, и регистрация фасадов. Список стандартных бутстрапперов можно увидеть тут же, в файле Illuminate\Foundation\Http\Kernel:
По умолчанию фреймворк не предоставляет возможности добавить свой бутстраппер, однако поскольку Kernel из приложения наследуется от Illuminate\Foundation\Http\Kernel, где определено свойство $bootstrappers с изображения выше, вы можете определить это свойство в App\Http\Kernel, перетащив туда стандартные бутстрапперы и добавив свои. Чтобы ваш бутстраппер заработал, необходимо в нем определить следующий метод:
public function bootstrap(Application $app)
{
}
Так как в Laravel зачастую используется утиная типизация, имплементить какой-либо интерфейс или класс не нужно.
Среди стандартных бутстрапперов есть такие два: RegisterProviders и BootProviders. Они вызывают сервис-провайдеры из config/app:providers, которые предоставляют возможность вам и фреймворку настроить необходимые зависимости для работы приложения: соединение с базой данных, конфигурация файловой системы, сервисы аутентификации и многое другое. Это можно назвать сердцем фреймворка, так как без зависимостей ваше приложение будет мало что уметь.
Из интересных сервис-провайдеров обратите внимание на DatabaseServiceProvider, в нем ваша модель "наполняется" соединением с базой данных. Это к слову о магии, которую вы могли не понимать.
Давайте вернемся к методу sendRequestThroughRouter. После бутстраппа приложения происходит следующее:
return (new Pipeline($this->app))
->send($request)
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
Компонент Pipeline предоставляет возможность запускать ряд обработчиков словно по трубе — один за другим. Такими обработчиками в Laravel являются, как вы уже, наверно, догадались, миддлвары. В данный момент запускаются глобальные миддлвары для всего приложения, а в конце выполняется callback-функция, которую возвращает метод dispatchToRouter. Компонент Pipeline устроен несложно: с помощью функции array_reduce по очереди выполняются одна миддлвара за другой, передавая результат обработки предыдущей миддлвары следующей.
Наверно, создавая собственные миддлвары, вы по умолчанию определяли в нем метод handle, однако вы могли бы определить вместо него магический метод __invoke с той же сигнатурой и все работало бы так же. Чтобы убедиться в этом, можете посмотреть код класса Pipeline на этой строке illuminate/pipeline
По умолчанию используется метод handle. Вы так же можете это изменить, если переопределите в Kernel метод sendRequestThroughRouter. Например, так:
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
...
protected function sendRequestThroughRouter($request)
{
$this->app->instance('request', $request);
Facade::clearResolvedInstance('request');
$this->bootstrap();
return (new Pipeline($this->app))
->send($request)
->via('process')
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
}
}
Теперь все ваши миддлвары должны будут иметь или метод process, или __invoke.
Есть еще одна особенность миддлвар, о которой вы могли не знать, и связана она с тем, что миддлвары могут запускаться как до контроллера, так и после. Ничего специально для этого делать не нужно, достаточно запомнить разницу:
// До контроллера
public function handle(Request $request, \Closure $next)
{
// do something
return $next($request);
}
// После контроллера
public function handle(Request $request, \Closure $next)
{
$response = $next($request);
// do something
return $response;
}
Как видно из примера, в первом случае мы возвращаем результат вызова callback-функции $next от Request, а во втором — используем этот результат (результатом будет вызов всех миддлвар и контроллера) и модифицируем ответ пользователю. Также одна миддлвара может быть выполнена как до контроллера, так и после. Примером такой миддлвары является StartSession. Обратите внимание на этот код: тут мы запускаем сессию до вызова контроллера, а после работы контроллера добавляем куки в ответ, сохраняем сессию и возвращаем ответ.
Роутинг
После того, как все глобальные миддлвары были запущены, выполняется callback-функция из метода dispatchToRouter: Kernel.php
Обратите внимание на следующую строку:
$this->app->instance('request', $request);
Поскольку миддлвары могли модифицировать Request, нам необходимо положить его в контейнер заново, чтобы вы могли использовать актуальный контекст запроса, когда будете получать его из метода контроллера, с помощью фасада или функции request().
Далее мы передаем обработку роутеру:
return $this->router->dispatch($request);
Роутер в свою очередь пытается найти роут и запустить его Router.php
Вы помните, что для отдельного роута тоже есть миддлвары, они запускаются тут. Если вам понадобится пропустить миддлвары для роута, вы можете определить в контейнере переменную middleware.disable со значением true. Нужно это в основном бывает только в тестах. Например, вы можете вызвать в тесте метод withoutMiddleware и ничего не передать, тогда отключатся все миддлвары. Этот метод доступен после наследования от TestCase.
И вновь мы снова видим Pipeline, на этот раз он перебирает миддлвары для конкретного роута и в конце вызывает $route->run():
public function run()
{
$this->container = $this->container ?: new Container;
try {
if ($this->isControllerAction()) {
return $this->runController();
}
return $this->runCallable();
} catch (HttpResponseException $e) {
return $e->getResponse();
}
}
Вы же не забыли, что мы до сих пор находимся в callback-функции, которую чуть ранее передали сюда? Напоминаю на всякий случай, чтобы вы понимали контекст.
Тут фреймворк пытается вызвать обработчик роута как callable или как контроллер. В обоих случаях ему необходимо определить входящие зависимости: если это callable —зависимости этой функции, если контроллер с методом — зависимости конструктора контроллера и вызываемого для роута метода.
В Route и ControllerDispatcher подключается трейт RouteDependencyResolverTrait, который с помощью рефлексии и контейнера внедрения зависимостей создает и возвращает входящие аргументы обработчика. Про контейнер внедрения зависимостей поговорим в следующей части обзора, а также напишем свой вариант.
После того, как контроллер отработал, мы возвращаем Response. Несмотря на то, что из контроллера вы можете возвращать не только объект Response, как это сделано в Symfony, Laravel все равно трансформирует ваш ответ в этот объект.
На всех этапах перемещения запроса от пользователю к ядру Laravel бросает различные события: RouteMatched, RequestHandled, события о том, что конкретный сервис-провайдер начал выполняться и так далее. Это дает разработчикам дополнительные точки расширения, однако это не так популярно, как в Symfony, где события являются основным функционалом для общения различных компонентов. Тем не менее, это не так удобно, как миддлвары, которых в Symfony нет, за что Laravel можно только похвалить.
Response
В index.php после обработки запроса вызывается $response->send(). Этот метод отправляет заголовки через функцию php header и распечатывает контент с помощью echo. В самом конце у Kernel вызывается метод terminate, который, во-первых, вызывает terminable middleware (подробнее читайте в документации), а во-вторых, выполняет terminatingCallbacks, куда вы можете добавить джобу следующим образом:
use Illuminate\Bus\Dispatcher;
final class SomeCommandHandler
{
private Dispatcher $dispatcher;
public function __construct(Dispatcher $dispatcher)
{
$this->dispatcher = $dispatcher;
}
public function handle(SomeCommand $command)
{
// do something here
$this->dispatcher->dispatchAfterResponse(new AfterResponseJob(...));
}
}
Теперь после того, как мы вернули ответ пользователю, приложение выполнит эти джобы.
Помимо успешного ответа, у нас есть исключения, их надо как-то обработать и показывать пользователю. Если вы не словили исключение, его словит метод Kernel:handle. Давайте вспомним код из начала статьи:
public function handle($request)
{
try {
$request->enableHttpMethodParameterOverride();
$response = $this->sendRequestThroughRouter($request);
} catch (Throwable $e) {
$this->reportException($e);
$response = $this->renderException($request, $e);
}
$this->app['events']->dispatch(
new RequestHandled($request, $response)
);
return $response;
}
Если мы попадем в catch, фреймворк вызовет специальный объект ExceptionHandler, где вы можете обработать исключения в зависимости от типа в два этапа: зарепортить, то есть сообщить о нем в sentry, telegram, slack, куда-либо еще или ничего не делать, и отрендерить, то есть вернуть Response, который увидит пользователь, или, опять же, ничего не делать, тогда пользователь увидит неприятную или приятную ошибку в зависимости от того, как вы настроили веб-сервер.
Есть еще один способ, чтобы зарепортить и отрендерить исключение, — вы можете реализовать у исключения методы report и render, подробнее смотрите в документации.
Итак, в первой части обзора мы постарались разобрать основной процесс работы Laravel. В последующих частях мы начнем более детально разбирать каждый из компонентов: контейнер внедрения зависимостей, миддлвары, event-dispatcher и так далее.
Комментарии