Routování#
Jak se z adresy stane presenter a z parametrů zpátky hezká adresa. Jádro:
app/Core/Routers/.
Tahle kapitola je návod: v první polovině je, jak to funguje, ve druhé postupy pro tři nejčastější úkoly — přidat routu, přidat hezký slug a najít, proč odkaz vede jinam, než měl.
Dvě poloviny routeru#
| Část | Kde se definuje | Příklad |
|---|---|---|
| Statické sekce | v kódu, přes factory v DI | /admin/…, /api/…, /cron/…, /script/… |
| Dynamické routy frontu | v databázi (cms_system_routes) |
/inzeraty/<kategorie>, /clanky/<slug> |
Masky frontových rout NEJSOU v kódu
Tohle je první věc, o kterou se člověk uhodí. Když hledáte, proč má výpis
inzerátů zrovna takovou adresu, v app/ to nenajdete — je to řádek v databázi.
V kódu je jen továrna, která z masky postaví routu a doplní jí překlady.
Router z databáze nečte přímo: postaví se snapshot tabulky rout (RouteTableBuilder)
a ten se cachuje. Při zásahu do cache je sestavení routeru bez jediného SQL dotazu.
požadavek
│
▼
RouterDispatcher ──► statické sekce (factory z DI)
│
└─────────────► dynamické routy z RouteTableSnapshot
├─ cache-hit → 0 SQL
└─ miss/off → RouteTableBuilder → DB
Co dělá továrna routy#
Maska sama o sobě neumí hezké adresy — jen parametry. Továrna k ní přidá překlady:
$route = new RestrictedTranslatableRoute($mask, [
'module' => $module, 'presenter' => $presenter, 'action' => $action,
]);
// id stránky ⇄ slug v adrese
$route->addTranslation('slug',
fn(int $id, string $loc) => $this->pageSlugTranslator->idToSlug($id, $loc),
fn(string $slug, string $loc) => $this->pageSlugTranslator->slugToId($slug, $loc),
);
// `name` se v adrese objeví, ale odvozuje se z `id` — do odkazu se nepředává
$route->addDerivedParameter('name', 'id',
fn(int $id) => $this->advertNameTranslator->idToName($id),
);
| Prvek | K čemu je |
|---|---|
addTranslation |
převod parametru při skládání odkazu (id → slug); zpětný převod (slug → id) slouží jen kontrole whitelistu, presenter dostane z adresy slug |
addDerivedParameter |
parametr, který se do odkazu nepředává — dopočítá se z jiného (typicky název z id) |
applyWhitelist |
omezí routu na stránky, kterým patří |
Z adresy přijde slug, ne id
TranslatableRoute::match() parametry zpátky nepřekládá. RestrictedTranslatableRoute
si slug na id převede jen pro kontrolu whitelistu a dál pošle původní hodnotu, takže
getParameter('categoryId') vrátí 'automobily', ne 12. Presenter si záznam dohledá
sám: číslo přes find(), jinak podle translations.href (vzor CategoryTrait::initCurrentCategory(),
PageInitTrait::loadPageEntity()). Kdo slug z adresy předvyplní do selectu s volbami
podle id, shodí stránku na 500, viz Inzerce → Vyhledávací box.
Odvozený parametr = kratší volání odkazu
Šablona linkuje jen id; název inzerátu v adrese doplní router sám. Kdyby se
předával, musela by ho každá šablona odněkud vzít — a při přejmenování by se
rozešly.
Postup: přidávám novou frontovou routu#
- Řádek do
cms_system_routess maskou, modulem, presenterem a akcí. Masku piš tak, jak má vypadat adresa:inzeraty/<kategorie>[/p-<page>]. - Zvol továrnu. Když stačí prosté parametry, použij existující obecnou; když
má být v adrese slug nebo název, potřebuješ továrnu s překladem
(
app/Core/Routers/Factories/<Modul>/). - SQL do
docs/sql/jako idempotentní skript — routa je konfigurace, ne migrace schématu, ale patří k nasazení. - Smaž cache rout (nebo počkej na TTL) — jinak jede pořád starý snapshot.
- Prokliknij obojí směry: adresu zadanou ručně (čte se) i odkaz vygenerovaný šablonou (skládá se). Nestačí jedno.
Výchozí hodnota v masce se z adresy VYPUSTÍ
o-<order=0> znamená, že /o-0 v adrese nikdy neuvidíte — Nette segment
s výchozí hodnotou odstraní a příchozí /o-0 přesměruje (301) na tvar bez
něj. Je to užitečné (jde tím vyjádřit „výchozí řazení = relevance“), ale kdo to
neví, hledá chybu v presenteru.
Literál shodný s výchozí hodnotou routa spolkne
Když maska má <akce=detail> a někdo chce adresu …/detail, segment zmizí.
Řeší se to jinou výchozí hodnotou, ne jinou šablonou.
Postup: chci v adrese hezký název místo id#
- Napiš překladač do
app/Core/Routers/Translators/<Modul>/— podědíAbstractTranslatora umíidToName(), případně obousměrněidToSlug()/slugToId(). - Zaregistruj ho v továrně routy: v konstruktoru
$translator->addTargetFactory(self::class)a vcreate()ho zapoj přesaddTranslation()neboaddDerivedParameter(). - Ověř obě strany. Skládání odkazu (id → text) selže tiše — vznikne adresa s číslem místo názvu. Čtení (text → id) selže hlasitě, 404.
Překladač musí umět i neznámou hodnotu
Smazaný nebo přejmenovaný záznam znamená, že starý odkaz odněkud zvenčí dorazí
s hodnotou, kterou překladač nezná. Vrátit null je správně (skončí to 404);
spadnout na výjimce ne.
Název jako ozdoba vedle id: <name>-s<id>
Když má id v masce vlastní zachycení (detail sady [<loc>/]<slug>/<name>-s<id>),
stačí název jen skládat. Čtecí směr (addStringTranslation) vrací null a routa
se trefí podle id, takže starý nebo cizojazyčný název v adrese pořád vede na
správný záznam. Překladač pak nepotřebuje nameToId() ani obsluhu neznámého názvu.
Cache překladače drž podle jazyka
Mapa „id → název“ bez jazyka vrací název toho překladu, který se načetl poslední, takže
všechny jazyky odkazují stejným (třeba slovenským) slugem. Klíčuj ji
[languageId][id] a chybějící překlad ber přes
AbstractTranslator::fromCacheWithLanguageFallback().
Předehřátí překladače před linkováním
Komponenta, která skládá hodně odkazů, si překladač předehřeje — jinak se sáhne do databáze pro každý řádek výpisu zvlášť.
Postup: odkaz vede jinam, než jsem čekal#
Od nejlevnějšího:
- Podívej se na masku v databázi, ne do kódu. Devět z deseti překvapení je tam.
- Zkontroluj výchozí hodnoty v masce — segment s výchozí hodnotou se vypouští a příchozí adresa se na kanonický tvar přesměrovává.
- Ověř, jestli nepřebíjí jiná routa. Pořadí rozhoduje; routa s obecnější maskou a nižším pořadím spolkne adresu dřív, než se dostane na tu tvou.
- Smaž cache rout a zkus znovu — po zásahu do tabulky jede starý snapshot až do vypršení TTL.
- Teprve pak hledej v presenteru.
Osiřelá routa s obecnou maskou spolkne KAŽDOU adresu
Routa bez navázaných stránek dostane prázdný whitelist. Když má obecnou masku a nízké pořadí, chytí všechno na webu — a projeví se to jako „náhodně padají různé stránky", ne jako chyba routování.
Routa s neexistujícím presenterem tiše zmizí
RouteTableBuilder při sestavení snapshotu vynechá řádek, jehož třída
App\UI\Front\<Modul>\Presenters\<Presenter>Presenter neexistuje, a zapíše to jen
do log/info.log. Adresa pak skončí na jiné routě nebo 404. Když presenter přidáš až
po řádku v tabulce, smaž cache rout, jinak platí snapshot bez té routy.
Persistentní parametry se propisují do odkazů
Parametr označený jako persistentní se objeví i v odkazech, kde ho nikdo nechtěl. Vypadá to jako chyba routy, ale je to vlastnost presenteru.
Odkaz z komponenty, která se kreslí na cizích stránkách (karta, patička, přepínač jazyka),
proto skládej přes Front\BasePresenter::getDefaultUrlParams(): konstanta
DEFAULT_URL_PARAMS vynuluje stránkování, filtry, řazení, slug, id a další.
$presenter->link(':Front:System:Page:form', ['slug' => $pageId] + $presenter->getDefaultUrlParams());
Vlastní parametry patří vlevo od +: sjednocení polí drží hodnotu z levé strany a
DEFAULT_URL_PARAMS obsahuje 'slug' => null. Obrácené pořadí pošle prázdný slug a odkaz
skončí na „No route for …“. Nový #[Persistent] parametr frontu doplň i do
DEFAULT_URL_PARAMS, jinak začne prosakovat; hlídá to tests/Unit/Core/DefaultUrlParamsCoverageTest.php.
Překladače adres a jejich rychlost#
Překladače (app/Core/Routers/Translators/<Modul>/) mění id na text a zpět. Rychlé musí být,
protože se volají pro každý odkaz na stránce.
| Věc | Jak |
|---|---|
SlugHelper::webalize() |
náhrada Strings::webalize() se sdíleným transliterátorem a memoizací; 15 překladačů; výstup bit-identický |
SlugMapCache |
mapy id ⇄ slug kategorií v Redisu (Bazaar, Comcat, Blog, Diskuze, e-shop), viz Cache |
zástupný segment - |
znamená „žádná kategorie/region"; odpovídá se bez dotazu |
| nenalezený slug regionu | se pamatuje po dobu požadavku, další odkazy do databáze nejdou |
| warmup | warmupTranslatorFromEntities() předehřeje jen uzly, které dostanou odkaz — nikdy celý strom |
Překladač s neznámým slugem načte celou tabulku — jednou za požadavek
Translátory Blog\{Article,News,Event}NameTranslator,
Bazaar|Comcat\AdvertNameTranslator, Discussion\MessageSubjectTranslator a
System\PageSlugTranslator při neznámém slugu načtou jednou celou tabulku
(opakovaně ne, hlídá to warmedUp). Kandidát na SlugMapCache, až to měření ukáže.
SlugHelper je kopie algoritmu z vendoru
Z výstupu se skládají veřejné adresy, takže musí zůstat bit-identický se
Strings::webalize(). Po každé aktualizaci nette/utils spusť
tests/Unit/Utils/Helpers/SlugHelperTest.php; když se algoritmus ve vendoru změnil,
oprav kopii v SlugHelper, ne test. Jinak se adresy z SlugHelper rozejdou s těmi, které
staví Strings::webalize() jinde, a odkazy přestanou trefovat své stránky.
Jedinečné href stránky v jazyce#
PageSlugTranslator drží dvě mapy na jazyk: slugToId[jazyk][href] = pageId pro příchozí
adresu a idToSlug[jazyk][pageId] = href pro sestavení odkazu. Mapa slugů je jedna na jazyk
pro celý web, bez ohledu na modul a větev stromu stránek.
Dvě stránky se stejným href v jednom jazyce |
Následek |
|---|---|
| příchozí adresa | vyhraje stránka zapsaná do mapy později, druhá je z webu nedostupná |
| odkaz na kteroukoli z nich | obě dávají stejnou adresu, takže přesměrování po akci vede na cizí stránku |
| hlášení | žádné, chyba se nikde nezapíše |
Uložení stránky v administraci (PageFormTrait::validateHrefUniqueness()) proto kolizi
odmítne: projde jazyky formuláře, neprázdné href vyhledá mezi ostatními stránkami a chybu
napíše k poli href_<langId> i s názvem a id kolidující stránky. Prázdné href se nekontroluje,
stránka bez adresy do mapy nevstupuje.
Kontrola je jen ve formuláři
Stránku, kterou zakládá migrace nebo skript mimo administraci, nic nehlídá. Před vložením
řádku do cms_system_page_texts ověř, že href v tom jazyce nikdo jiný nemá; jinak
jedna ze stránek tiše zmizí z webu.
Povinné parametry masky a stránka v menu#
Položka menu odkazuje na stránku a stránka na routu; odkaz sestaví Link.latte. Menu ale dodá
jen slug (z href stránky v daném jazyce), nic jiného. RouteMaskHelper::requiredParams()
proto z masky vyčte parametry, které Route::constructUrl() odmítne vynechat:
| Zápis v masce | Povinný? |
|---|---|
<name>, <name [regex]> mimo [...] |
ano |
cokoli uvnitř [...] (i vnořeně) |
ne |
<name=default>, <name=default [regex]> |
ne — chybějící hodnotu nahradí výchozí |
Obsah regexu ({3,}, [a-z]) na povinnost vliv nemá; hranaté závorky uvnitř <...> se za
volitelný blok nepočítají. Rozhoduje živá Nette routa — RouteMaskHelperTest porovná parser
s constructUrl().
Uložení stránky v administraci (PageFormTrait) to používá: je-li stránka v menu a vybraná
routa má povinný parametr jiný než slug, formulář uložení odmítne a chybu napíše k poli
routy. Povinný slug vadí jen v jazycích, kde má stránka prázdné href (kontrolují se všechny
jazyky formuláře, ne jen aktivní, protože Link.latte čte href bez ohledu na active).
Kanonizace a jazyk#
| Věc | Chování |
|---|---|
| Adresa v jiném než kanonickém tvaru | 301 na kanonický (výchozí hodnoty vypuštěné) |
| Jazyk | LocaleResolver + mapa locale → jazyk ze snapshotu |
| Hledaný výraz | je v cestě (/clanky/s-<dotaz>), ne v query stringu |
Adresa bez jazykového prefixu = hlavní lokalizace#
Adresa bez prefixu jazyka patří hlavní lokalizaci webu (localizations.main), ne natvrdo
češtině. Ostatní jazyky mají prefix (/sk/…) a prefix hlavního jazyka se přesměruje 301 na
adresu bez něj.
| Část | Co dělá |
|---|---|
RouteTableBuilder |
přečte klíč hlavní lokalizace do RouteTableSnapshot::$defaultLocale; ten přežije cache. Starší snapshot z cache bez klíče se načte jako cs |
RouterDispatcher |
ve všech dynamických frontových maskách nahradí <loc=cs …> z databáze za <loc=hlavní …> a routě nastaví výchozí locale (TranslatableRoute::setDefaultLocale()). Je to jedno místo, továrny rout to neřeší |
perzistentní $loc presenteru |
nemá výchozí hodnotu ('', LocalizationInitTrait). Kdyby měl 'cs', Nette by ho z českých odkazů vynechal a routa by dosadila hlavní jazyk |
| statické routy | admin, Api a /script/… mají v masce dál cs, nejsou to veřejné adresy |
Hlavní lokalizaci měň přes aplikaci
Výchozí jazyk nese snapshot rout. Uložení lokalizace přes Api ho zneplatní (Localization
je mezi tagy RouteTableCache), ruční SQL ne: do vypršení routerTtl by adresy bez
prefixu dál patřily původnímu jazyku. Viz Cache rout.
?search=… router zahodí
Přesměruje dřív, než se dostane k presenteru. Kdo přidává hledání, musí ho dát do masky — jinak parametr zmizí a nikde se to nezaloguje.
Kanonický host a robots.txt#
Adresa www.<host> se přesměruje na <host> a doslovný prefix /www/… na /… (301, v
www/.htaccess). /robots.txt generuje presenter Seo:Robots:default (routa v
RouterDispatcher). Podrobně v kapitole
Roboti, robots.txt a sitemapy.
Cache rout#
Vlastní vypínač routerEnabled v config/Shared/cache.neon, nezávislý na hlavním
vypínači cache. TTL routerTtl (výchozí 300 s) s rozptylem ±10 %.
TTL je strop staleness, ne řešení
Zápis do tabulky rout mimo aplikaci (ruční SQL) snapshotem nehne. Cache proto drží nanejvýš do vypršení TTL — a po ruční změně je čistší ji smazat, než čekat.
Kam sáhnout#
| Chci | Kde |
|---|---|
| změnit tvar adresy | řádek v cms_system_routes |
| přidat překlad id ⇄ text | app/Core/Routers/Translators/<Modul>/ |
| zapojit překlad do routy | app/Core/Routers/Factories/<Modul>/ |
| statické sekce (admin/api/cron) | app/Core/Routers/Factories/*RouteFactory.php |
| chování cache rout | config/Shared/cache.neon → routerEnabled, routerTtl |