Самый простой и логичный ЧПУ для PHP

Решил написать эту заметку, потому как надоело отвечать 100500 раз одно и то же на ВиО.

Многие начинающие веб-программисты рано или поздно сталкиваются с задачей внедрения в свой сайт человеко-понятных линков (ЧПУ). До внедрения ЧПУ все ссылки имеют вид /myscript.php или даже /myfolder/myfolder2/myscript3.php, что тяжело для запоминания и ещё хуже для SEO. После внедрения ЧПУ линки принимают вид /statiya-o-php или даже на кириллице /статья-о-пхп.

Кстати о SEO. Человекопонятные линки на САМОМ деле придумали не для удобного запоминания, а в основном для повышения индексируемости сайта, потому что совпадение поискового запроса и части URL даёт хорошее преимущество в рейтинге поиска.

Эволюция начинающего PHP-программиста может быть выражена следующей последовательностью шагов:

  1. Размещение plain-PHP кода в отдельных файлах и доступ к этим файлам через линки вида /myfolder/myscript.php
  2. Понимание, что все скрипты имеют значительную часть общего (например, создание подключения к БД, чтение конфигурации, запуск сессии и проч.) и как следствие создание общей начальной точки «входа», некоторого скрипта, который принимает ВСЕ запросы, а потом выбирает — какой внутренний скрипт подключить. Обычно этот скрипт имеет имя index.php и лежит в корне, вследствие чего все запросы (они же URLы) выглядят так: /index.php?com=myaction&com2=mysubaction
  3. Необходимость внедрения роутера и переход к человекопонятным линкам.

Замечу, что между пунктами 2 и 3 большинство программистов делают очевидную ошибку. Я не ошибусь, если назову это значением около 95% программистов. Даже большинство известных фреймворков содержат эту ошибку. И заключается она в следующем.

Вместо того, чтобы реализовывать принципиально новый способ обработки линков, ошибочно делается концепция «заплаток и редиректов» на базе .htaccess, которая заключается в том, чтобы с помощью mod_rewrite создавать множество правил редиректа. Эти строки сравнивают URL с каким-либо регулярным выражением и при совпадении расталкивают выуженные из URL значения по GET-переменным, в дальнейшем вызывая всё тот же index.php.

#Неправильный метод ЧПУ

RewriteEngine On
RewriteRule ^\/users\/(.+)$ index.php?module=users&id=$1 [QSA]

#....Ещё куча подобных правил...

У данной концепции множество недостатков. Один из них — трудность создания правил, большой процент человеческих ошибок при добавлении правил, которые сложно выявить, но они приводят к ошибке сервера 500.

Другой недостаток в том, что часто правится по сути конфига сервера, что само по себе нонсенс. И если в Apache конфиг можно «пропатчить» с помощью .htaccess, то в популярном nginx такой возможности нет, там всё находится в общем файле конфигурации в системной зоне.

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

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

Суть заключается в том, что начальный запрос всегда хранится в переменной $_SERVER[‘REQUEST_URI’], то есть его можно считать внутри index.php и разобрать как строку средствами PHP со всеми обработками ошибок, динамическими редиректами и проч и проч.

При этом в файле конфигурации можно создать только одно статичное правило, которое будет все запросы к несуществующим файлам или папкам редиректить на index.php.

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f     #Если файл не существует
RewriteCond %{REQUEST_FILENAME} !-d    #И если папка не существует
RewriteRule ^.*$ index.php [QSA,L]

Причём это правило можно разместить как в .htaccess, так и в основном файле конфигурации Apache.

Для nginx соответствующее правило будет выглядеть вот так:

location / {
    if (!-e $request_filename) {
        rewrite ^/(.*)$ /index.php last;
    }
}

Всё просто.

Теперь рассмотрим кусок кода PHP в index.php, который анализирует ссылки и принимает решение — какой скрипт запускать.

В общем случае ссылка из $_SERVER[‘REQUEST_URI’] выглядит так

/часть1/часть2/часть3

Первое, что приходит в голову — разбить её с помощью explode(‘/’, $uri) и сделать сложный роутер на основе switch/case, анализирующий каждый кусок запроса. Не делайте этого! Это сложно и в итоге приводит код в ужасный непонимабельный и неконфигурабельный вид!

Я предлагаю более лаконичный способ. Лучше не описывать словами, а сразу показать код.

<?php

/**
 * Sitemap (можно перенести в отдельный файл)
 */
$GLOBALS['sitemap'] = array (
    '_404' => 'page404.php',   // Страница 404</span>
    '/' => 'mainpage.php',   // Главная страница
    '/news' => 'newspage.php',   // Новости - страница без параметров
    '/stories(/[0-9]+)?' => 'storypage.php',  // С числовым параметром
    // Больше правил
);

// Код роутера
class uSitemap {
    public $title = '';
    public $params = null;
    public $classname = '';
    public $data = null;

    public $request_uri = '';
    public $url_info = array();

    public $found = false;

    function __construct() {
        $this->mapClassName();
    }

    function mapClassName() {

        $this->classname = '';
        $this->title = '';
        $this->params = null;

        $map = &$GLOBALS['sitemap'];
        $this->request_uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        $this->url_info = parse_url($this->request_uri);
        $uri = urldecode($this->url_info['path']);
        $data = false;
        foreach ($map as $term => $dd) {
            $match = array();
            $i = preg_match('@^'.$term.'$@Uu', $uri, $match);
            if ($i > 0) {
                // Get class name and main title part
                $m = explode(',', $dd);
                $data = array(
                    'classname' => isset($m[0])?strtolower(trim($m[0])):'',
                    'title' => isset($m[1])?trim($m[1]):'',
                    'params' => $match,
                );
                break;
            }
        }
        if ($data === false) {
            // 404
            if (isset($map['_404'])) {
                // Default 404 page
                $dd = $map['_404'];
                $m = explode(',', $dd);
                $this->classname = strtolower(trim($m[0]));
                $this->title = trim($m[1]);
                $this->params = array();
            }
            $this->found = false;
        } else {
            // Found!
            $this->classname = $data['classname'];
            $this->title = $data['title'];
            $this->params = $data['params'];
            $this->found = true;
        }
        return $this->classname;
    }
}
$sm = new uSitemap();
$routed_file = $sm->classname; // Получаем имя файла для подключения через require()
require('app/'.$routed_file); // Подключаем файл

// P.S. Внутри подключённого файла Вы можете использовать параметры запроса,
// которые хранятся в свойстве $sm->params

Несмотря на то, что код довольно длинный, он прост логически. Мне не хочется его объяснять, я считаю любой код на PHP самообъясняющим, если он правильно написан. Учитесь читать код.

Подписаться
Уведомить о
guest
33 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Семен
Семен
10 лет назад

Очень грамотно написано про правило маршрутизации! Везде в интернете создают кучу лишних правил!
Подскажите пожалуйста, какое добавить условие, чтоб для админки была отдельная маршрутизация.
К примеру index.php в корне это основное, и админка в папке /admin
Спасибо!

Хм
Хм
10 лет назад

Ваш скрипт выдает ошибку:

Fatal error: Class ‘uKernel’ not found in Z:\home\festival\www\core\routing.php on line 37

Хм
Хм
10 лет назад
Ответить на  Epsilon

Возможно, я чего не допонимаю, но:
‘/kalendar’ => ‘index.php?page=1’, // Новости — страница без параметров

приводит к крашу:

Warning: require(index.php?page=1) [function.require]: failed to open stream: No error in Z:\home\festival\www\core\routing.php on line 78

возможно я должен как-то иначе передавать GET запрос к срипту?
и сразу же вопрос, как тогда передавать POST запрос (регистрация там, и все такое)? Т.е. придется указывать чпу адрес для скрипта?

Хм
Хм
10 лет назад
Ответить на  Epsilon

уяснил. Т.е. чтобы передать get-запрос мне нужно запрашивать не index.php?page=1, а kalendar?page=1
но тогда у меня возникает вопрос:
http://festival/kalendar?page=1 — можно ли как-то не отображать этот GET запрос? (Иначе смысл от этого чпу?)

большое спасибо, за подсказки!

Хм
Хм
10 лет назад
Ответить на  Epsilon

Но тогда страница регистрации будет:
http://festival/registration/2 — и это, эмм… некрасиво.
т.е. в случае с новостями http://festival/news/51 еще куда ни шло, или со статьями
http://festival/article/21 (но все равно в данном случае хотелось бы иметь ссылку еа подобии как у вас «samyj-prostoj-i-logichnyj-chpu-dlya-php» )
можно ли получить это самый get — kalendar/1 , а затем заменить его просто на kalendar в адресной строек?

Юрий Шевчук
Юрий Шевчук
10 лет назад

/nameNews.html
/categoryName.html
/categoryName/SubcategoryName.html
/categoryName//SubcategoryName/MultycategoryName.html

А как быть с префиксами страниц или вернее как их добавить?

10 лет назад
Ответить на  Юрий Шевчук

Что имеется ввиду под префиксами страниц?

Юрий Шевчук
Юрий Шевчук
10 лет назад
Ответить на  Alexey Khaydukov

.html

site.ru/ggg/ggg/ggg в таком варианте ссылок, поисковик плохо их индексирует 🙁

10 лет назад
Ответить на  Юрий Шевчук

Их можно просто добавить в выражение, например, так:

‘/(categoryName)/(SubcategoryName).html’ => ‘newspage.php’

Юрий Шевчук
Юрий Шевчук
10 лет назад
Ответить на  Alexey Khaydukov

нашол статью похожию немного на ваш вариант. http:// http://www.phpinfo.su/ articles/practice/ chpu_na_php.html

10 лет назад
Ответить на  Юрий Шевчук

Да, очень напоминает один из моих первоначальных вариантов этого же скрипта (когда в нём ещё было много лишнего).

10 лет назад
Ответить на  Alexey Khaydukov

Кстати приоткрою один секрет (в принципе, очевидная вещь для тех, кто читал статьи от яндекса по SEO): ссылки вида сайт.рф/категория1/подкатегория2/товар3 имеют меньший вес, чем ссылки вида товар.рф/товар3.

10 лет назад
Ответить на  Юрий Шевчук

Не совсем верно, поисковики не смотрят на расширения. Им важно содержание страницы и совпадение ключевых слов в title, url и h1.

Анонимно
Анонимно
9 лет назад

Здравствуйте! Сам использую аналогичную систему, но мучает маленький вопросик по поводу правильной переадресации на страницу 404.
Вариант 1 — если в базе не найдено соответствующей записи, то переадресовываем на страницу аля 404.php, где прописываем header(‘HTTP/1.1 404 Not Found’);
Вариант 2 — прямо в обработчике, без переадресации выдавать этот же хэдер и выводить на страницу предупреждение.
С точки зрения юзера, значения не имеет, а как лучше с точки зрения поисковых ботов — честно говоря, не знаю.

9 лет назад
Ответить на  Анонимно

Всё верно, не имеет значения ни для юзера, ни для SEO (поскольку поисковые боты видят страницы так же как и юзеры). Имеет значение лишь для программиста, а именно для структуры и логики вашего кода. Я применяю вариант с некоей дефолтовой вьюхой, которая в правилах у меня записана под ключом «__404», и в этой вьюхе помимо вывода красочного сообщения об ошибке также выдаётся хэдер 404.

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

alexander lysenko
alexander lysenko
9 лет назад

Добрый день!

Отличное решение, но столкнулся с тем, что для роутера адрес /test и /test/ это разные адреса. Как это можно починить?

9 лет назад
Ответить на  alexander lysenko

Добавлять в конце правила «[/]?». Например, в вашем случае это будет выглядеть вот так: ‘/test[/]?’.

alexander lysenko
alexander lysenko
9 лет назад
Ответить на  Alexey Khaydukov

Алексей!
Спасибо большое!

+маленький вопрос, возможно, для вас он слишком простой. как сделать, что бы ссылка /teST/ и /test/ для ЧПУ была одинаковой?

9 лет назад
Ответить на  alexander lysenko

Это можно сделать, добавив флаг безразличия к регистру в регулярное выражение (буква i в конце).

$i = preg_match(‘@^’.$term.’$@Uui’, $uri, $match);

Но вообще-то это не совсем правильно, потому что /teST/ и /test/ это разные ссылки.

alexander lysenko
alexander lysenko
9 лет назад
Ответить на  Alexey Khaydukov

Алексей, добрый вечер!

Еще раз спасибо, с безразличием ссылки попробую.

В качестве дискуссии.
Про разные ссылки — это интересный философский вопрос. Согласен, что это разные ссылки, но для большинства сайтов, наверно, лучше обезопасить урл от ошибки? Мне сложно представить ситуацию, в которой необходимо такое строгое соответствие. У вас есть какие-то аргументы в пользу того, что не нужно беспокоиться о таких ссылках?

9 лет назад
Ответить на  alexander lysenko

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

Впрочем, есть исключения — например, ссылки на ролики YouTube. Там id video что-то вроде FLK3_BqG0ycS1AneQ31G, и если поменять регистр хотя бы одной буквы, работать не будет. Из чего мы делаем вывод, что гугл не особо запаривается на тему «возможных ошибок» в URL. И я вам также не советую.

alexander lysenko
alexander lysenko
9 лет назад

Алексей, добрый вечер!

Еще вопрос по динамическим ссылкам.

Вот, так работает:
/download(/[0-9]+)?[/]?
/download/23232/ — все ок

Вот, не работает:
/download(/[0-9a-zA-Z]+)?[/]
/download/abz123abcz123/ — не работает((

Что делаю не так?

alexander lysenko
alexander lysenko
9 лет назад
Ответить на  alexander lysenko

Хотя, вот так сработало, вроде:
/download(/[0-9a-zA-Z]+)?[/]?

9 лет назад
Ответить на  alexander lysenko

Да, верно, слэш экранировать не нужно. А «любой символ, кроме слэша» можно записать и проще: [^/]
в итоге выражение будет таким

/download(/[^/]+)?[/]?

Хотя как правило используют следующую форму:

/download(/.+)?

alexander lysenko
alexander lysenko
9 лет назад
Ответить на  Alexey Khaydukov

Спасибо!

Сергей Валитов
Сергей Валитов
9 лет назад

Алексей, доброго времени суток! Разобрался я с вашим кодом)
У меня вопрос: Как организовать такую обработку, вида mysite/город/тип объявления/раздел/категория ? Причем, значение Город может быть опущено, как и тип объявления, так и раздел.. Получается, что надо создать в глобалс еще одно значение, где указать регулярку, которая подойдет для моего случая? А потом сверять эти params с данными из БД? И еще как вариант — не сверять данные с БД, а добавить все в дополнительный массив, и сверять с ним.. Всё равно все эти данные неизменны.. Подскажите самый оптимальный вариант?

9 лет назад
Ответить на  Сергей Валитов

Нужно поменять последовательность линков от общего к частному. То есть, сперва самая большая категория, потом поменьше, потом ещё меньше. В конце должны остаться категории, которые могут быть опущены. В вашем случае я бы порекомендовал следующее: mysite.com/категория/раздел/тип_объявления/город а вообще складывать все критерии в линк неправильно. То, что вы пытаетесь сделать очень напоминает ссылку на результат поиска, а такие ссылки удобнее всего делать с помощью GET-параметров. Кроме того, можно использовать любые разделители, не обязательно «слэши». Например, я бы сделал так mysite.com/раздел/категория/?bt=тип_объявления&bc=город город часто ещё выносят в субдомен, например город.mysite.com/раздел/категория/?bt=тип_объявления Соответственно в массив router попадут только те части правил, которые в path-части ссылки,… Подробнее »

Сергей Валитов
Сергей Валитов
9 лет назад
Ответить на  Alexey Khaydukov

Алексей, очень толково объяснили! Вашему опыту я доверяю, и во многом согласен. Подскажите, а можно не вручную создавать субдомены? Есть какие способы делать это «на ходу?». Мы пишем — город.сайт.ру, но физически папки город у нас нет, просто срабатывает этакая хитрая обработка ссылки, с дальнейшим получением первой части URI? Что-то подсказывает, что это невозможно, и в любом случае приходится создавать вручную, это ведь так? Просто с этой адресацией на хостинге и субдоменами я не особо знаком, скажу честно. И еще вопрос: GET параметры такого вида не пагубно влияют на пресловутое SEO? А то мне человек предлагал сделать ссылку именно такого… Подробнее »