Данная статья достаточно подробно описывает устройство работы маршрутизации, примеры использования, а так же примеры того, как можно в этот механизм вмешаться, либо, при желании, полностью его заменить его на собственный.
Основное отличие
Главное отличие маршрутизации от реализаций в популярных фреймворках типа Symfony, Laravel или Yii это декларативность вместо императивности.
Это значит, что вместо того, чтобы указывать маршруты в определённом формате и сопоставлять маршруту определённый класс, метод или замыкание, мы всего лишь описываем структуру маршрутов, и этой структуры достаточно для того, чтобы понять какой код будет выполнен в зависимости от маршрута.
Подобный подход конвенций вместо конфигураций удобен в том смысле, что требует меньше усилий во время написания кода, и не требует просмотра конфигурации для того, чтобы понять, какой код будет вызван при открытии определённой страницы, так как это очевидно из соглашения, принятого во фрейворке.
Основы маршрутизации
Любой URL в представлении фреймворка разбивается на несколько частей. В самом начале до какой-либо обработки из пути страницы удаляются параметры запроса (
?
и всё что после него).Далее мы получаем общий формат пути следующего вида (
|
используется для разделения выбора из нескольких вариантов, в []
сгруппированы необязательные самостоятельные компоненты пути), пример разбит на несколько строчек для удобства, перед обработкой путь разбивается по слэшах и превращается в массив из частей исходного пути:
[language/]
[admin/|api/|cli/]
[Module_name
[/path
[/sub_path
[/id1
[/another_subpath
[/id2]
]
]
]
]
]
Количество уровней вложенности не ограничено.
Первым делом проверяется префикс языка. Он не участвует в маршрутизации (и может отсутствовать), но при наличии влияет на то, какой язык будет использоваться на странице. Формат зависит от используемых языков и их количества, может бы простым (
en
, ru
), либо учитывать регион (en_gb
, ru_ua
).После языка следует необязательная часть, определяющая тип страницы. Это может быть страница администрирования (
$Request->admin_path === true
), запрос к API ($Request->api_path === true
), запрос к CLI интерфейсу ($Request->cli_path === true
) или обычная пользовательская страница если не указано явно.Далее определяется модуль, который будет обрабатывать страницу. В последствии этот модуль доступен как
$Request->current_module
.Стоит заметить, что название модуля может быть локализовано, к примеру, если для модуля
My_blog
в переводах есть пара "My_blog" : "Мой блог"
, то можно в качестве названия модуля использовать Мой_блог
, при этом всё равно $Request->current_module === 'My_blog'
.Остаток элементов массива после модуля попадает в
$Request->route
, который может использоваться модулями, к примеру, для кастомной маршрутизации.Перед тем, как перейти к следующим этапам, заполняются ещё 2 массива.
$Request->route_ids
содержит элементы из $Request->route
, которые являются целыми числами (подразумевается что это идентификаторы), $Request->route_path
же содержит все элементы $Request->route
кроме целых чисел, и используется как маршрут внутри модуля.Как вклиниться в маршрутизацию на ранних этапах
У разработчика есть в распоряжении ряд событий, которые позволяют вклиниться уже на данных этапах и изменить поведение по собственному усмотрению.
Событие
System/Request/routing_replace/before
срабатывает сразу перед определением языка страницы и позволяет как-то модифицировать исходный путь в виде строки, самые низкоуровневые манипуляции можно проводит в этом месте.Событие
System/Request/routing_replace/after
срабатывает после формирования $Request->route_ids
и $Request->route_path
, позволяя откорректировать важные параметры после того, как они были определены системой.Пример добавления поддержки UUID как альтернативы стандартным целочисленным идентификаторам:
Event::instance()->on(
'System/Request/routing_replace/after',
function ($data) {
$route_path = [];
$route_ids = [];
foreach ($data['route'] as $item) {
if (preg_match('/([a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}?)/i', $item)) {
$route_ids[] = $item;
} else {
$route_path[] = $item;
}
}
if ($route_ids) {
$data['route_path'] = $route_path;
$data['route_ids'] = $route_ids;
}
}
);
Структура маршрутов
Структура маршрутов являет собой древовидный JSON, в котором ключ каждого дочернего уровня является продолжением родительского, некоторые окончательные узлы могут быть пустыми, если соседние имеют более глубокую структуру.
Пример текущей структуры API системного модуля:
{
"admin" : {
"about_server" : [],
"blocks" : [],
"databases" : [],
"groups" : [
"_",
"permissions"
],
"languages" : [],
"mail" : [],
"modules" : [],
"optimization" : [],
"permissions" : [
"_",
"for_item"
],
"security" : [],
"site_info" : [],
"storages" : [],
"system" : [],
"themes" : [],
"upload" : [],
"users" : [
"_",
"general",
"groups",
"permissions"
]
},
"blank" : [],
"languages" : [],
"profile" : [],
"profiles" : [],
"timezones" : []
}
Примеры (реальные) запросов, подходящих под данную структуру:
GET api/System/blank
GET api/System/admin/about_server
SEARCH_OPTIONS api/System/admin/users
SEARCH api/System/admin/users
PATCH api/System/admin/users/42
GET api/System/admin/users/42/groups
PUT api/System/admin/users/42/permissions
Получение окончательного маршрута
Получение маршрута из пути страницы это только первый из двух этапов. Второй этап учитывает конфигурацию текущего модуля и корректирует финальный маршрут соответственно.
Для чего это нужно? Допустим, пользователь открывает страницу
/Blogs
, а структура маршрутов сконфигурирована следующим образом (modules/Blogs/index.json
):[
"latest_posts",
"section",
"post",
"tag",
"new_post",
"edit_post",
"drafts",
"atom.xml"
]
В этом случае
$Request->route_path === []
, но $App->controller_path === ['index', 'latest_posts']
.index
будет здесь вне зависимости от модуля и конфигурации, а вот latest_posts
уже зависит от конфигурации. Дело в том, что если страница не API и не CLI запрос, то при указании неполного маршрута фреймворк будет выбирать первый ключ из конфигурации на каждом уровне, пока не дойдет до конца вглубь структуры. То есть Blogs
аналогично Blogs/latest_posts
.Для API и CLI запросов в этом смысле есть отличие — опускание частей маршрута подобным образом запрещено и допускается только если в структуре в качестве первого элемента на соответствующем уровне используется
_
.К примеру, для API мы можем иметь следующую структуру (
modules/Module_name/api/index.json
):{
"_" : []
"comments" : []
}
В этом случае
api/Module_name
аналогично api/Module_name/_
. Это позволяет делать API с красивыми методами (помним, что идентификаторы у нас в отдельном массиве):
GET api/Module_name
GET api/Module_name/42
POST api/Module_name
PUT api/Module_name/42
DELETE api/Module_name/42
GET api/Module_name/42/comments
GET api/Module_name/42/comments/13
POST api/Module_name/42/comments
PUT api/Module_name/42/comments/13
DELETE api/Module_name/42/comments/13
Расположение файлов со структурой маршрутов
Модули в CleverStyle Framework хранят всё своё внутри папки модуля (в противовес фреймворкам, где все view в одной папке, все контроллеры в другой, все модели в третьей, все маршруты в одном файле и так далее) для удобства сопровождения.
В зависимости от типа запроса используются разные конфиги в формате JSON:
- для обычных страниц
modules/Module_name/index.json
- для страниц администрирования
modules/Module_name/admin/index.json
- для API
modules/Module_name/api/index.json
- для CLI
modules/Module_name/cli/index.json
В тех же папках находятся и обработчики маршрутов.
Типы маршрутизации
В CleverStyle Framework есть два типа маршрутизации: основанный на файлах (активно использовался ранее) и основанный на контроллере (более активно используется сейчас).
Возьмем из примера выше страницу
Blogs/latest_posts
и окончательный маршрут ['index', 'latest_posts']
.В случае с маршрутизацией основанной на файлах, следующие файлы будут подключены в указанном порядке:
modules/Blogs/index.php
modules/Blogs/latest_posts.php
Если же используется маршрутизация, основанная на контроллере, то должен существовать класс
cs\modules\Blogs\Controller
(файл modules/Blogs/Controller.php
) со следующими публичными статическими методами:
cs\modules\Blogs\Controller::index($Request, $Response) : mixed
cs\modules\Blogs\Controller::latest_posts($Request, $Response) : mixed
Важно, что любой файл/метод кроме последнего можно опустить, и это не приведет к ошибке.
Теперь возьмем более сложный пример, запрос
GET api/Module_name/items/42/comments
.Во-первых, для API и CLI запросов кроме пути так же имеет значение HTTP метод.
Во-вторых, здесь будет использоваться под-папка
api
.В случае с маршрутизацией основанной на файлах, следующие файлы будут подключены в указанном порядке:
modules/Module_name/api/index.php
modules/Module_name/api/index.get.php
modules/Module_name/api/items.php
modules/Module_name/api/items.get.php
modules/Module_name/api/items/comments.php
modules/Module_name/api/items/comments.get.php
Если же используется маршрутизация, основанная на контроллере, то должен существовать класс
cs\modules\Blogs\api\Controller
(файл modules/Blogs/api/Controller.php
) со следующими публичными статическими методами:
cs\modules\Blogs\api\Controller::index($Request, $Response) : mixed
cs\modules\Blogs\api\Controller::index_get($Request, $Response) : mixed
cs\modules\Blogs\api\Controller::items($Request, $Response) : mixed
cs\modules\Blogs\api\Controller::items_get($Request, $Response) : mixed
cs\modules\Blogs\api\Controller::items_comments($Request, $Response) : mixed
cs\modules\Blogs\api\Controller::items_comments_get($Request, $Response) : mixed
В этом случае хотя бы один из двух последних файлов/контроллеров должен существовать.
Как можно заметить, для API и CLI запросов используется явное разделение кода обработки запросов с разными HTTP методами, в то время как для обычных страниц и страниц администрирования это не учитывается.
Аргументы в контроллерах и возвращаемое значение
$Request
и $Response
не что иное, как экземпляры cs\Request
и cs\Response
.Возвращаемого значения в простых случаях достаточно для задания контента. Под капотом для API запросов возвращаемое значение будет передано в
cs\Page::json()
, а для остальных запросов в cs\Page::content()
.public static function items_comments_get () {
return [];
}
// полностью аналогично
public static function items_comments_get () {
Page::instance->json([]);
}
Несуществующие обработчики HTTP методов
Может случиться, что нет обработчика HTTP метода, который запрашивает пользователь, в этом случае есть несколько сценариев развития событий.
API: если нет ни
cs\modules\Blogs\api\Controller::items_comments()
ни cs\modules\Blogs\api\Controller::items_comments_get()
(либо аналогичных файлов), то:- в первую очередь будет проверено существования обработчика метода
OPTIONS
, если он есть — он решает что с этим делать
- если обработчика метода
OPTIONS
нет, то автоматически сформированый список существующих методов будет отправлен в заголовкеAllow
(если вызываемый метод был отличный отOPTIONS
, то дополнительно код статуса будет изменен на501 Not Implemented
)
CLI: Аналогично API, но вместо
OPTIONS
особенным методом является CLI
, и вместо заголовка Allow
доступные методы будут выведены в консоль (если вызываемый метод был отличный от CLI
, то дополнительно статус выхода будет изменен на 245
(501 % 256
)).Использование собственной системы маршрутизации
Если вам по какой-то причине не нравится устройство маршрутизации во фреймворке, в каждом отдельном модуле вы можете создать лишь
index.php
файл и в нём подключить маршрутизатор по вкусу.Поскольку
index.php
не требует контроллеров и структуры в index.json
, вы обойдете большую часть системы маршрутизации.Права доступа
Для каждого уровня маршрута проверяются права доступа. Права доступа во фреймворке имеют два ключевых параметра: группу и метку.
В качестве группы при проверки прав доступа к странице используется название модуля с опциональным префиксом для страниц администрирования и API, в качестве метки используется путь маршрута (без учета префикса
index
).К примеру, для страницы
api/Module_name/items/comments
будут проверены права пользователя для разрешений (через пробел group label
):
api/Module_name index
api/Module_name items
api/Module_name items/comments
Если на каком-то уровне у пользователя нет доступа — обработка завершится ошибкой
403 Forbidden
, при этом обработчики предыдущих уровней не будут выполнены, так как права доступа определяются на этапе окончательного формирования маршрута, до запуска обработчиков.Напоследок
Реализация обработки запросов в CleverStyle Framework достаточно мощная и гибкая, являясь при этом декларативной.
В статье описаны ключевые этапы обработки запросов с точки зрения системы маршрутизации и её интереса для разработчика, но на самом деле если вникать в нюансы то там ещё есть что изучать.
Надеюсь, данного руководства достаточно для того, чтобы не потеряться. Теперь должно быть понятно, почему для того, чтобы определить, какой код был вызван в ответ на определённый запрос, не нужно даже смотреть в конфигурацию. Достаточно определить тип используемой маршрутизации по наличию
Controller.php
в целевой папке и открыть соответствующий файл.Актуальная версия фреймворка на момент написания статьи 5.29, в более новых версиях возможны изменения, следите за заметками к релизам.
» GitHub репозиторий
» Документация по фреймфорку
Конструктивные комментарии как обычно приветствуются.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Комментарии (11)
t1gor
15.08.2016 09:24А как сделать тогда кастомный рот без языкового префикса типо "/l/" и какой язык при этом будет выбран?
nazarpc
15.08.2016 15:23Есть события, с их помощью вы можете делать любые алиасы, которые вам только заблагорассудится.
Если не указывать язык в URL — будут взяты к сведению заголовки запроса. К примеру, Accept-Language если это браузер, так же поддерживаются заголовки, которые отправляет Facebook, когда встраивает preview в ленте (ему почему-то не хотелось стандартный Accept-Language применять).
Далее если и заголовков нет никаких — используется язык, сконфигурированный в настройках системы.
Так же если пользователь зарегистрирован и в профиле явно указан язык — он так же будет иметь бОльший вес, чем язык в заголовках и чем в настройках системы.
Но это уже не совсем к маршрутизации относится, это многоязычность, с которой тоже много все интересного происходит)
MetaDone
Мне кажется сомнительным решением писать роуты к cli-модулям, а не оформлять их как консольную команду
И как такое чудо вешать на крон?
Ни в одном популярном фреймворке такого вопроса и не возникнет.
И не описан способ получения ссылки по интересующему роуту с передачей параметра, как это делается почти везде. Как в вашей системе получить ссылку на комментарий к созданному мной элементу каталога?
nazarpc
Они и есть консольные команды, просто формат для унификации аналогичен остальной маршрутизации.
CLI команд сейчас всего несколько, к примеру, очистить кэш можно следующим образом:
Где
clean_cache
выступает в роли аналога HTTP метода, аSystem/optimization
в роли аналога пути страницы.Параметры командной строки превращаются в унифицированные параметры, доступные через привычный
$Request->query()
, аналогично параметрам после?
в URL:./cli help:System
выведет справку с доступными командами и форматом вызова (аналогично вызову./cli
без каких либо параметров).Увы, фреймворк ничего такого из коробки не предоставляет. Не знаю почему, но мне как-то даже не было нужно такое никогда. Можете уточнить для чего конкретно такое может быть нужно, что нельзя вручную сформировать ссылку? Интересно узнать сценарии использования.
MetaDone
допустим, генерация ссылки на комментарий или на какую-либо страницу/раздел/статью/путь к api и т.п
Или же генерация ссылки для карты сайта
Генерация ссылки в шаблоне для почтовой рассылки. Много сценариев можно придумать, и переименование модуля не приведет к тому, что придется вручную переписывать уже захардкоженные ссылки во всех местах с их упоминанием.
Почему бы не сделать консольные команды по человечески, например:
В чем смысл именно такой реализации?
nazarpc
Во фреймворке нет очевидной связи между определённым маршрутом и моделью, к примеру, статьи. Соответственно, нет простого способа в общем виде ассоциировать какие-то сущности с маршрутами и обратно. В последнее время всё больше перехожу на подход API + по сути отдельное приложение на фронтенде, которое работает с API. В этом случае пути всё равно придется генерировать не на сервере. В общем, нужно будет над этим подумать.
Приведу команду к вашему формату:
Не вижу какой-то фундаментальной разницы. В то же время, в Symfony не так очевидно, какой код отвечает за команду, а в CleverStyle Framework я не смотря в код знаю, что за запрос отвечает статический метод
cs\modules\System\cli\Controller::optimization_clean_cache()
.Почему именно так? Мне это показалось весьма логичным и удобным (как в HTTP запросах: метод и цель), а в контексте остальной маршрутизации даже в некотором смысле очевидно. Вы выполняете операцию (
clean_cache
) над сущностью или в контексте определённого пути (System/optimization
).Если бы мы создавали статьи из командной строки, то у нас были бы методы
get
,post
,delete
и сущностьArticles
. То есть в формате Symfony получаетсяarticles:get
, а здесьget:Articles
, хотя суть та же.MetaDone
Наверно потому что вы автор. Лично для меня связь не настолько явная.
nazarpc
Естественно, я имею ввиду что оно полностью ложится на конвенцию. Если понять несложный принцип, то всё сразу становится на свои места, не нужно изучать код и конфигурацию для того чтобы с почти 100% вероятностью узнать что будет происходить и где это искать.
oxidmod
А как статику тестить?
nazarpc
Моки и стабы на используемый внешний код и вперёд. В инструментах для разработки самого фреймворка такие штуки уже есть готовые и активно используются, при желании можно любые собственные инструменты притащить.