Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Routování

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#

  1. Řádek do cms_system_routes s maskou, modulem, presenterem a akcí. Masku piš tak, jak má vypadat adresa: inzeraty/<kategorie>[/p-<page>].
  2. 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>/).
  3. SQL do docs/sql/ jako idempotentní skript — routa je konfigurace, ne migrace schématu, ale patří k nasazení.
  4. Smaž cache rout (nebo počkej na TTL) — jinak jede pořád starý snapshot.
  5. 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#

  1. Napiš překladač do app/Core/Routers/Translators/<Modul>/ — podědí AbstractTranslator a umí idToName(), případně obousměrně idToSlug()/slugToId().
  2. Zaregistruj ho v továrně routy: v konstruktoru $translator->addTargetFactory(self::class) a v create() ho zapoj přes addTranslation() nebo addDerivedParameter().
  3. 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:

  1. Podívej se na masku v databázi, ne do kódu. Devět z deseti překvapení je tam.
  2. 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á.
  3. 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.
  4. Smaž cache rout a zkus znovu — po zásahu do tabulky jede starý snapshot až do vypršení TTL.
  5. 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());
{plink ':Front:System:Page:homepage' (expand) $presenter->getDefaultUrlParams(), loc: $key}

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

Nastavení cookies