Маршрутизация в CleverStyle Framework

Многие аспекты CleverStyle Framework имеют альтернативную по отношению к большинству других фреймворков реализацию тех же вещей.

Данная статья достаточно подробно описывает устройство работы маршрутизации, примеры использования, а так же примеры того, как можно в этот механизм вмешаться, либо, при желании, полностью его заменить его на собственный.

Основное отличие


Главное отличие маршрутизации от реализаций в популярных фреймворках типа 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 репозиторий
» Документация по фреймфорку

Конструктивные комментарии как обычно приветствуются.

Комментарии (8)

  • 14 августа 2016 в 17:24

    0

    Мне кажется сомнительным решением писать роуты к cli-модулям, а не оформлять их как консольную команду
    И как такое чудо вешать на крон?
    Ни в одном популярном фреймворке такого вопроса и не возникнет.
    И не описан способ получения ссылки по интересующему роуту с передачей параметра, как это делается почти везде. Как в вашей системе получить ссылку на комментарий к созданному мной элементу каталога?
    • 14 августа 2016 в 17:43

      0

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


      ./cli clean_cache:System/optimization

      Где clean_cache выступает в роли аналога HTTP метода, а System/optimization в роли аналога пути страницы.


      Параметры командной строки превращаются в унифицированные параметры, доступные через привычный $Request->query(), аналогично параметрам после ? в URL:


      ./cli get:Module_name bool_param text_param="Some value"

      ./cli help:System выведет справку с доступными командами и форматом вызова (аналогично вызову ./cli без каких либо параметров).


      Как в вашей системе получить ссылку на комментарий к созданному мной элементу каталога?

      Увы, фреймворк ничего такого из коробки не предоставляет. Не знаю почему, но мне как-то даже не было нужно такое никогда. Можете уточнить для чего конкретно такое может быть нужно, что нельзя вручную сформировать ссылку? Интересно узнать сценарии использования.

      • 14 августа 2016 в 18:01

        0

        Можете уточнить для чего конкретно такое может быть нужно, что нельзя вручную сформировать ссылку?

        допустим, генерация ссылки на комментарий или на какую-либо страницу/раздел/статью/путь к api и т.п
        Или же генерация ссылки для карты сайта
        Генерация ссылки в шаблоне для почтовой рассылки. Много сценариев можно придумать, и переименование модуля не приведет к тому, что придется вручную переписывать уже захардкоженные ссылки во всех местах с их упоминанием.
        Где clean_cache выступает в роли аналога HTTP метода, а System/optimization в роли аналога пути страницы.

        Почему бы не сделать консольные команды по человечески, например:
        /usr/bin/php /path/to/bin/console cache:clear //symfony
        php app/cli.php main test world universe //phalcon
        php artisan make:console SendEmails //laravel
        

        В чем смысл именно такой реализации?
        • 14 августа 2016 в 18:24

          0

          Во фреймворке нет очевидной связи между определённым маршрутом и моделью, к примеру, статьи. Соответственно, нет простого способа в общем виде ассоциировать какие-то сущности с маршрутами и обратно. В последнее время всё больше перехожу на подход API + по сути отдельное приложение на фронтенде, которое работает с API. В этом случае пути всё равно придется генерировать не на сервере. В общем, нужно будет над этим подумать.


          Приведу команду к вашему формату:


          /usr/bin/php /path/to/bin/console cache:clear //symfony
          /usr/bin/php /path/to/bin/cli clean_cache:System/optimization //cleverstyle framework

          Не вижу какой-то фундаментальной разницы. В то же время, в 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, хотя суть та же.

          • 14 августа 2016 в 18:32

            0

            /usr/bin/php /path/to/bin/cli clean_cache: System/optimization

            в CleverStyle Framework я не смотря в код знаю, что за запрос отвечает статический метод cs\modules\System\cli\Controller: optimization_clean_cache ()

            Наверно потому что вы автор. Лично для меня связь не настолько явная.
            • 14 августа 2016 в 18:37

              0

              Естественно, я имею ввиду что оно полностью ложится на конвенцию. Если понять несложный принцип, то всё сразу становится на свои места, не нужно изучать код и конфигурацию для того чтобы с почти 100% вероятностью узнать что будет происходить и где это искать.

              • 14 августа 2016 в 23:41

                0

                А как статику тестить?
                • 14 августа 2016 в 23:50

                  0

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

© Habrahabr.ru