Маршрутизация

Маршрутизатор отвечает за все, что связано с URL-адресами, чтобы вам больше не приходилось о них думать. Мы покажем:

  • как настроить маршрутизатор, чтобы URL были такими, как вы хотите
  • расскажем о SEO и перенаправлении
  • и покажем, как написать собственный маршрутизатор

Более человечные URL (или также крутые или красивые URL) более удобны в использовании, запоминаемы и положительно влияют на SEO. Nette учитывает это и полностью идет навстречу разработчикам. Вы можете спроектировать для своего приложения именно такую структуру URL-адресов, какую захотите. Вы можете спроектировать ее даже тогда, когда приложение уже готово, потому что это не потребует изменений в коде или шаблонах. Она определяется элегантным способом в одном единственном месте, в маршрутизаторе, и таким образом не разбросана в виде аннотаций во всех презентерах.

Маршрутизатор в Nette уникален тем, что он двусторонний. Он умеет как декодировать URL в HTTP-запросе, так и создавать ссылки. Таким образом, он играет ключевую роль в Nette Application, поскольку не только решает, какой презентер и действие будут выполнять текущий запрос, но также используется для генерации URL в шаблоне и т. д.

Однако маршрутизатор не ограничен только этим использованием, вы можете использовать его в приложениях, где презентеры вообще не используются, для REST API и т. д. Подробнее в разделе Самостоятельное использование.

Коллекция маршрутов

Самый приятный способ определения вида URL-адресов в приложении предлагает класс Nette\Application\Routers\RouteList. Определение состоит из списка так называемых маршрутов, то есть масок URL-адресов и связанных с ними презентеров и действий, с помощью простого API. Маршруты не нужно никак именовать.

$router = new Nette\Application\Routers\RouteList;
$router->addRoute('rss.xml', 'Feed:rss');
$router->addRoute('article/<id>', 'Article:view');
// ...

Пример говорит, что если в браузере открыть https://domain.com/rss.xml, отобразится презентер Feed с действием rss, если https://domain.com/article/12, отобразится презентер Article с действием view и т. д. В случае ненахождения подходящего маршрута Nette Application реагирует выбрасыванием исключения BadRequestException, которое отображается пользователю как страница ошибки 404 Not Found.

Порядок маршрутов

Ключевым является порядок, в котором перечислены отдельные маршруты, поскольку они оцениваются последовательно сверху вниз. Действует правило, что маршруты объявляются от специфических к общим:

// НЕПРАВИЛЬНО: 'rss.xml' перехватит первый маршрут и поймет эту строку как <slug>
$router->addRoute('<slug>', 'Article:view');
$router->addRoute('rss.xml', 'Feed:rss');

// ПРАВИЛЬНО
$router->addRoute('rss.xml', 'Feed:rss');
$router->addRoute('<slug>', 'Article:view');

Маршруты оцениваются сверху вниз также при генерации ссылок:

// НЕПРАВИЛЬНО: ссылка на 'Feed:rss' сгенерируется как 'admin/feed/rss'
$router->addRoute('admin/<presenter>/<action>', 'Admin:default');
$router->addRoute('rss.xml', 'Feed:rss');

// ПРАВИЛЬНО
$router->addRoute('rss.xml', 'Feed:rss');
$router->addRoute('admin/<presenter>/<action>', 'Admin:default');

Мы не будем скрывать от вас, что правильное составление маршрутов требует определенного навыка. Пока вы не освоите его, полезным помощником будет панель маршрутизации.

Маска и параметры

Маска описывает относительный путь от корневого каталога сайта. Самой простой маской является статический URL:

$router->addRoute('products', 'Products:default');

Часто маски содержат так называемые параметры. Они указываются в угловых скобках (например, <year>) и передаются в целевой презентер, например, методу renderShow(int $year) или в персистентный параметр $year:

$router->addRoute('chronicle/<year>', 'History:show');

Пример говорит, что если в браузере открыть https://example.com/chronicle/2020, отобразится презентер History с действием show и параметром year: 2020.

Параметрам можно определить значение по умолчанию прямо в маске, и тем самым они станут необязательными:

$router->addRoute('chronicle/<year=2020>', 'History:show');

Маршрут теперь будет принимать и URL https://example.com/chronicle/, который снова отобразит History:show с параметром year: 2020.

Параметром может быть, конечно, и имя презентера и действия. Например, так:

$router->addRoute('<presenter>/<action>', 'Home:default');

Указанный маршрут принимает, например, URL вида /article/edit или /catalog/list и понимает их как презентеры и действия Article:edit и Catalog:list.

Одновременно он дает параметрам presenter и action значения по умолчанию Home и default, и они, следовательно, также необязательны. Так что маршрут принимает и URL вида /article и понимает его как Article:default. Или наоборот, ссылка на Product:default сгенерирует путь /product, ссылка на стандартный Home:default путь /.

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

// относительно document root
$router->addRoute('<presenter>/<action>', /* ... */);

// абсолютный путь (относительно домена)
$router->addRoute('/<presenter>/<action>', /* ... */);

// абсолютный URL включая домен (относительно схемы)
$router->addRoute('//<lang>.example.com/<presenter>/<action>', /* ... */);

// абсолютный URL включая схему
$router->addRoute('https://<lang>.example.com/<presenter>/<action>', /* ... */);

Выражения валидации

Для каждого параметра можно установить условие валидации с помощью регулярного выражения. Например, для параметра id мы укажем, что он может принимать только цифры с помощью регулярного выражения \d+:

$router->addRoute('<presenter>/<action>[/<id \d+>]', /* ... */);

Стандартным регулярным выражением для всех параметров является [^/]+, т. е. все, кроме слеша. Если параметр должен принимать и слеши, укажем выражение .+:

// принимает https://example.com/a/b/c, path будет 'a/b/c'
$router->addRoute('<path .+>', /* ... */);

Необязательные последовательности

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

$router->addRoute('[<lang [a-z]{2}>/]<name>', /* ... */);

// Принимает пути:
//    /cs/download  => lang => cs, name => download
//    /download     => lang => null, name => download

Когда параметр является частью необязательной последовательности, он, разумеется, также становится необязательным. Если у него нет указанного значения по умолчанию, то он будет null.

Необязательные части могут быть и в домене:

$router->addRoute('//[<lang=en>.]example.com/<presenter>/<action>', /* ... */);

Последовательности можно произвольно вкладывать и комбинировать:

$router->addRoute(
	'[<lang [a-z]{2}>[-<sublang>]/]<name>[/page-<page=0>]',
	'Home:default',
);

// Принимает пути:
// 	/cs/hello
// 	/en-us/hello
// 	/hello
// 	/hello/page-12

При генерации URL стремимся к кратчайшему варианту, поэтому все, что можно опустить, опускается. Поэтому, например, маршрут index[.html] генерирует путь /index. Изменить поведение можно, указав восклицательный знак после левой квадратной скобки:

// принимает /hello и /hello.html, генерирует /hello
$router->addRoute('<name>[.html]', /* ... */);

// принимает /hello и /hello.html, генерирует /hello.html
$router->addRoute('<name>[!.html]', /* ... */);

Необязательные параметры (т. е. параметры, имеющие значение по умолчанию) без квадратных скобок ведут себя по сути так, как если бы они были заключены в скобки следующим образом:

$router->addRoute('<presenter=Home>/<action=default>/<id=>', /* ... */);

// соответствует этому:
$router->addRoute('[<presenter=Home>/[<action=default>/[<id>]]]', /* ... */);

Если мы хотим повлиять на поведение конечного слеша, чтобы, например, вместо /home/ генерировалось только /home, этого можно достичь так:

$router->addRoute('[<presenter=Home>[/<action=default>[/<id>]]]', /* ... */);

Подстановочные знаки

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

  • %tld% = домен верхнего уровня, например com или org
  • %sld% = домен второго уровня, например example
  • %domain% = домен без поддоменов, например example.com
  • %host% = весь хост, например www.example.com
  • %basePath% = путь к корневому каталогу
$router->addRoute('//www.%domain%/%basePath%/<presenter>/<action>', /* ... */);
$router->addRoute('//www.%sld%.%tld%/%basePath%/<presenter>/<action', /* ... */);

Расширенная запись

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

$router->addRoute('<presenter>/<action>[/<id \d+>]', [
	'presenter' => 'Home',
	'action' => 'default',
]);

Для более детальной спецификации можно использовать еще более расширенную форму, где кроме значений по умолчанию можно настроить и другие свойства параметров, например, валидационное регулярное выражение (см. параметр id):

use Nette\Routing\Route;

$router->addRoute('<presenter>/<action>[/<id>]', [
	'presenter' => [
		Route::Value => 'Home',
	],
	'action' => [
		Route::Value => 'default',
	],
	'id' => [
		Route::Pattern => '\d+',
	],
]);

Важно отметить, что если параметры, определенные в массиве, не указаны в маске пути, их значения нельзя изменить, даже с помощью query-параметров, указанных после вопросительного знака в URL.

Фильтры и переводы

Исходные коды приложения мы пишем на английском языке, но если сайт должен иметь русские URL, то простая маршрутизация типа:

$router->addRoute('<presenter>/<action>', 'Home:default');

будет генерировать английские URL, например /product/123 или /cart. Если мы хотим, чтобы презентеры и действия в URL были представлены русскими словами (например, /продукт/123 или /корзина), мы можем использовать словарь перевода. Для его записи уже нужна “более многословная” версия второго параметра:

use Nette\Routing\Route;

$router->addRoute('<presenter>/<action>', [
	'presenter' => [
		Route::Value => 'Home',
		Route::FilterTable => [
			// строка в URL => презентер
			'produkt' => 'Product',
			'korzina' => 'Cart',
			'katalog' => 'Catalog',
		],
	],
	'action' => [
		Route::Value => 'default',
		Route::FilterTable => [
			'spisok' => 'list',
		],
	],
]);

Несколько ключей словаря перевода могут вести на один и тот же презентер. Таким образом, к нему создаются различные псевдонимы. Каноническим вариантом (то есть тем, который будет в сгенерированном URL) считается последний ключ.

Таблицу перевода можно таким образом использовать для любого параметра. При этом, если перевод не существует, берется исходное значение. Это поведение можно изменить, добавив Route::FilterStrict => true, и маршрут тогда отклонит URL, если значение отсутствует в словаре.

Кроме словаря перевода в виде массива, можно применить и собственные функции перевода.

use Nette\Routing\Route;

$router->addRoute('<presenter>/<action>/<id>', [
	'presenter' => [
		Route::Value => 'Home',
		Route::FilterIn => function (string $s): string { /* ... */ },
		Route::FilterOut => function (string $s): string { /* ... */ },
	],
	'action' => 'default',
	'id' => null,
]);

Функция Route::FilterIn преобразует параметр в URL в строку, которая затем передается в презентер, функция FilterOut обеспечивает преобразование в обратном направлении.

Параметры presenter, action и module уже имеют предопределенные фильтры, которые преобразуют между стилем PascalCase или camelCase и kebab-case, используемым в URL. Значение по умолчанию параметров записывается уже в преобразованном виде, поэтому, например, в случае презентера пишем <presenter=ProductEdit>, а не <presenter=product-edit>.

Общие фильтры

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

use Nette\Routing\Route;

$router->addRoute('<presenter>/<action>', [
	'presenter' => 'Home',
	'action' => 'default',
	null => [
		Route::FilterIn => function (array $params): array { /* ... */ },
		Route::FilterOut => function (array $params): array { /* ... */ },
	],
]);

Общие фильтры дают возможность настроить поведение маршрута абсолютно любым способом. Мы можем использовать их, например, для модификации параметров на основе других параметров. Например, перевод <presenter> и <action> на основе текущего значения параметра <lang>.

Если у параметра определен собственный фильтр и одновременно существует общий фильтр, выполняется собственный FilterIn перед общим и, наоборот, общий FilterOut перед собственным. То есть внутри общего фильтра значения параметров presenter или action записаны в стиле PascalCase или camelCase.

Односторонние маршруты OneWay

Односторонние маршруты используются для сохранения функциональности старых URL, которые приложение уже не генерирует, но все еще принимает. Мы помечаем их флагом OneWay:

// старый URL /product-info?id=123
$router->addRoute('product-info', 'Product:detail', $router::ONE_WAY);
// новый URL /product/123
$router->addRoute('product/<id>', 'Product:detail');

При доступе к старому URL презентер автоматически перенаправляет на новый URL, так что поисковые системы не проиндексируют эти страницы дважды (см. SEO и канонизация).

Динамическая маршрутизация с callback-функциями

Динамическая маршрутизация с callback-функциями позволяет вам напрямую назначать маршрутам функции (callback), которые будут выполнены при посещении данного пути. Эта гибкая функциональность позволяет быстро и эффективно создавать различные конечные точки (endpoints) для вашего приложения:

$router->addRoute('test', function () {
	echo 'вы находитесь по адресу /test';
});

Вы также можете определить в маске параметры, которые автоматически передадутся в ваш callback:

$router->addRoute('<lang cs|en>', function (string $lang) {
	echo match ($lang) {
		'cs' => 'Добро пожаловать на чешскую версию нашего сайта!',
		'en' => 'Welcome to the English version of our website!',
	};
});

Модули

Если у нас есть несколько маршрутов, относящихся к общему модулю, мы используем withModule():

$router = new RouteList;
$router->withModule('Forum') // следующие маршруты являются частью модуля Forum
	->addRoute('rss', 'Feed:rss') // презентер будет Forum:Feed
	->addRoute('<presenter>/<action>')

	->withModule('Admin') // следующие маршруты являются частью модуля Forum:Admin
		->addRoute('sign:in', 'Sign:in');

Альтернативой является использование параметра module:

// URL manage/dashboard/default отображается на презентер Admin:Dashboard
$router->addRoute('manage/<presenter>/<action>', [
	'module' => 'Admin',
]);

Поддомены

Коллекции маршрутов можно разделять по поддоменам:

$router = new RouteList;
$router->withDomain('example.com')
	->addRoute('rss', 'Feed:rss')
	->addRoute('<presenter>/<action>');

В имени домена можно использовать и Подстановочные знаки:

$router = new RouteList;
$router->withDomain('example.%tld%')
	// ...

Префикс пути

Коллекции маршрутов можно разделять по пути в URL:

$router = new RouteList;
$router->withPath('eshop')
	->addRoute('rss', 'Feed:rss') // ловит URL /eshop/rss
	->addRoute('<presenter>/<action>'); // ловит URL /eshop/<presenter>/<action>

Комбинации

Вышеупомянутые разделения можно взаимно комбинировать:

$router = (new RouteList)
	->withDomain('admin.example.com')
		->withModule('Admin')
			->addRoute(/* ... */)
			->addRoute(/* ... */)
		->end()
		->withModule('Images')
			->addRoute(/* ... */)
		->end()
	->end()
	->withDomain('example.com')
		->withPath('export')
			->addRoute(/* ... */)
			// ...

Query-параметры

Маски могут также содержать query-параметры (параметры после вопросительного знака в URL). Для них нельзя определить валидационное выражение, но можно изменить имя, под которым они передадутся в презентер:

// query-параметр 'cat' мы хотим использовать в приложении под именем 'categoryId'
$router->addRoute('product ? id=<productId> & cat=<categoryId>', /* ... */);

Foo-параметры

Теперь мы углубляемся. Foo-параметры — это, по сути, безымянные параметры, которые позволяют сопоставлять регулярное выражение. Примером является маршрут, принимающий /index, /index.html, /index.htm и /index.php:

$router->addRoute('index<? \.html?|\.php|>', /* ... */);

Можно также явно определить строку, которая будет использоваться при генерации URL. Строка должна быть размещена непосредственно за вопросительным знаком. Следующий маршрут похож на предыдущий, но генерирует /index.html вместо /index, потому что строка .html установлена как генерируемое значение:

$router->addRoute('index<?.html \.html?|\.php|>', /* ... */);

Включение в приложение

Чтобы подключить созданный маршрутизатор к приложению, мы должны сообщить о нем DI-контейнеру. Самый простой способ — подготовить фабрику, которая создаст объект маршрутизатора, и сообщить в конфигурации контейнера, что ее нужно использовать. Допустим, для этой цели мы напишем метод App\Core\RouterFactory::createRouter():

namespace App\Core;

use Nette\Application\Routers\RouteList;

class RouterFactory
{
	public static function createRouter(): RouteList
	{
		$router = new RouteList;
		$router->addRoute(/* ... */);
		return $router;
	}
}

В конфигурацию затем запишем:

services:
	- App\Core\RouterFactory::createRouter

Любые зависимости, например, от базы данных и т. д., передаются фабричному методу как его параметры с помощью autowiring:

public static function createRouter(Nette\Database\Connection $db): RouteList
{
	// ...
}

SimpleRouter

Гораздо более простым маршрутизатором, чем коллекция маршрутов, является SimpleRouter. Мы используем его тогда, когда у нас нет особых требований к форме URL, если недоступен mod_rewrite (или его альтернативы) или если мы пока не хотим заниматься красивыми URL.

Генерирует адреса примерно в таком виде:

http://example.com/?presenter=Product&action=detail&id=123

Параметром конструктора SimpleRouter является стандартный презентер и действие, на который нужно направлять, если мы открываем страницу без параметров, например, http://example.com/.

// стандартным презентером будет 'Home' и действие 'default'
$router = new Nette\Application\Routers\SimpleRouter('Home:default');

Рекомендуем SimpleRouter напрямую определить в конфигурации:

services:
	- Nette\Application\Routers\SimpleRouter('Home:default')

SEO и канонизация

Фреймворк способствует SEO (оптимизации для поисковых систем), предотвращая дублирование контента на разных URL. Если к определенной цели ведет несколько адресов, например, /index и /index.html, фреймворк определяет первый из них как основной (канонический) и остальные перенаправляет на него с помощью HTTP-кода 301. Благодаря этому поисковые системы не индексируют страницы дважды и не размывают их page rank.

Этот процесс называется канонизацией. Каноническим URL является тот, который генерирует маршрутизатор, то есть первый подходящий маршрут в коллекции без флага OneWay. Поэтому в коллекции мы указываем основные маршруты первыми.

Канонизацию выполняет презентер, подробнее в главе канонизация.

HTTPS

Чтобы использовать протокол HTTPS, необходимо включить его на хостинге и правильно настроить сервер.

Перенаправление всего сайта на HTTPS необходимо настроить на уровне сервера, например, с помощью файла .htaccess в корневом каталоге нашего приложения, и это с HTTP-кодом 301. Настройки могут отличаться в зависимости от хостинга и выглядят примерно так:

<IfModule mod_rewrite.c>
	RewriteEngine On
	...
	RewriteCond %{HTTPS} off
	RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
	...
</IfModule>

Маршрутизатор генерирует URL с тем же протоколом, с которым была загружена страница, так что больше ничего настраивать не нужно.

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

// Будет генерировать адрес с HTTP
$router->addRoute('http://%host%/<presenter>/<action>', /* ... */);

// Будет генерировать адрес с HTTPS
$router->addRoute('https://%host%/<presenter>/<action>', /* ... */);

Отладка маршрутизатора

Панель маршрутизации, отображаемая в Tracy Bar, является полезным помощником, который показывает список маршрутов, а также параметры, которые маршрутизатор получил из URL.

Зеленая полоса с символом ✓ представляет маршрут, который обработал текущий URL, синим цветом и символом ≈ обозначены маршруты, которые также обработали бы URL, если бы зеленый их не опередил. Далее мы видим текущий презентер и действие.

Одновременно, если происходит неожиданное перенаправление из-за канонизации, полезно посмотреть в панель в строке redirect, где вы узнаете, как маршрутизатор изначально понял URL и почему перенаправил.

При отладке маршрутизатора рекомендуем открыть в браузере Developer Tools (Ctrl+Shift+I или Cmd+Option+I) и в панели Network отключить кеш, чтобы в него не сохранялись перенаправления.

Производительность

Количество маршрутов влияет на скорость маршрутизатора. Их число определенно не должно превышать нескольких десятков. Если у вашего сайта слишком сложная структура URL, вы можете написать собственный Собственный маршрутизатор под свои нужды.

Если маршрутизатор не имеет зависимостей, например, от базы данных, и его фабрика не принимает никаких аргументов, мы можем его скомпилированную форму сериализовать прямо в DI-контейнер и тем самым немного ускорить приложение.

routing:
	cache: true

Собственный маршрутизатор

Следующие строки предназначены для очень продвинутых пользователей. Вы можете создать собственный маршрутизатор и совершенно естественно включить его в коллекцию маршрутов. Маршрутизатор — это реализация интерфейса Nette\Routing\Router с двумя методами:

use Nette\Http\IRequest as HttpRequest;
use Nette\Http\UrlScript;

class MyRouter implements Nette\Routing\Router
{
	public function match(HttpRequest $httpRequest): ?array
	{
		// ...
	}

	public function constructUrl(array $params, UrlScript $refUrl): ?string
	{
		// ...
	}
}

Метод match обрабатывает текущий запрос $httpRequest, из которого можно получить не только URL, но и заголовки и т. д., в массив, содержащий имя презентера и его параметры. Если он не может обработать запрос, возвращает null. При обработке запроса мы должны вернуть как минимум презентер и действие. Имя презентера является полным и содержит также возможные модули:

[
	'presenter' => 'Front:Home',
	'action' => 'default',
]

Метод constructUrl, наоборот, составляет из массива параметров итоговый абсолютный URL. Для этого он может использовать информацию из параметра $refUrl, который является текущим URL.

В коллекцию маршрутов его добавите с помощью add():

$router = new Nette\Application\Routers\RouteList;
$router->add($myRouter);
$router->addRoute(/* ... */);
// ...

Самостоятельное использование

Под самостоятельным использованием мы подразумеваем использование возможностей маршрутизатора в приложении, которое не использует Nette Application и презентеры. Для него действует почти все, что мы показали в этой главе, со следующими отличиями:

Итак, мы снова создаем метод, который нам составит маршрутизатор, например:

namespace App\Core;

use Nette\Routing\RouteList;

class RouterFactory
{
	public static function createRouter(): RouteList
	{
		$router = new RouteList;
		$router->addRoute('rss.xml', [
			'controller' => 'RssFeedController',
		]);
		$router->addRoute('article/<id \d+>', [
			'controller' => 'ArticleController',
		]);
		// ...
		return $router;
	}
}

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

$router = $container->getByType(Nette\Routing\Router::class);
$httpRequest = $container->getByType(Nette\Http\IRequest::class);

Или объекты создаем напрямую:

$router = App\Core\RouterFactory::createRouter();
$httpRequest = (new Nette\Http\RequestFactory)->fromGlobals();

Теперь остается только запустить маршрутизатор в работу:

$params = $router->match($httpRequest);
if ($params === null) {
	// не найден подходящий маршрут, отправляем ошибку 404
	exit;
}

// обрабатываем полученные параметры
$controller = $params['controller'];
// ...

И наоборот, используем маршрутизатор для составления ссылки:

$params = ['controller' => 'ArticleController', 'id' => 123];
$url = $router->constructUrl($params, $httpRequest->getUrl());
версия: 4.0