Презентеры

Мы разберём, как в 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 или даже перенаправление на другую страницу.

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

Каждый из этих методов немедленно завершает презентер, выбрасывая исключение молчаливого завершения 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. Доступно несколько готовых ответов:

Ответы отправляются методом 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>().

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

версия: 4.x