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

Рутерът отговаря за всичко около URL адресите, за да не се налага вие да мислите за тях. Ще покажем:

  • как да настроим рутера, така че URL адресите да са според представите ни
  • ще поговорим за SEO и пренасочване
  • и ще покажем как да напишем собствен рутер

По-човешките URL адреси (или също cool или pretty 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>]]]', /* ... */);

Заместващи знаци

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

  • %tld% = top level domain, напр. com или org
  • %sld% = second level domain, напр. 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 да бъдат представени с български думи (напр. /produkt/123 или /kosik), можем да използваме преводен речник. За неговия запис вече се нуждаем от “по-многословния” вариант на втория параметър:

use Nette\Routing\Route;

$router->addRoute('<presenter>/<action>', [
	'presenter' => [
		Route::Value => 'Home',
		Route::FilterTable => [
			// низ в URL => презентер
			'produkt' => 'Product',
			'kosik' => 'Cart',
			'katalog' => 'Catalog',
		],
	],
	'action' => [
		Route::Value => 'default',
		Route::FilterTable => [
			'seznam' => '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