Самый простой и логичный ЧПУ для 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 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Семен
Семен
7 лет назад

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

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

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

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

Хм
Хм
7 лет назад
Reply to  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 запрос (регистрация там, и все такое)? Т.е. придется указывать чпу адрес для скрипта?

Хм
Хм
7 лет назад
Reply to  Epsilon

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

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

Хм
Хм
7 лет назад
Reply to  Epsilon

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

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

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

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

6 лет назад

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

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

.html

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

6 лет назад

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

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

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

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

6 лет назад

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

6 лет назад

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

6 лет назад

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

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

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

6 лет назад

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

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

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

Добрый день!

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

5 лет назад

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

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

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

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

5 лет назад

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

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

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

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

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

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

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

5 лет назад

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

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

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

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

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

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

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

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

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

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

5 лет назад

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

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

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

/download(/.+)?

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

Спасибо!

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

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

5 лет назад

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

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

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