Маршрутизация во фреймворках: Управление адресами URL

Столб-указатель

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

Генерация адресов ссылок

Кто-то наверняка не желая разобраться в этой теме сразу жёстко прописывает во всех представлениях адреса со вставками:

<?php
<a href="index.php?r=blog/post&id=<?php echo $post->id; ?>">...</a>

или, произведя небольшие настройки, делает то же самое, но в другом формате:

<?php
<a href="/blog/post/view/id/<?php echo $post->id; ?>">...</a>

Но через некоторое время всё-таки переписывает все ссылки на использование системного метода createUrl:

<?php
<a href="<?php echo Yii::app()->createUrl('post/view', array('id'=>$post->id); ?>">...</a>

Попробуем понять эволюцию мыслей разработчика сайта на фреймворке.

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

Управление адресами страниц в приложении

Давным давно, в стране первых веб-программистов PHP файлы произвели революцию в разработке сайтов. Ведь тогда каждый ремесленник мог построить мега-портал с использованием GET параметров, которые он передавал из форм и ссылок на свой сервер.

Он мог динамически выводить страницы:

/page.php?method=view&alias=about

товары в своём магазине:

/shop.php?method=category&category=printers&brand=canon

редкие записи в своём блоге:

/blog.php?method=view&id=15

Потом программист задумался о модульности. И подумал, что было бы неплохо взять только файл index.php (который, кстати, вписывать в адресную строку необязательно) и пускать все запросы через него:

/?module=page&method=view&alias=about

/?module=shop&method=index
/?module=shop&method=category&category=printers&page=2
/?module=shop&method=view&id=printers&page=2

/?module=blog&method=index
/?module=blog&method=category&category=design
/?module=blog&method=tag&tag=отпуск
/?module=blog&method=view&id=15
/?module=blog&method=rss

а в нём уже через операцию include подключать нужный PHP файл из нужного модуля module и запускать в нём процедуру method.

Но вдруг король Гуглиан провозгласил декрет о ненамерении терпеть такие нечеловеческие адреса. Тут-то со всех сторон слетелись SEO-мастера и стали советовать внедрить человекопонятную переадресацию в .htaccess:

RewriteEngine on
RewriteBase /

RewriteRule ^/page/([A-Za-z0-9]+)\.html$ index.php?module=page&method=view&alias=$1 [L,QSA]

RewriteRule ^/shop(/page-([0-9]+))?$ index.php?module=shop&method=index&page=$3 [L,QSA]
RewriteRule ^/shop/category/([A-Za-z0-9]+)(/page-([0-9]+))?$ index.php?module=shop&method=view&category=$1&page=$3 [L,QSA]
RewriteRule ^/shop/item/([0-9]*)\.html$ index.php?module=shop&method=view&id=$1 [L,QSA]

RewriteRule ^/blog/category/([A-Za-z0-9]+)(/page-([0-9]+))?$ index.php?module=blog&method=category&category=$1&page=$3 [L,QSA]
RewriteRule ^/blog(/page-([0-9]+))?$ index.php?module=blog&method=index&page=$2 [L,QSA]
RewriteRule ^/blog/tag/([A-Za-zА-Яа-яЁё0-9]+)(/page-([0-9]+))?$ index.php?module=blog&method=tag&tag=$1&page=$3 [L,QSA]
RewriteRule ^/blog/post/([0-9]*)\.html$ index.php?module=blog&method=view&id=$1 [L,QSA]
RewriteRule ^/feed\.xml$ index.php?module=blog&method=rss&id=$1 [L,QSA]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php

чтобы вернуться к имитации старых добрых .html страниц и реализации приятных для глаза адресов:

/page/about.html

/shop
/shop/category/printers/page-2
/shop/item/341.html

/blog
/blog/category/design
/blog/tag/отпуск
/blog/post/15.html
/feed.xml

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

Для выхода из такого неоднозначного положения лучше было бы оставить только перевод всех запросов на файл index.php в файле .htaccess:

RewriteEngine on
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php

и в конфигурации Nginx:

location / {
    try_files $uri $uri/ /index.php?$args;
}

а регулярные выражения переместить в конфигурационные файлы приложения и ту же работу производить вручную в PHP скрипте. Кроме того, удобно разместить правила преобразования адресов в модулях, например правила для блога поместить в соответствующий файл modules/blog/routes.php:

# modules/blog/routes.php
return array(
    '#^/blog$#' => 'blog/list/index',
    '#^/blog/category/(?P<category>[\w-]+)$#' => 'blog/list/category',
    '#^/blog/tag/(?P<tag>[\w-]+)$#' => 'blog/list/tag',
    '#^/blog/post/(?P<id>\d+)\.html$#' => 'blog/post/view',
    '#^feed\.xml$#' => 'blog/list/rss',
);

Здесь мы представили правила в виде массива. Слева у нас для удобства размещён шаблон адреса, а справа – маршрут в виде строки модуль/файл-контроллер/функция-действие. Также мы воспользовались возможностью регулярных выражений в PHP указывать именованные параметры. Удобнее использовать символьные имена $matches['category'] и $matches['tag'], чем путаться c номерами вроде $maches[1] или $maches[3].

Пишем своё приложение

Попробуем использовать этот подход в небольшом приложении.

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

# modules/blog/routes.php
return array(
    'blog' => 'blog/list/index',
    'blog/category/(?P<category>[\w-]+)' => 'blog/list/category',
    'blog/tag/(?P<tag>[\w-]+)' => 'blog/list/tag',
    'blog/post/(?P<id>\d+)\.html' => 'blog/post/view',
    'feed\.xml' => 'blog/list/rss',
);

Пусть у нас методы-действия для вывода ленты записей блога названы с префиксом action_ и собраны в виде функций в файле-контроллере list.php:

# modules/blog/controllers/list.php
function action_index() {
    $page = get_page();
    $posts = load_all_posts($page);
    include('modules/blog/views/list/index.php');
}
 
function action_category() {
    $page = get_page();
    $posts = load_posts_by_category($_GET['category'], $page);
    include('modules/blog/views/list/category.php');
}
 
function action_tag() {
    $page = get_page();
    $posts = load_posts_by_tag($_GET['tag'], $page);
    include('modules/blog/views/list/tag.php');
}
 
function action_rss() {
    $posts = load_latest_posts();
    include('modules/blog/views/list/rss.php');
}

а действия для просмотра (а также, при желании, создания и редактирования) записи – в файле-контроллере post.php:

# modules/blog/controllers/post.php
function action_view() {
    $post = load_post_by_id($_GET['id']);
    include('modules/blog/views/post/view.php');
}
...

Аналогично мы можем создать модуль магазина shop с файлами-контроллерами catalog, product, cart, order с соответствующими функциями-действиями внутри.

Добавим настройки к нашему приложению:

# config.php
 
define('MODULES', 'page, blog, shop');
define('MODULES_PATH', 'modules');

Вспомогательные функции вынесем в отдельный файл functions.php.

# functions.php
 
// Нам нужно как-то получать список установленных модулей
// Для этого можно брать список папок в директории modules
// или, как у нас, просто хранить в файле настроек
function get_modules() {
    return preg_split('/\s*,\s*/', MODULES);
}
 
// Мы будем смотреть, по какому адресу к нам зашли
function get_request_path() {
    return trim($_SERVER['PATH_INFO'], '/');
}
 
// Часто придётся получать номер страницы
function get_page() {
    $page = intval(isset($_GET['page']) ? $_GET['page']: 0);
    return $page > 0 ? $page : 1;
}
 
// Соберём правила из всех модулей в один массив
function get_modules_rules() {
    $rules = array();
    foreach (get_modules as $module) {
        $rules = array_merge($rules, require_once(MODULES_PATH . '/' . $module . '/routes.php'));
    }
    return $rules;
}
 
// Удалим числовые элементы $matches[0], $matches[1],
// оставим строковые вида $matches['category']
function clear_numberic_matches($matches) {
    $clean = array();
    foreach ($matches as $key=>$value) {
        if (!is_int($key) {
            $clean[$key] = $value;
        }
    }
    return $clean;
}

И соберём наш парсер адресов в файле index.php:

# index.php
 
require_once('config.php');
require_once('functions.php');
 
$module = 'page';
$controller = 'default';
$action = 'index';
 
$path = get_request_path();
$rules = get_modules_rules();
 
foreach ($rules as $pattern=>$route) {
    if (preg_match('#^/' . ltrim($pattern, '/') . '$#', $path, $matches) {
        list($module, $controller, $action) = explode('/', $route);
        $_GET = array_merge($_GET, clear_numberic_matches($matches));
        break;
    }
}
 
require_once(MODULES_PATH . '/' . $module . '/controllers/' . $controller . '.php');
call_user_func('action_' . $action);

Здесь мы, первым делом, считали правила из всех модулей, а потом по очереди стали проверять их на применимость к текущему адресу $_SERVER['PATH_INFO']. У первого же совпавшего шаблона мы взяли значение модуль/контроллер/действие, подключили нужный файл-контроллер и вызвали в нём нужный метод-действие. Также мы не забыли про извлечённые из адреса параметры вроде ?P<category> и добавили их в массив $_GET.

То есть, если мы теперь зайдём по адресу

http://site.com/blog/category/design?page=2&sort=date

то в переменной $path окажется значение blog/category/design и в результате работы нашего анализатора мы получим значения

$module = 'blog';
$controller = 'list';
$action = 'category'
 
$_GET = array(
    'category' => 'design',
    'page' => 2,
    'sort' => 'date',
}

Дальше скрипт подключит файл-контроллер modules/blog/controllers/list.php и выполнит действие action_category().

Явное преимущество по сравнению с .htaccess состоит здесь в том, что нам не нужно прописывать абсолютно все параметры. Достаточно указать их в обычном виде как ?page=2&sort=date в конце пути и они не пропадут.

Модернизируем наш парсер дальше.

Передача параметров в маршрут

У нас в модуле магазина может быть контроллер для корзины cart с методами index, add, remove, clear:

return array(
    'shop/cart' => 'shop/cart/index',
    'shop/cart/add' => 'shop/cart/add',
    'shop/cart/remove' => 'shop/cart/remove',
    'shop/cart/clear' => 'shop/cart/clear',
);

Не забываем, что слева здесь регулярные выражения, а справа – маршруты вида модуль/контроллер/действие.

Ссылка кнопки удаления товара из корзины могла бы выглядеть так:

http://site.com/shop/cart-remove?id=15

Конечно же, мы могли бы добавить значение номера товара в шаблон адреса:

'shop/cart/remove/(?P<id>\d+)' => 'shop/cart/remove',

и указывать номер прямо в пути

http://site.com/shop/cart-remove/15

но это было бы слишком педантично и малополезно. Так что оставим первый вариант.

Опыт работы с регулярными выражениями может подсказать нам сократить несколько конкретных правил до одного универсального.

При работе с регулярными выражениями мы можем использовать совпавшие фрагменты по их номерам вроде $1, $2. Например функция

$string = 'shop/cart/remove';
echo preg_replace('#^shop/cart/(add|remove|clear)$#', 'shop/cart/action-$1', $string);

подставит remove вместо $1 и выведет

shop/cart/action-remove

Будь у нас такая же возможность, мы могли бы заменить список похожих правил:

'shop/cart/add' => 'shop/cart/add',
'shop/cart/remove' => 'shop/cart/remove',
'shop/cart/clear' => 'shop/cart/clear',

всего одной строкой:

'shop/cart/(?P<action>add|remove|clear)' => 'shop/cart/<action>',

Но, к сожалению, функция preg_replace поддерживает обращение к фрагментам только по порядковому номеру, например:

preg_replace('#^shop/cart/(?P<action>add|remove|clear)$#', 'shop/cart/$1', $string);

Устраним этот недостаток, а именно воспользуемся функцией str_replace:

foreach ($rules as $pattern=>$route) {
    if (preg_match('#^/' . ltrim($pattern, '/') . '$#', $path, $matches) {
        $symbol_matches = clear_numberic_matches($matches);
        foreach ($symbol_matches as $key=>$value) {
            if (strpos($route, '<' . $key . '>') !== false) {
                $route = str_replace('<' . $key . '>', $value, $route);
                unset($symbol_matches[$key]);
            }
        }
        list($module, $controller, $action) = explode('/', $route);
        $_GET = array_merge($_GET, $symbol_matches);
        break;
    }
}

Здесь мы операцией

$route = str_replace('<' . $key . '>', $value, $route);

заменяем подстановки на их значения.

Теперь при переходе по адресу

http://site.com/shop/cart/remove?id=15

вместо <action> в $route произойдёт подстановка извлечённого из текущего адреса значения remove, что приведёт нас куда мы и хотели:

$module = 'shop';
$controller = 'cart';
$action = 'remove'
 
$_GET = array(
    'id' => 15,
}

С помощью функции unset() мы позаботились о том, чтобы значение 'action'=>'remove' не попало в $_GET.

Теперь мы можем совершить чудо. Просто добавим в конец списка тройку правил:

'(?P<module>\w+)' => '<module>/default/index',
'(?P<module>\w+)/(?P<controller>\w+)' => '<module>/<controller>/index',
'(?P<module>\w+)/(?P<controller>\w+)/(?P<action>\w+)' => '<module>/<controller>/<action>',

Это будут запасные правила по умолчанию, позволяющие вызывать любое действие любого контроллера. С ними у нас заработает любой модуль, у которого нет своих правил.

Фактически, парсинг адресов на основе регулярных выражений у нас готов.

Достоинства обработки адресов в программном коде

Наше мини-приложение уже умеет корректно производить разбор адресов по аналогии с записями RewriteRule в .htaccess. Но зачем же мы сделали этот «велосипед», а именно имитацию работы модуля mod_rewrite?

Как мы упоминали, первая причина кроется в облегчении поддержки модульности. Разумнее хранить правила прямо в модуле и подключать к приложению динамически. Иначе бы приходилось каждый раз при подключении нового функционала вручную или в скрипте-установщике дописывать правила в файл '.htaccess`. А если используется не Apache, а иной сервер, то ручная правка неизбежна.

Если с этим доводом можно поспорить и нет ничего страшного в правке файлов, то другая причина развевает все сомнения. Эта причина – возможность обратного конструирования адресов на основе маршрутов.

Генерация адресов из маршрутов

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

Например, если у нас есть правило

'feed\.xml' => 'blog/list/rss',

то при заходе по адресу

http://site.com/feed.xml

у нас должен произойти разбор адреса и выполниться действие rss контроллера list модуля blog. Одновременно если мы напишем в шаблоне строку

<?php
<link rel="alternate" type="application/rss+xml" title="Лента" href="<?php echo create_url('blog/list/rss'); ?>" />

должно произойти обратное преобразование, то есть результирующий HTML-код страницы должен содержать строку

<?php
<link rel="alternate" type="application/rss+xml" title="Лента" href="/feed.xml" />

Аналогично вызов функции create_url с дополнительными параметрами

<?php
<a href="<?php echo create_url('blog/post/view', array('id'=>$post['id'])); ?>">
    <?php echo htmlspecialchars($post['title']); ?>
</a>

должен найти подходящий шаблон

'blog/post/(?P<id>\d+)\.html' => 'blog/post/view',

и, подставив в него переданное значение параметра id, сгенерировать правильный адрес:

<?php
<a href="/blog/post/42">Как построить дом</a>

И в третьем случае

<?php
<a href="<?php echo create_url('shop/cart/clear'); ?>">Очистить корзину</a>

должно найтись совпадение с правилом

'shop/cart/(?P<action>add|remove|clear)' => 'shop/cart/<action>',

и сгенерироваться корректный адрес:

<?php
<a href="shop/cart/clear">Очистить корзину</a>

Функция create_url должна следить за экранированием спецсимволов в параметрах, пропуская их через функцию urlencode.

Реализация метода create_url будет сложнее чем parse_url, так как в нём необходимо производить синтаксический анализ обеих частей правила и сопоставлять число и типы параметров для поиска подходящего правила, а потом и генерировать URL из регулярного выражения. Это нетривиальная задача, так как синтаксис регулярных выражений достаточно сложен. Сделать анализатор для языка настоящих регулярных выражений нелегко.

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

Первым делом, вместо неудобных конструкций (?P<id>\d+) было бы приятнее использовать облегчённые <id:\d+>

# modules/blog/routes.php
return array(
    'blog' => 'blog/list/index',
    'blog/category/(?P<category>[\w-]+)' => 'blog/list/category',
    'blog/tag/(?P<tag>[\w-]+)' => 'blog/list/tag',
    'blog/post/(?P<id>\d+)\.html' => 'blog/post/view',
    // ...
    'shop/cart/(?P<action>add|remove|clear)' => 'shop/cart/<action>',
);
<?php
# modules/blog/routes.php
return array(
    'blog' => 'blog/list/index',
    'blog/category/' => 'blog/list/category',
    'blog/tag/' => 'blog/list/tag',
    'blog/post/\.html' => 'blog/post/view',
    // ...
    'shop/cart/' => 'shop/cart/',
);
~~~

Теперь при разборе адреса можно простой заменой строки реализовать преобразование псевдошаблона в настоящее регулярное выражение.

Упрощение анализа шаблонов
------

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

~~~
/blog/category/programming?page=2
~~~

для поддержки ЧПУ для страниц

~~~
/blog/category/programming
/blog/category/programming/page/2
~~~

мы могли бы написать одно правило, в котором знаком `?` мы указали бы необязательность параметра `page`:

~~~
[php]
<?php
'blog/category/(?P[\w-]+)(/page/(?P\d+))?' => 'blog/list/category',
~~~

Аналогично мы могли бы добавить другие параметры. В парсере такого выражения нам бы пришлось разбираться во множестве скобок.

Это сложный синтаксис, но вовсе не обязательный. Вместо него мы можем использовать несколько простых для анализатора правил:

~~~
[php]
<?php
'blog/category//page/' => 'blog/list/category',
'blog/category/' => 'blog/list/category',
~~~

При наличии параметра `page` в аргументах функции при вызове `create_url` сработает первое правило. Иначе случится совпадение со вторым.

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

Теперь рассмотрим такое правило

~~~
[php]
<?php
'blog/post/(?P\d+)\.(html|xml|json)' => 'blog/post/view',
~~~

Здесь в произвольном месте вне именованного параметра `id` добавлена группа `(html|xml|json)` для возможности принимать расширение (что может понадобиться для работы сайта по Ajax), но эта группа никак не названа.

При попытке открыть адрес `http://site.com/blog/post/52.xml` это правило вполне себе сработает, но попытка создать адрес по нему

~~~
[php]
<?php
echo create_url('blog/post/view', array('id' => 52));
~~~

ни к чему ни приведёт, так как наш анализатор не будет знать, какое расширение нужно указать после точки.

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

В связи с этим данный шаблон должен выглядеть так:

~~~
[php]
<?php
'blog/post/.' => 'blog/post/view',
~~~

и вызываться он должен с передачей значений всех имеющихся параметров

~~~
[php]
<?php
echo create_url('blog/post/view', array(
    'id' => 52,
    'extension' => 'xml',
));
~~~

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

По этой же причине мы не можем указать необязательность параметра на нашем псевдоязыке вот так:

~~~
[php]
<?php
'blog/post/(\.)?' => 'blog/post/view',
~~~

так как теперь мы знаем, что скобки не окажут должного эффекта. Система их примет за обычные символы и при генерации просто выведет как есть:

~~~
/blog/post/52(\.xml)?
~~~

Так что если нужно сделать указание расширения необязательным, то создайте два правила:

~~~
[php]
<?php
'blog/post/.' => 'blog/post/view',
'blog/post/' => 'blog/post/view',
~~~

При передаче идентификатора и расширения сработает первое, а только для идентификатора – второе.

Можно использовать и запасной работоспособный вариант:

~~~
[php]
<?php
'blog/post/' => 'blog/post/view',

то есть разрешить передавать пустое значение аргументу extension, но в этом случае расширение при генерации придётся указывать с точкой.

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

Теперь не составит труда выделить из шаблона все упоминаемые в нём параметры одним простым проходом функции preg_match_all или сгенерировать на основе шаблона настоящее регулярное выражение. Генерировать адрес по такому шаблону тоже достаточно легко.

Теперь мы можем продолжить исследование и перейти от процедурного подхода к объектно-ориентированному.

Объектно-ориентированная реализация маршрутизатора

Попробуем сейчас построить каркас, в который мы можем инкапсулировать наш код.

Первым делом, создадим сущность для оформления правила. Дополним для большего удобства наш класс некоторыми свойствами:

<?php
abstract class BaseUrlRule
{
    abstract public function parseUrl($url, $pathInfo);
    abstract public function createUrl($route, $params);
}

class UrlRule extends BaseUrlRule;
{
    public $route;
    public $pattern;
    public $urlSuffix = '';
    public $caseSensitive = false;
    public $defaultParams = array();
    public $routePattern;

    public function __construct($route, $pattern) {
        if (is_array($route))  {
            foreach (array('urlSuffix', 'caseSensitive', 'defaultParams') as $name) {
                if (isset($route[$name])) {
                    $this->$name=$route[$name];
                }
            }
            if (isset($route['pattern'])) {
                $pattern = $route['pattern'];
            }
            $route = $route[0];
        }
        $this->route = trim($route,'/');
        $this->pattern = $pattern;
    }

    public function parseUrl($url, $pathInfo) {
        // Преобразовываем шаблон $this->route в регулярное выражение $this->routePattern
        // и проверяем на совпадение адреса этому выражению.
        // Возвращаем маршрут при успехе или false.
        ...
    }

    public function createUrl($route, $params) {
        // Анализируем шаблон и маршрут, при совпадении строим адрес.
        // Иначе возвращаем адрес по умолчанию на основе $route и $params.
        ...
    }
}
~~~

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

Теперь добавим в наше приложение менеджер адресов, который будет хранить список правил и обходить их при поиске нужного:

~~~
[php]
<?php
class UrlManager
{
    /**
     * Список правил-объектов UrlRule или массивов
     * со специфическими опциями
     * @var BaseUrlRule[]|array[]
     */
    public $rules = array();

    /**
     * Добавляет правила в список
     * @param array $rules
     */
    public function addRules($rules) {
        foreach($rules as $pattern => $route) {
            $this->rules[] = $this->createUrlRule($route, $pattern);
        }
    }

    /**
     * Проходит по списку до тех пор, пока какое-либо правило не сработает
     * и не вернёт строку вида модуль/контроллер/действие
     * @param string $url
     */
    public function parseUrl($url, $pathInfo) {
        foreach ($this->rules as $i=>$rule) {
            if (is_array($rule)) {
                $rule = $this->createRuleObject($rule);
                $this->rules[$i] = $rule;
            }
            $route = $rule->parseUrl($url, $pathInfo);
            if ($route !== false) {
                return $route;
            }
        }
        return false;
    }

    /**
     * Проходит по списку до тех пор, пока какое-либо правило не сработает
     * и не вернёт построенный URL
     * @param string $route
     * @param array $params
     */
    public function createUrl($route, $params = array()) {
        foreach ($this->rules as $rule) {
            $url = $rule->createUrl($route, $params);
            if ($url !== false) {
                return '/' . $url;
            }
        }
        return $route . '?' . $this->createPathInfo($params);
    }

    /**
     * Генерирует строку параметров param1=val1&param2=val2
     * @param array $params
     */
    public function createPathInfo($params = array()) {
        $items = array()
        foreach ($params as $key=>$value) {
            $items[] = urlencode($key) . '=' urlencode($value);
        }
        return implode('&', $items);
    }

    /**
     * Оборачивает в класс UrlRule простые пары 'pattern'=>'route'
     * если не указан отдельный класс для правила
     * @param string $route
     * @param string $pattern
     */
    private function createUrlRule($route, $pattern) {
        if (is_array($route) && isset($route['class'])) {
            return $route;
        } else {
            return new UrlRule($route, $pattern);
        }
    }

    /**
     * Если правило задано со своим именем класса, то создать
     * его экземпляр вместо стандартного
     * @param array $options
     */
    private function createRuleObject($options) {
        $class = $options['class'];
        unset($options['class']);
        $rule = new $class();
        foreach ($options as $key=>$value) {
            $rule->$key = $value;
        }
        return $rule;
    }
}
~~~

В самом приложении мы теперь можем использовать наш менеджер:

~~~
[php]
<?php
class MySite
{
    public $urlManager;

    private function __construct() {
        $this->urlManager = new UrlManager();
    }

    private static $_instance;

    public static function app() {
        if (self::$_instance === null) {
            self::$_instance = new App();
        }
        return self::$_instance;
    }

    public function run() {

        // добавляем правила
        foreach ($modules as $module) {
            $rules = require_once(MODULES_PATH . '/' . $module . '/routes.php');
            $this->urlManager->addRules($rules);
        }

        // получаем нужный маршрут на основе текущего адреса
        $route = $this->urlManager->parseUrl($_SERVER['REQUEST_URI'], $_SERVER['PATH_INFO']);

        // если маршрут не нашёлся, то перенаправляем на страницу ошибки
        // в действии DefaultController::actionError модуля site
        if (!$route) {
            $route = 'site/default/error';
        }

        // расщепляем маршрут
        list($module, $controller, $action) = explode('/', $route);

        // подключаем контроллер $controller и запускаем действие $action
        $controller_class = new ucfirst($controller) . 'Controller';
        require_once(MODULES_PATH . '/' . $module . '/controllers/' . $controller_class . '.php');
        $controller = new $controller_class();
        $action_method = 'action' . ucfirst($action);
        $controller->$action_method();
    }
}
~~~

А в представлениях и контроллерах для построения ссылок использовать метод `createUrl`:

~~~
[php]
<?php

но и конфигурируемые записи с любым числом опций:

'feed' => array('blog/list/rss', 'urlSuffix'=>'.xml'),

или

array('blog/list/rss', 'pattern'=>'feed', 'urlSuffix'=>'.xml'),

или

array('pattern'=>'feed', 'route'=>'blog/list/rss', 'urlSuffix'=>'.xml'),

Именно для таких вариантов мы добавили проверки вроде is_array($route). В каждом из этих трёх случаев происходит разбор пришедших значений в конструкторе класса UrlRule.

Подобная запись в виде массива может быть использована и для указания своих классов вместо стандартного UrlRule.

Свои классы правил маршрутизации

Порой указания простого маршрута не хватает для корректной работы приложения. Это может происходить при конфликтах нескольких похожих адресов.

Пусть, например, у нас статические страницы:

http://site.com/about
http://site.com/services
http://site.com/delivery-information

Они должны выводится в действии page/default/view. Для этого мы можем добавить несложное правило после всех других:

<?php
...
'<alias:[\w-]+>' => 'page/default/view',

Оно будет срабатывать в последнюю очередь (если предшествующие ему правила не сработают). Но предположим, что у нас есть и стандартный правила, упоминаемые ранее. Если мы поместим наше правило после стандартного:

'<module:\w+>' => '<module>/default/index',
'<alias:[\w-]+>' => 'page/default/view',

то при попытке открыть страницу

http://site.com/about

случайно сработает первое правило, и мы окажемся в действии about/default/index, которого не существует. Будут срабатывать модули, но не будут открываться страницы.

Если мы поставим правило для статических страниц выше стандартного:

'<alias:[\w-]+>' => 'page/default/view',
'<module:\w+>' => '<module>/default/index',

то при заходе по адресу

http://site.com/shop

тоже сработает первое правило, и вместо действия shop/default/index откроется страница shop, которой тоже не существует.

Это неудивительно, так как правила проверяются по списку сверху вниз, и если будет несколько подходящих правил, то выбор остановится на первом из них.

Обычного правила из простой пары шаблон-маршрут недостаточно для работы такого вывода страниц. Во избежание такой путаницы шаблон должен быть «умным», а именно должен проверять наличие страницы и срабатывать только тогда, когда страница существует.

Создадим класс, реализующий тот же интерфейс, как и BaseUrlRule. Метод parseUrl будет осуществлять проверку на существование страницы и в случае успеха заполнять параметр $_GET['id'] её идентификатором и возвращать маршрут 'page/default/view'. Если страница с указанным псевдонимом не существует, то метод parseUrl данного класса просто вернёт false:

class PageUrlRule extends BaseUrlRule
{
    public $caseSensitive = false;
 
    public function parseUrl($url, $pathInfo) {
        if (preg_match('#^([\w-]+)#i', $pathInfo, $matches)) {
 
            $escaped_alias = mysql_real_escape_string($matches[1]);
 
            if ($this->caseSensitive) {
                $where = 'alias = "' . $escaped_alias . '"';
            } else {
                $where = 'lower(alias) = lower("' . $escaped_alias . '")';
            }
 
            $rows = mysql_query('SELECT id FROM pages WHERE ' . $where . ' LIMIT 1');
            if ($row = mysql_fetch_assoc($rows)) {
                $_GET['id'] = $row['id'];
                return 'page/default/view';
            }
        }
        return false;
    }
 
    public function createUrl($route, $params) {
        if ($route == 'page/default/view') {
            if (!empty($params['id']) {
                $rows = mysql_query('SELECT alias FROM pages WHERE id = ' . (int)$params['id'] . ' LIMIT 1');
                if ($row = mysql_fetch_assoc($rows)) {
                    return $row['alias'];
                }
            }
        }
        return false;
    }
}

Теперь добавим наше правило с указанием класса:

<?php
...
array('class'=>'PageUrlRule', 'caseSensitive'=>true),
'<module:\w+>' => '<module>/default/index',

Вспомним, что наш менеджер производит перебор правил из своего массива rules по очереди:

class UrlManager
{
    ...
 
    public function parseUrl($url, $pathInfo) {
        foreach ($this->rules as $i=>$rule) {
            if (is_array($rule)) {
                $rule = $this->createRuleObject($rule);
                $this->rules[$i] = $rule;
            }
            $route = $rule->parseUrl($url, $pathInfo);
            if ($route !== false) {
                return $route;
            }
        }
        return false;
    }
}

Если менеджер дойдёт до нашего правила, то в методе createRuleObject он здесь же создаст экземпляр PageUrlRule, заполнит его оставшимися значениями параметров (у нас это caseSensitive) и запустит метод PageUrlRule::parseUrl. Если этот метод не найдёт нашу страницу shop, то цикл foreach пойдёт к следующим правилам.

Хотя мы и поменяли работу с id на работу с alias, но в самом приложении ничего не изменится. Мы также передаём в генератор адреса маршрут и числовой идентификатор id страницы:

<?php
<a href="<?php echo MySite::app()->urlManager->createUrl('page/default/view', array('id'=>$page['id'])); ?>">
    <?php echo htmlspecialchars($page['title']); ?>
</a>

а наш умный класс сам в методе createUrl генерирует адрес в нужном формате. Также в действии контроллера этот идентификатор благодаря коду

$_GET['id'] = $row['id'];

остаётся доступен как $_GET['id'].

Что мы узнали

На этом примере написания своего мини-приложения мы познакомились с ролью и устройством такого компонента, как система маршрутизации. Хоть и для разбора адреса можно использовать директивы файла .htaccess, но для гибкой генерации адресов без маршрутизатора не обойтись.

Гибкость здесь заключается в том, что в отличие от создания адресов простой конкатенацией мы можем в любой момент изменить вид отображаемого адреса, всего лишь изменив строку с нужным правилом в файле конфигурации, а не переписывая код всего приложения. Например, если мы вдруг захотим добавить суффикс .html к адресам записей блога (или вообще ко всем страницам на сайте), то достаточно указать

'urlSuffix' => '.html'`

в нужном правиле или глобально для всего менеджера (реализовать такую глобальную опцию в классе UrlManager достаточно легко: добавить открытое свойство в класс UrlManager и брать его значение как значение по умолчанию внутри UrlRule), и адреса мгновенно поменяются. Это очень удобно.

Также мы теперь знаем, что в шаблонах правил разные системы маршрутизации могут применять либо «ненастоящий» язык регулярных выражений, то есть свой собственный упрощённый аналог, либо позволяют использовать регулярные выражения только в специфических местах. Поэтому не стоит удивляться, что некоторые символы в правилах разных маршрутизаторов не сработают и будут выведены как простой текст.

В следующей части мы с вами рассмотрим реализацию маршрутизации в Yii.

Вторая часть: Маршрутизация во фреймворках: CUrlManager в Yii

Комментарии

 

Sergey

В private function createRuleObject($options) забыл вернуть объект.
И в array('pattern'=>'feed', route=>'blog/list/rss', 'urlSuffix'=>'.xml'), ошибка.
А тут вообще не понятно что return falshop/cart/...

За статью спасибо, было интересно.

Ответить

 

Дмитрий Елисеев

Спасибо. Исправил.

Ответить

 

Redee .

регулярки действительно лучше ограничивать чем то более заметным как тут рекомендуется типо решеток #, а то раньше делал через слеши /

Ответить

 

Akulenok

Решил записи выводить по alias (link)
в урл менеджере сделал такое правило

'post/<link:\w+>' => 'post/view',

все хорошо, но в комментах делаю капчу и она не выводится.
у капчи получается такая ссылка /post/captcha?v=5351729d8859a
Данная запись не найдена
подскажите как сделать правильно в моей ситуации

Ответить

 

Akulenok

а все, этот вопрос решен

Ответить

 

Дмитрий Елисеев

Думаю, что решили этим способом:

'post/captcha' => 'post/captcha',
'post/<link:\w+>' => 'post/view',
Ответить

 

Дмитрий

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

blog/post/<id:\d+>

насколько я понимаю его нужно преобразовать в регулярное выражение подобное этому:

blog/post/(?P<id>\d+)

Но в статье к большому сожалению я этого не увидел. Если можно приведите пожалуйста пример как это реализовать. Заранее очень благодарен.

Ответить

 

Дмитрий Елисеев

В статье есть два примера:

'blog/post/(?P<id>\d+)\.html' => 'blog/post/view',
'shop/cart/remove/(?P<id>\d+)' => 'shop/cart/remove',
Ответить

 

Дмитрий

Да и еще одну странность заметил, функция "str_pos" используется. А кода данной функции нет.

Ответить

 

Дмитрий Елисеев

strpos

Ответить

 

Дмитрий

Ну если так:

strpos('<' . $key . '>', $route)

Тогда уже правильно так:

strpos($route, '<' . $key . '>')

Параметры нужно поменять местами.

Первым идет строка, в которой производится поиск.
Вторым искомая строка.

Ответить

 

Дмитрий Елисеев

Спасибо. Исправил.

Ответить

 

Igor – orshansky-uezd.org

А с такими настройками .htaccess

RewriteEngine on
RewriteBase /

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php

не будет проблем с получением файлов изображений, css и т.д.?

Ответить

 

Дмитрий Елисеев

Не будет из-за строк этих условий:

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d

Они проверяют, что текущий запрос – это не папка и не файл.

Ответить

 

Igor – orshansky-uezd.org

Т.е., он проверяет по списку существующих файлов/папок?

Ответить

 

Дмитрий Елисеев

Да.

Ответить

 

Andrey

Спасибо за статью!

Получается, что UrlManager делает что-то вроде 301 редиректа для ключ->значение в массиве конфига?

Ответить

 

Дмитрий Елисеев

Ищет соответствие по массиву правил, где шаблон URL слева, а контроллер и действие – справа.

Ответить

 

Andrey

Спасибо. Насчет правил понятно, а вот что заставляет меняться то что слева на то что справа - вот это для меня загадка)

Ответить

 

Григорий Степенко

Дмитрий, отличная статья, спасибо. Лучшая статья! Пытаюсь всё повторить от и до, чтобы проникнуться. Нашёл небольшую неточность

RewriteRule ^/page/([A-Za-z0-9]+)\.html$ index.php?module=page&method=view&alias=$1 [L,QSA]

пришлось заменить на

RewriteRule ^page/([A-Za-z0-9]+)\.html$ index.php?module=page&method=view&alias=$1 [L,QSA]

И аналогично пришлось изменить

"И соберём наш парсер адресов в файле index.php:
...if (preg_match('#^/' . ltrim($pattern, '/') . '$#', $path, $matches)){..."

на

...if (preg_match('#^' . ltrim($pattern, '/') . '$#', $path, $matches)){...

И ещё несколько моментов пришлось доделать, чтобы заработало, но в целом статья уникальная!
В Бауманке такого не покажут. А зря. Как будто большой пробел теперь заполнен.

Ответить

 

Ihor – igroup.com.ua

Спасибо тебе, человечище!

Ответить

 

Виктор Закал

Статья супер! Автор молодец!

Но вот никак не понял, где идет преобразование такого адреса

blog/post/<id:\d+>

Или в статье не описано про это? Можно ткнуть меня в кусок кода который занимается этим?

Кто в теме, помогите...

Ответить

 

Виктор Закал

Я разобрался с преобразованием таких адресов по шаблону
такой регуляркой

\<\s*([a-z][a-zA-Z0-9_-]*)\s*(?::\s*([^{}]*(?:\{(?-1)\}[^{}]*)*))?\>
blog/post/<id:\d+>

и написал метод

public function parseUrl($url, $pathInfo) {


Но теперь другой вопрос, как написать метод?

public function createUrl($route, $params)

Допустим, я во view создаю ссылку так

 
createUrl('blog/post/view', ['id'=>$post['id'] ] )


и пробежался по роутам и нашел совпадение

 
'blog/post/<id:\d+>' => 'blog/post/view',

Как теперь сделать замену и вернуть правильную ссылку?

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>