Решил написать эту заметку, потому как надоело отвечать 100500 раз одно и то же на ВиО.
Многие начинающие веб-программисты рано или поздно сталкиваются с задачей внедрения в свой сайт человеко-понятных линков (ЧПУ). До внедрения ЧПУ все ссылки имеют вид /myscript.php или даже /myfolder/myfolder2/myscript3.php, что тяжело для запоминания и ещё хуже для SEO. После внедрения ЧПУ линки принимают вид /statiya-o-php или даже на кириллице /статья-о-пхп.
Кстати о SEO. Человекопонятные линки на САМОМ деле придумали не для удобного запоминания, а в основном для повышения индексируемости сайта, потому что совпадение поискового запроса и части URL даёт хорошее преимущество в рейтинге поиска.
Эволюция начинающего PHP-программиста может быть выражена следующей последовательностью шагов:
- Размещение plain-PHP кода в отдельных файлах и доступ к этим файлам через линки вида /myfolder/myscript.php
- Понимание, что все скрипты имеют значительную часть общего (например, создание подключения к БД, чтение конфигурации, запуск сессии и проч.) и как следствие создание общей начальной точки «входа», некоторого скрипта, который принимает ВСЕ запросы, а потом выбирает — какой внутренний скрипт подключить. Обычно этот скрипт имеет имя index.php и лежит в корне, вследствие чего все запросы (они же URLы) выглядят так: /index.php?com=myaction&com2=mysubaction
- Необходимость внедрения роутера и переход к человекопонятным линкам.
Замечу, что между пунктами 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 самообъясняющим, если он правильно написан. Учитесь читать код.
Очень грамотно написано про правило маршрутизации! Везде в интернете создают кучу лишних правил!
Подскажите пожалуйста, какое добавить условие, чтоб для админки была отдельная маршрутизация.
К примеру index.php в корне это основное, и админка в папке /admin
Спасибо!
Семён, можно использовать дополнительную строку в контексте существующих. Обратите внимание на её местоположение. RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f #Если файл не существует RewriteCond %{REQUEST_FILENAME} !-d #И если папка не существует RewriteRule ^/admin(/.*)?$ admin/index.php [QSA,L] # Added RewriteRule ^.*$ index.php [QSA,L] Однако я не рекомендую рассматривать админку как нечто отделённое от основного сайта. Админка должна быть приложением, написанном на базе того же ядра, что и основной сайт, посему такая дополнительная строка для админки в подавляющем числе случаев не требуется. Можно добавить в роутер в PHP простое правило, например, '/admin(/.*)' => 'admin.php', И уже в файле admin.php анализировать «хвостик» и сделать что-то… Подробнее »
Ваш скрипт выдает ошибку:
Fatal error: Class ‘uKernel’ not found in Z:\home\festival\www\core\routing.php on line 37
Спасибо, исправил ошибку (писалось под движок с ядром, потому не учёл этот момент сразу).
Возможно, я чего не допонимаю, но:
‘/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 запрос (регистрация там, и все такое)? Т.е. придется указывать чпу адрес для скрипта?
Разумеется будет ошибка, ведь справа должен быть указан конкретный файл, а не запрос. То есть следовало бы указать просто ‘index.php’, т.к. файла с именем index.php?page=1 у тебя нет и быть не может (знак вопроса — недопустимый символ в файловой системе). Приведённый мной роутер работает только с path-частью запроса, то есть той частью, которая есть путь. Ни GET-параметры, ни POST-параметры роутером не рассматриваются вообще. То есть я хочу сказать, что роутер сопоставляет path-часть URL’а с каким-то твоим php-файлом, а всё, что в URL есть после знака вопроса (page=1, например), оно так и попадёт в $_GET. Ну и то, что передаётся через… Подробнее »
уяснил. Т.е. чтобы передать get-запрос мне нужно запрашивать не index.php?page=1, а kalendar?page=1
но тогда у меня возникает вопрос:
http://festival/kalendar?page=1 — можно ли как-то не отображать этот GET запрос? (Иначе смысл от этого чпу?)
большое спасибо, за подсказки!
Ну да, в этом же и суть ЧПУ, что запрос выглядит как простой путь /часть1/часть2/часть3, а на стороне сервера он отображается в конкретный php-файл, например, index.php.
Поэтому с ЧПУ можно сделать так http://festival/kalendar/1, и строка роутинга будет выглядеть вот так
‘/kalendar/(\d+)’ => ‘index.php’,
тогда после роутинга число, которое попало в круглые скобки, будет лежать в $this->params[1], точно так же как оно раньше лежало в $_GET[‘page’].
Но тогда страница регистрации будет:
http://festival/registration/2 — и это, эмм… некрасиво.
т.е. в случае с новостями http://festival/news/51 еще куда ни шло, или со статьями
http://festival/article/21 (но все равно в данном случае хотелось бы иметь ссылку еа подобии как у вас «samyj-prostoj-i-logichnyj-chpu-dlya-php» )
можно ли получить это самый get — kalendar/1 , а затем заменить его просто на kalendar в адресной строек?
Совсем необязательно соблюдать конкретно такой формат пути для всех случае жизни. В случае с регистрацией номер вообще ни к чему, пусть будет ‘/registration’. В случае с новостями можно сделать так
‘/news/samyj-prostoj-i-logichnyj-chpu-dlya-php’
только тогда строка роутера будет такой
‘/news/([^/]+)’ => ‘news.php’,
и после срабатывания этого правила информационная часть «samyj-prostoj-i-logichnyj-chpu-dlya-php», по которой ты сможешь найти новость в БД, будет находиться в $this->params[1].
/nameNews.html
/categoryName.html
/categoryName/SubcategoryName.html
/categoryName//SubcategoryName/MultycategoryName.html
А как быть с префиксами страниц или вернее как их добавить?
Что имеется ввиду под префиксами страниц?
.html
site.ru/ggg/ggg/ggg в таком варианте ссылок, поисковик плохо их индексирует 🙁
Их можно просто добавить в выражение, например, так:
‘/(categoryName)/(SubcategoryName).html’ => ‘newspage.php’
нашол статью похожию немного на ваш вариант. http:// http://www.phpinfo.su/ articles/practice/ chpu_na_php.html
Да, очень напоминает один из моих первоначальных вариантов этого же скрипта (когда в нём ещё было много лишнего).
Кстати приоткрою один секрет (в принципе, очевидная вещь для тех, кто читал статьи от яндекса по SEO): ссылки вида сайт.рф/категория1/подкатегория2/товар3 имеют меньший вес, чем ссылки вида товар.рф/товар3.
Не совсем верно, поисковики не смотрят на расширения. Им важно содержание страницы и совпадение ключевых слов в title, url и h1.
Здравствуйте! Сам использую аналогичную систему, но мучает маленький вопросик по поводу правильной переадресации на страницу 404.
Вариант 1 — если в базе не найдено соответствующей записи, то переадресовываем на страницу аля 404.php, где прописываем header(‘HTTP/1.1 404 Not Found’);
Вариант 2 — прямо в обработчике, без переадресации выдавать этот же хэдер и выводить на страницу предупреждение.
С точки зрения юзера, значения не имеет, а как лучше с точки зрения поисковых ботов — честно говоря, не знаю.
Всё верно, не имеет значения ни для юзера, ни для SEO (поскольку поисковые боты видят страницы так же как и юзеры). Имеет значение лишь для программиста, а именно для структуры и логики вашего кода. Я применяю вариант с некоей дефолтовой вьюхой, которая в правилах у меня записана под ключом «__404», и в этой вьюхе помимо вывода красочного сообщения об ошибке также выдаётся хэдер 404.
Переадресацию тут делать смысла не имеет, поскольку в адресной строке должен оставаться адрес той страницы, которую юзер пытается открыть. И для SEO это важно, поскольку поисковые боты должны знать, что страницы с таким адресом больше нет.
Добрый день!
Отличное решение, но столкнулся с тем, что для роутера адрес /test и /test/ это разные адреса. Как это можно починить?
Добавлять в конце правила «[/]?». Например, в вашем случае это будет выглядеть вот так: ‘/test[/]?’.
Алексей!
Спасибо большое!
+маленький вопрос, возможно, для вас он слишком простой. как сделать, что бы ссылка /teST/ и /test/ для ЧПУ была одинаковой?
Это можно сделать, добавив флаг безразличия к регистру в регулярное выражение (буква i в конце).
$i = preg_match(‘@^’.$term.’$@Uui’, $uri, $match);
Но вообще-то это не совсем правильно, потому что /teST/ и /test/ это разные ссылки.
Алексей, добрый вечер!
Еще раз спасибо, с безразличием ссылки попробую.
В качестве дискуссии.
Про разные ссылки — это интересный философский вопрос. Согласен, что это разные ссылки, но для большинства сайтов, наверно, лучше обезопасить урл от ошибки? Мне сложно представить ситуацию, в которой необходимо такое строгое соответствие. У вас есть какие-то аргументы в пользу того, что не нужно беспокоиться о таких ссылках?
Александр, просто попробуйте вспомнить, когда вы последний раз набирали ссылку на конкретную страницу вручную и при этом должны были переключать регистр символов. В подавляющем большинстве ссылок не используются заглавные буквы, соответственно мало кто будет набирать ссылку целиком, да ещё и заглавными буквами.
Впрочем, есть исключения — например, ссылки на ролики YouTube. Там id video что-то вроде FLK3_BqG0ycS1AneQ31G, и если поменять регистр хотя бы одной буквы, работать не будет. Из чего мы делаем вывод, что гугл не особо запаривается на тему «возможных ошибок» в URL. И я вам также не советую.
Алексей, добрый вечер!
Еще вопрос по динамическим ссылкам.
Вот, так работает:
/download(/[0-9]+)?[/]?
/download/23232/ — все ок
Вот, не работает:
/download(/[0-9a-zA-Z]+)?[/]
/download/abz123abcz123/ — не работает((
Что делаю не так?
Хотя, вот так сработало, вроде:
/download(/[0-9a-zA-Z]+)?[/]?
Да, верно, слэш экранировать не нужно. А «любой символ, кроме слэша» можно записать и проще: [^/]
в итоге выражение будет таким
/download(/[^/]+)?[/]?
Хотя как правило используют следующую форму:
/download(/.+)?
Спасибо!
Алексей, доброго времени суток! Разобрался я с вашим кодом)
У меня вопрос: Как организовать такую обработку, вида mysite/город/тип объявления/раздел/категория ? Причем, значение Город может быть опущено, как и тип объявления, так и раздел.. Получается, что надо создать в глобалс еще одно значение, где указать регулярку, которая подойдет для моего случая? А потом сверять эти params с данными из БД? И еще как вариант — не сверять данные с БД, а добавить все в дополнительный массив, и сверять с ним.. Всё равно все эти данные неизменны.. Подскажите самый оптимальный вариант?
Нужно поменять последовательность линков от общего к частному. То есть, сперва самая большая категория, потом поменьше, потом ещё меньше. В конце должны остаться категории, которые могут быть опущены. В вашем случае я бы порекомендовал следующее: mysite.com/категория/раздел/тип_объявления/город а вообще складывать все критерии в линк неправильно. То, что вы пытаетесь сделать очень напоминает ссылку на результат поиска, а такие ссылки удобнее всего делать с помощью GET-параметров. Кроме того, можно использовать любые разделители, не обязательно «слэши». Например, я бы сделал так mysite.com/раздел/категория/?bt=тип_объявления&bc=город город часто ещё выносят в субдомен, например город.mysite.com/раздел/категория/?bt=тип_объявления Соответственно в массив router попадут только те части правил, которые в path-части ссылки,… Подробнее »
Алексей, очень толково объяснили! Вашему опыту я доверяю, и во многом согласен. Подскажите, а можно не вручную создавать субдомены? Есть какие способы делать это «на ходу?». Мы пишем — город.сайт.ру, но физически папки город у нас нет, просто срабатывает этакая хитрая обработка ссылки, с дальнейшим получением первой части URI? Что-то подсказывает, что это невозможно, и в любом случае приходится создавать вручную, это ведь так? Просто с этой адресацией на хостинге и субдоменами я не особо знаком, скажу честно. И еще вопрос: GET параметры такого вида не пагубно влияют на пресловутое SEO? А то мне человек предлагал сделать ссылку именно такого… Подробнее »