Презентеры
Мы разберём, как в Nette пишутся презентеры и шаблоны. После прочтения вы будете понимать:
- как работают презентеры
- что такое постоянные параметры
- как отрисовываются шаблоны
Мы уже знаем, что презентер – класс, представляющий определённую страницу веб-приложения, например главную страницу, товар в интернет-магазине, форму входа, ленту карты сайта и так далее. У приложения может быть от одного до тысяч презентеров. В других фреймворках их называют контроллерами.
Обычно под словом „презентер“ понимают потомка класса Nette\Application\UI\Presenter, который подходит для создания веб-интерфейсов и которому посвящена остальная часть этой главы. В общем смысле презентер – любой объект, реализующий интерфейс Nette\Application\IPresenter.
Жизненный цикл презентера
Задача презентера – обработать запрос и вернуть ответ (которым может быть HTML-страница, изображение, перенаправление и так далее).
Итак, сначала ему передаётся запрос. Это не прямой HTTP-запрос, а объект Nette\Application\Request, в который HTTP-запрос был преобразован с помощью маршрутизатора. Обычно мы с этим объектом напрямую не работаем, потому что презентер ловко передаёт обработку запроса другим методам, которые мы сейчас и рассмотрим.
Жизненный цикл презентера
Диаграмма показывает список методов, которые вызываются по очереди сверху вниз, если они существуют. Ни один из них не обязателен: у вас может быть совершенно пустой презентер без единого метода, на котором вы построите простой статический сайт.
__construct()
Строго говоря, конструктор не относится к жизненному циклу презентера, потому что вызывается в момент создания объекта. Но мы упоминаем его из-за его важности. Конструктор (вместе с методом inject) служит для передачи зависимостей.
Презентер не должен заниматься бизнес-логикой приложения, писать в
базу данных или читать из неё, выполнять вычисления и подобное. За это
отвечают классы слоя, который мы называем моделью. Например, класс
ArticleRepository может отвечать за загрузку и сохранение статей. Чтобы
презентер мог с ним работать, класс нужно передать через внедрение
зависимостей:
class ArticlePresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private ArticleRepository $articles,
) {
}
}
startup()
Сразу после получения запроса вызывается метод startup(). Вы
можете использовать его для инициализации свойств, проверки прав
пользователя и подобного. Требуется, чтобы этот метод всегда вызывал
родительский: parent::startup().
action<Action>(args...)
Похож на метод render<View>(). Если render<View>()
предназначен для подготовки данных конкретного шаблона, который затем
будет отрисован, то action<Action>() обрабатывает запрос, не
обязательно отрисовывая после этого шаблон. Например, он может
обработать данные, выполнить вход или выход пользователя и затем перенаправить куда-то.
Важно, что action<Action>() вызывается перед
render<View>(). Это позволяет нам при необходимости изменить ход
запроса внутри метода действия, например поменять шаблон, который
будет отрисован, или даже метод render<View>(), который будет
вызван, с помощью setView('otherView').
Вы можете даже переключиться на совершенно другое
действие методом switch('otherAction'). Он прерывает текущий метод и
вместо этого запускает методы action<Action>() и render<View>()
нового действия (и отключает автоматическую канонизацию). Сам запрос продолжается; прерывается
лишь выполняющийся в данный момент метод.
В метод передаются параметры из запроса. У этих параметров можно и
рекомендуется указывать типы, например actionShow(int $id, ?string $slug = null).
Если параметра id нет или он не является целым числом, презентер
возвращает ошибку 404 и завершается.
handle<Signal>(args...)
Этот метод обрабатывает так называемые сигналы, о которых мы узнаем в главе, посвящённой компонентам. Он предназначен прежде всего для компонентов и обработки AJAX-запросов.
В метод передаются параметры из запроса, как и в action<Action>(),
включая проверку типов.
beforeRender()
Метод beforeRender, как следует из его имени, вызывается перед
каждым методом render<View>(). Он служит для общей настройки
шаблона, передачи переменных в макет и подобных задач.
render<View>(args...)
Здесь мы готовим шаблон к последующей отрисовке, передаём в него данные и так далее.
В метод передаются параметры из запроса, как и в action<Action>(),
включая проверку типов.
public function renderShow(int $id): void
{
// получаем данные из модели и передаём их в шаблон
$this->template->article = $this->articles->getById($id);
}
afterRender()
Метод afterRender, как опять же следует из имени, вызывается после
каждого метода render<View>(). Используется он довольно редко.
shutdown()
Вызывается в конце жизненного цикла презентера.
События
Помимо методов startup(), beforeRender() и shutdown(),
вызываемых в рамках жизненного цикла презентера, можно определить и
другие функции, которые будут вызваны автоматически. Презентер
определяет так называемые события, и
вы добавляете их обработчики в массивы $onStartup, $onRender и
$onShutdown.
class ArticlePresenter extends Nette\Application\UI\Presenter
{
public function __construct()
{
$this->onStartup[] = function () {
// ...
};
}
}
Обработчики из массива $onStartup вызываются прямо перед методом
startup(), обработчики $onRender – между beforeRender() и
render<View>(), а обработчики $onShutdown – прямо перед
shutdown().
Небольшой совет, прежде чем продолжим: как видите, презентер
может обрабатывать несколько действий или представлений, то есть у
него может быть несколько методов render<View>(). Однако мы
рекомендуем проектировать презентеры с одним действием или с как
можно меньшим их числом.
Отправка ответа
Ответом презентера обычно служит отрисовка шаблона в HTML-страницу, но это может быть и отправка файла, JSON или даже перенаправление на другую страницу.
В любой момент жизненного цикла мы можем воспользоваться одним из следующих методов, чтобы отправить ответ и одновременно завершить презентер:
redirect(),redirectPermanent(),redirectUrl()иforward()выполняют перенаправлениеerror()завершает презентер из-за ошибкиsendJson($data)завершает презентер и отправляет данные в формате JSONsendTemplate()завершает презентер и сразу отрисовывает шаблонsendResponse($response)завершает презентер и отправляет собственный ответterminate()завершает презентер без ответа
Каждый из этих методов немедленно завершает презентер, выбрасывая
исключение молчаливого завершения Nette\Application\AbortException.
Если вы не вызовете ни один из этих методов, презентер автоматически перейдёт к отрисовке шаблона. Почему? Потому что в 99 % случаев мы хотим отрисовать шаблон, поэтому презентер принимает такое поведение как поведение по умолчанию, чтобы упростить нам работу.
Создание ссылок
У презентера есть метод link(), служащий для создания URL-ссылок на
другие презентеры. Первый параметр – целевой презентер и действие, за
ними идут аргументы, которые можно передать массивом:
$url = $this->link('Product:show', $id);
$url = $this->link('Product:show', [$id, 'lang' => 'en']);
В шаблоне ссылки на другие презентеры и действия создаются так:
<a n:href="Product:show $id">product detail</a>
Просто напишите привычную пару Презентер:действие вместо
настоящего URL и добавьте нужные параметры. Хитрость в n:href,
которая говорит Latte обработать этот атрибут и породить настоящий URL. В
Nette вам вообще не нужно думать об URL, только о презентерах и действиях.
Подробнее в главе Создание URL-ссылок.
Перенаправление
Для перехода к другому презентеру служат методы redirect() и
forward(). Их синтаксис очень похож на метод link().
Метод forward() переходит к новому презентеру сразу, без
HTTP-перенаправления:
$this->forward('Product:show');
Пример временного перенаправления с HTTP-кодом 302 (или 303, если текущий метод запроса – POST):
$this->redirect('Product:show', $id);
Чтобы добиться постоянного перенаправления с HTTP-кодом 301, используйте:
$this->redirectPermanent('Product:show', $id);
Перенаправить на другой URL вне приложения можно методом
redirectUrl(). HTTP-код можно указать вторым параметром; по умолчанию
это 302 (или 303, если текущий метод запроса – POST):
$this->redirectUrl('https://nette.org');
Перенаправление немедленно завершает работу презентера, выбрасывая
так называемое исключение молчаливого завершения
Nette\Application\AbortException.
Перед перенаправлением можно отправить flash-сообщения, то есть сообщения, которые отобразятся в шаблоне после перенаправления.
Flash-сообщения
Это сообщения, обычно извещающие о результате какой-то операции. Важная особенность flash-сообщений в том, что они остаются доступны в шаблоне и после перенаправления. После показа они остаются активными ещё 30 секунд: например, если пользователь обновит страницу из-за ошибки передачи, сообщение не исчезнет сразу.
Достаточно вызвать метод flashMessage(), а
презентер позаботится о передаче сообщения в шаблон. Первый
параметр – текст сообщения, необязательный второй – его тип
(например, error, warning, info). Метод flashMessage() возвращает экземпляр
flash-сообщения, что позволяет добавить дополнительные сведения.
$this->flashMessage('The item has been deleted.');
$this->redirect(/* ... */); // и перенаправляем
В шаблоне эти сообщения доступны в переменной $flashes как
объекты stdClass, содержащие свойства message (текст сообщения),
type (тип сообщения) и, возможно, упомянутые добавленные
пользователем сведения. Отрисовываем мы их так:
{foreach $flashes as $flash}
<div class="flash {$flash->type}">{$flash->message}</div>
{/foreach}
Ошибка 404 и подобные
Если запрос выполнить нельзя, например потому что статьи, которую мы
хотим показать, нет в базе данных, мы выбрасываем ошибку 404 методом
error(string $message = '', int $httpCode = 404).
public function renderShow(int $id): void
{
$article = $this->articles->getById($id);
if (!$article) {
$this->error();
}
// ...
}
HTTP-код ошибки можно передать вторым параметром; по умолчанию это
404. Метод работает так, что выбрасывает Nette\Application\BadRequestException,
после чего Application передаёт управление error-презентеру. Это
презентер, задача которого – показать страницу с сообщением о
произошедшей ошибке. Error-презентер задаётся в конфигурации приложения.
Отправка JSON
Метод sendJson($data) кодирует заданные данные в JSON, отправляет их
как HTTP-ответ и завершает презентер. Пример:
public function actionData(): void
{
$data = ['hello' => 'nette'];
$this->sendJson($data);
}
Параметры запроса
Презентер, а также каждый компонент получают свои параметры из
HTTP-запроса. Получить их значения можно методами getParameter($name) или
getParameters(). Значениями служат строки или массивы строк, по сути
сырые данные, полученные прямо из URL.
Ради большего удобства мы рекомендуем обращаться к параметрам через
свойства. Достаточно пометить их атрибутом #[Parameter]:
use Nette\Application\Attributes\Parameter; // эта строка важна
class HomePresenter extends Nette\Application\UI\Presenter
{
#[Parameter]
public string $theme; // должно быть public
}
У свойства мы рекомендуем указывать тип данных (например, string),
и Nette автоматически приведёт значение соответствующим образом.
Значения параметров можно и проверять.
При создании ссылки значение параметра можно задать напрямую:
<a n:href="Home:default theme: dark">click</a>
Постоянные параметры
Постоянные параметры служат для сохранения состояния между разными
запросами. Их значение остаётся тем же и после щелчка по ссылке. В
отличие от данных сессии, они передаются в URL. И происходит это
полностью автоматически, поэтому явно указывать их в link() или
n:href не нужно.
Пример применения? Представьте многоязычное приложение. Текущий
язык – параметр, который всегда должен быть частью URL. Но включать его
в каждую ссылку было бы невероятно утомительно. Поэтому вы делаете его
постоянным параметром lang, и он будет переноситься
автоматически. Красота!
Создать постоянный параметр в Nette исключительно просто. Достаточно
создать публичное свойство и пометить его атрибутом (раньше
использовалось /** @persistent */):
use Nette\Application\Attributes\Persistent; // эта строка важна
class ProductPresenter extends Nette\Application\UI\Presenter
{
#[Persistent]
public string $lang; // должно быть public
}
Если у $this->lang значение вроде 'en', то ссылки, созданные
через link() или n:href, будут содержать и параметр lang=en.
А после щелчка по ссылке $this->lang снова будет 'en'.
У свойства мы рекомендуем указывать тип данных (например, string),
а также можно задать значение по умолчанию. Значения параметров можно
проверять.
Постоянные параметры обычно переносятся между всеми действиями данного презентера. Чтобы переносить их и между несколькими презентерами, их нужно определить либо:
- в общем предке, от которого наследуются презентеры
- либо в трейте, который презентеры используют:
trait LanguageAware
{
#[Persistent]
public string $lang;
}
class ProductPresenter extends Nette\Application\UI\Presenter
{
use LanguageAware;
}
При создании ссылки значение постоянного параметра можно изменить:
<a n:href="Product:show $id, lang: cs">detail in Czech</a>
Как вариант, его можно сбросить, то есть убрать из URL. Тогда он примет значение по умолчанию:
<a n:href="Product:show $id, lang: null">click</a>
Общее пространство параметров
Параметры запроса, постоянные параметры и
параметры методов action, render и handle (сигналов) делят
единое пространство, где каждый определяется своим именем. Если одно и
то же имя встречается больше чем в одном из них, они относятся к одному
и тому же значению.
Этим часто пользуются. Например, постоянный параметр lang и
аргумент $lang метода действия или сигнала – одно и то же: вы
можете прочитать текущее значение постоянного параметра, просто
указав его в сигнатуре метода:
#[Persistent]
public string $lang;
public function handleSearch(string $query, string $lang): void
{
// $lang содержит текущее значение постоянного параметра lang
}
Поскольку это пространство общее, держите имена параметров уникальными, если только вы намеренно не хотите, чтобы они делили значение. Это относится и к сигналам, которые дополнительно читают параметры из тела POST-запроса, см. Сигналы в подробностях.
Интерактивные компоненты
В презентеры встроена система компонентов. Компоненты – самостоятельные переиспользуемые единицы, которые мы встраиваем в презентеры. Это могут быть формы, таблицы данных, меню – в общем, всё, что имеет смысл использовать повторно.
Как компоненты встраиваются в презентеры и затем используются? Вы узнаете это в главе Компоненты. Вы даже выясните, что у них общего с Голливудом.
А где взять компоненты? На Componette вы найдёте компоненты с открытым кодом и множество других дополнений для Nette, созданных добровольцами из сообщества фреймворка.
Погружаемся глубже
То, что мы разобрали в этой главе до сих пор, скорее всего, покроет большинство случаев. Следующие разделы предназначены тем, кто хочет погрузиться в презентеры глубже и знать совершенно всё.
Проверка параметров
Значения параметров запроса и постоянных параметров, полученные из URL,
записываются в свойства методом loadState(). Он также проверяет,
соответствуют ли они типу данных, указанному у свойства; иначе он
ответит ошибкой 404, и страница не отобразится.
Никогда не доверяйте параметрам из URL вслепую, потому что
пользователь легко может их переписать. Вот, например, как мы проверили
бы, входит ли язык $this->lang в число поддерживаемых. Подходящий
способ – переопределить упомянутый метод loadState():
class ProductPresenter extends Nette\Application\UI\Presenter
{
#[Persistent]
public string $lang;
public function loadState(array $params): void
{
parent::loadState($params); // здесь задаётся $this->lang
// далее идёт собственная проверка значения:
if (!in_array($this->lang, ['en', 'cs'])) {
$this->error();
}
}
}
Сохранение и восстановление запроса
Запрос, обрабатываемый презентером, – объект Nette\Application\Request,
возвращаемый методом презентера getRequest().
Текущий запрос можно сохранить в сессию или, наоборот, восстановить
из неё и дать презентеру выполнить его заново. Это полезно, например,
когда пользователь заполняет форму, а его сессия входа истекает. Чтобы
не потерять данные, перед перенаправлением на страницу входа мы
сохраняем текущий запрос в сессию через $reqId = $this->storeRequest(). Это
возвращает его идентификатор в виде короткой строки, которую мы затем
передаём параметром презентеру входа.
После входа мы вызываем метод $this->restoreRequest($reqId), который
достаёт запрос из сессии. POST-запросы перебрасываются в него, а
остальные (GET) перенаправляются на URL запроса. Метод проверяет, что
запрос был создан тем же пользователем, который сейчас вошёл. Если
войдёт другой пользователь или ключ недействителен, метод ничего не
делает, и программа продолжает работу как обычно.
См. руководство Как вернуться на предыдущую страницу.
Канонизация
У презентеров есть по-настоящему прекрасная возможность,
способствующая лучшему SEO (поисковой оптимизации). Они автоматически
предотвращают существование одинакового содержимого под разными URL.
Если к определённой цели ведут несколько URL, например /index и
/index?page=1, фреймворк объявляет один из них основным (каноническим)
и перенаправляет остальные на него HTTP-кодом 301. Благодаря этому
поисковые системы не индексируют ваши страницы дважды и не размывают
их вес.
Этот процесс называется канонизацией. Канонический URL – тот, который порождает маршрутизатор, обычно первый подходящий маршрут в наборе.
Канонизация включена по умолчанию и может быть отключена через
$this->autoCanonicalize = false.
Перенаправление не происходит при AJAX- или POST-запросах, потому что это могло бы привести к потере данных или не дало бы никакой пользы для SEO.
Вы можете вызвать канонизацию и вручную, методом canonicalize(). Как
и методу link(), вы передаёте ему презентер, действие и параметры.
Он порождает ссылку и сравнивает её с текущим URL-адресом. Если они
различаются, он перенаправляет на порождённую ссылку.
public function actionShow(int $id, ?string $slug = null): void
{
$realSlug = $this->facade->getSlugForId($id);
// перенаправляет, если $slug отличается от $realSlug
$this->canonicalize('Product:show', [$id, $realSlug]);
}
Полный пример, объединяющий фильтры маршрутов с canonicalize() ради
дружественных к SEO URL, см. в Красивые URL со
слагами.
Ответы
Ответ, возвращаемый презентером, – объект, реализующий интерфейс Nette\Application\Response. Доступно несколько готовых ответов:
- Nette\Application\Responses\CallbackResponse – отправляет callback
- Nette\Application\Responses\FileResponse – отправляет файл
- Nette\Application\Responses\ForwardResponse – forward()
- Nette\Application\Responses\JsonResponse – отправляет JSON
- Nette\Application\Responses\RedirectResponse – перенаправление
- Nette\Application\Responses\TextResponse – отправляет текст
- Nette\Application\Responses\VoidResponse – пустой ответ
Ответы отправляются методом sendResponse():
use Nette\Application\Responses;
// Обычный текст
$this->sendResponse(new Responses\TextResponse('Hello Nette!'));
// Отправляет файл
$this->sendResponse(new Responses\FileResponse(__DIR__ . '/invoice.pdf', 'Invoice13.pdf'));
// Отправляет callback
$callback = function (Nette\Http\IRequest $httpRequest, Nette\Http\IResponse $httpResponse) {
if ($httpResponse->getHeader('Content-Type') === 'text/html') {
echo '<h1>Hello</h1>';
}
};
$this->sendResponse(new Responses\CallbackResponse($callback));
Вы можете написать и собственный ответ. Достаточно реализовать
интерфейс Nette\Application\Response с единственным методом send(),
получающим HTTP-запрос и ответ. Это полезно, например, при потоковой
передаче данных, которые вы не хотите держать в памяти:
class CsvResponse implements Nette\Application\Response
{
public function __construct(
private string $fileName,
private iterable $rows,
) {
}
public function send(Nette\Http\IRequest $request, Nette\Http\IResponse $response): void
{
$response->setContentType('text/csv', 'utf-8');
$response->sendAsFile($this->fileName);
$handle = fopen('php://output', 'w');
foreach ($this->rows as $row) {
fputcsv($handle, $row);
}
fclose($handle);
}
}
Затем вы отправляете его в презентере как обычно:
$this->sendResponse(new CsvResponse('export.csv', $rows));
HTTP-кеширование
Метод lastModified() позволяет легко воспользоваться
HTTP-кешированием. Вы передаёте ему дату и время последнего изменения
содержимого (как временную метку, строку или объект DateTimeInterface), а
при желании ещё валидатор ETag (короткую строку, определяющую текущую
версию содержимого, например её хеш) и время истечения. Если у браузера
уже есть подходящая версия, презентер отправляет ответ
304 Not Modified и завершается, поэтому страница не отрисовывается и не
передаётся зря:
public function renderArticle(int $id): void
{
$article = $this->articles->getById($id);
$this->lastModified($article->updatedAt);
// ...
}
Завершение шаблона
Когда презентер отрисовывает шаблон, метод sendTemplate() прямо
перед отрисовкой вызывает completeTemplate(). Этот метод заполняет
переменные, помеченные атрибутом #[TemplateVariable], и находит файл
шаблона (переменные по умолчанию уже задаёт TemplateFactory при
создании шаблона). Вы можете переопределить этот защищённый метод,
чтобы добавить переменные, общие для всех представлений, или задать
другой файл:
protected function completeTemplate(Nette\Application\UI\Template $template): void
{
parent::completeTemplate($template);
$template->siteName = 'My App';
}
Ограничение доступа через
#[Requires]
Атрибут #[Requires] даёт продвинутые возможности ограничить
доступ к презентерам и их методам. Им можно задать HTTP-методы,
потребовать AJAX-запрос, ограничить тем же источником и разрешить доступ
только через переброску. Атрибут можно применять и к классам
презентеров, и к отдельным методам вроде action<Action>(),
render<View>(), handle<Signal>() и createComponent<Name>().
Вы можете задать такие ограничения:
- по HTTP-методам:
#[Requires(methods: ['GET', 'POST'])] - требование AJAX-запроса:
#[Requires(ajax: true)] - доступ только с того же источника:
#[Requires(sameOrigin: true)] - доступ только через переброску:
#[Requires(forward: true)] - ограничения для конкретных действий:
#[Requires(actions: 'default')]
Начиная с версии 3.3 совпадение источника проверяется по
заголовку браузера Sec-Fetch-Site (раньше через cookie SameSite), что надёжнее
и проверяет точное совпадение схемы, домена и порта.
Подробности в руководстве Как использовать атрибут Requires.
Проверка HTTP-метода
Презентеры в Nette автоматически проверяют HTTP-метод каждого входящего
запроса, прежде всего из соображений безопасности. По умолчанию
разрешены методы GET, POST, HEAD, PUT, DELETE,
PATCH.
Если вы хотите дополнительно разрешить, например, метод OPTIONS,
используйте атрибут #[Requires] (начиная с Nette Application v3.2.3):
#[Requires(methods: ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])]
class MyPresenter extends Nette\Application\UI\Presenter
{
}
Начиная с версии 3.1.13 проверка выполняется в checkHttpMethod(),
который проверяет, входит ли указанный в запросе метод в массив
$presenter->allowedMethods. Начиная с версии 3.2.3 этот подход объявлен
устаревшим в пользу #[Requires]. Переопределить метод можно так:
class MyPresenter extends Nette\Application\UI\Presenter
{
protected function checkHttpMethod(): void
{
$this->allowedMethods[] = 'OPTIONS';
parent::checkHttpMethod();
}
}
Важно подчеркнуть, что если вы разрешаете метод OPTIONS, вы должны
затем соответствующим образом обработать его в своём презентере. Этот
метод часто используется как так называемый предварительный (preflight)
запрос, который браузер автоматически отправляет перед настоящим
запросом, когда нужно определить, допустим ли запрос по политике CORS
(Cross-Origin Resource Sharing). Если вы разрешите метод, но не реализуете правильный
ответ, это может привести к несогласованности и возможным проблемам с
безопасностью.
Пометка устаревших действий
Атрибут #[Deprecated] помечает действия, сигналы или целые
презентеры как устаревшие и предназначенные к будущему удалению. При
порождении ссылок на устаревшие части приложения Nette выдаёт
предупреждение, чтобы обратить на это внимание разработчиков.
Атрибут можно применить как ко всему классу презентера, так и к
отдельным методам action<Action>(), render<View>() и
handle<Signal>().