ЧПУ на PHP. Прочь от ModeRewrite правил. Единая точка входа и роутинг на PHP.

Человекопонятный URL (ЧПУ или роутинг) - одна из самых часто затрагиваемых тем на различных форумах, посвящённых языку PHP. Можно до бесконечности спорить, нужны ли красивые URL-адреса для SEO-оптимизации, но факт того, что веб-сайт с ЧПУ выглядит аккуратно и профессионально отрицать глупо.

URL адрес вида

http://server.com/catalog/tv/samsung/5

выглядит опрятно и интуитивно понятно для пользователя, нежели адрес вида

http://server.com/catalog.php?product=tv&brand=samsung&page=5

Кроме того, более-менее продвинутый пользователь в строке адреса броузера может стереть или заменить определенный путь иерархии в URL адресе, попав тем самым в нужное для него место на сайте (об этом хорошо написал Лебедев ещё в 2000 году - "Боремся за чистоту урлов", "Дублирующая навигация").

Роутинг на базе модуля Apache mod_rewrite

Долгое вря ЧПУ делались с помощью небезызвестного модуля веб-сервера Apache mod_rewrite, который предназначен для манипуляции URL адресами. Директивы для mod_rewrite обычно писались разработчиками в .htaccess файле конфигурации Apache и выглядели примерно так:

RewriteEngine on
RewriteBase /

# Досье пользователя.
RewriteRule ^users/([0-9]+)\.html$	userinfo.php?user_id=$1
# Список всех пользователей
RewriteRule ^users/?$			    users.php?%{QUERY_STRING}

# Новости общим списком
RewriteRule ^news/?$			    news.php
# Новости по разделам
RewriteRule ^news/([a-z_]+)/?$		news.php?cat=$1
# Страница одной новости
RewriteRule ^news/([0-9]+).html$	news.php?id=$1

Чем плох подобный механизм преобразований URL адресов, освоенный на mode_rewrite? Ничем не плох, но для разработки гибких веб-приложений он не подходит. В первую очередь потому, что преобразованием занимается сам mod_rewrite и система фактически завязана на файле конфигурации .htaccess. Мы не имеем возможности влиять на процесс преобразования, добавлять в автоматическом режиме новые виртуальные URL адреса, привязывать сложную логику к определённым виртуальным URL-адресам и делать многое другое. В конце-концов, файл конфигурации .htaccess всё-таки больше файл глобальных настроек уровня сервера и его модулей, но никак не web-приложения.

Роутинг на PHP

Конечно же, на одном PHP ЧПУ не сделать. Моудуль mode_rewrite как и раньше нужно использовать, но только лишь для того, что бы перенаправить все запросы к виртуальным ЧПУ-адресам в единую точку входа в приложении - в index.php. Для этого в .htaccess файле конфигурации можно прописать следующий код:

<IfModule mod_rewrite.c>
RewriteEngine On
Options +FollowSymlinks
RewriteBase /

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]
</IfModule>

Данная запись означает буквально следующее: если запрошенный URL-адрес не является файлом и не является директорией, то подменить виртуальный адрес файлом index.php. При этом, суперглобальная переменная PHP $_SERVER['REQUEST_URI'] будет содержать именно запрошенный виртуальный адрес! Что это нам даёт? Фактически - безграничные возможности для манипулирования виртуальными адресами. Механизм разбора таких виртуальных URL-адресов на урвне PHP называется роутингом.

Пример № 1.

Во многих фреймворках роутинг строится по следующему принципу:

http://server.com/модуль/действие/параметр1/значение1/параметр2/значение2/параметрН/значениеН/

"Распарсить" такой URL и получить имя модуля, действие и параметры нет никаких проблем.

В данном случае под определением "модуль" и "действие" можно понимать что угодно:

  • Модуль - подключаемый файл, действие - функция или конструкция в блоке if-else
  • Модуль - класс, действие - метод класса

т.е. все зависит от вашей архитектуры приложения.

Далее по тексту "модуль" и "действие" будет также называться совокупным определением "обработчик".

Возьмем в пример такой URL-адрес:

http://localhost/guestbook/edit_message/id/123

и "распарсим" его с помощью нижестоящего кода, который поместим в единую точку входа - в index.php:

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

// Назначаем модуль и действие по умолчанию.
$module = 'index';
$action = 'index';
// Массив параметров из URI запроса.
$params = array();

// Если запрошен любой URI, отличный от корня сайта.
if ($_SERVER['REQUEST_URI'] != '/') {
	try {
		// Для того, что бы через виртуальные адреса можно было также передавать параметры
		// через QUERY_STRING (т.е. через "знак вопроса" - ?param=value),
		// необходимо получить компонент пути - path без QUERY_STRING.
		// Данные, переданные через QUERY_STRING, также как и раньше будут содержаться в 
		// суперглобальных массивах $_GET и $_REQUEST.
		$url_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

		// Разбиваем виртуальный URL по символу "/"
		$uri_parts = explode('/', trim($url_path, ' /'));

		// Если количество частей не кратно 2, значит, в URL присутствует ошибка и такой URL
		// обрабатывать не нужно - кидаем исключение, что бы назначить в блоке catch модуль и действие,
		// отвечающие за показ 404 страницы.
		if (count($uri_parts) % 2) {
			throw new Exception();
		}

		$module = array_shift($uri_parts); // Получили имя модуля
		$action = array_shift($uri_parts); // Получили имя действия

		// Получили в $params параметры запроса
		for ($i=0; $i < count($uri_parts); $i++) {
			$params[$uri_parts[$i]] = $uri_parts[++$i];
		}
	} catch (Exception $e) {
		$module = '404';
		$action = 'main';
	}
}

echo "\$module: $module\n";
echo "\$action: $action\n";
echo "\$params:\n";
print_r($params);

Вот так будет выглядеть результат для URL-адреса http://localhost/guestbook/edit_message/id/123:

$module: guestbook
$action: edit_message
$params:
Array
(
    [id] => 123
)

Теперь, если у вас есть класс Guestbook, отвечающий за работу гостевой книги, то вы просто инстанцируете этот класс и вызываете его метод отвечающий за редактирование сообщения. Массив $params можно передать в метод в качестве аргумента метода:

$guestbook = new $module();
$guestbook->$action($params); // редактирование сообщения с ID = 123

но лучше всего при подобном подходе поместить все полученные переменные из парсинга URL адреса в суперглобальный массив $_REQUEST:

$_REQUEST = array_merge($_REQUEST, $params);

Если же будет запрошен ошибочный URL -адрес:

http://localhost/guestbook/edit_message/id/

то сработает исключение и в качестве обработчика будет фигурировать обработчик 404 ошибки:

$module: 404
$action: main
$params:
Array
(
)

Пример № 2.

Для понимания следующего подхода вам необходимы базовые знания Perl-совместимых регулярных выражений (POSIX).

Данный подход очень гибок и интересен, т.к. позволяет создавать практические любые виртуальные адреса, с абсолютно произвольной структурой и в отличие от предыдущего подхода - без упоминания в URL реального имени обработчика (модуля или действия). Суть метода заключается в следующем: каждый виртуальный URL-адрес, который может быть в системе, необходимо описать в виде регулярного выражения. Назначить для него обработчик. Если запрошенный URL совпадает с одним из таких регулярных выражений, то с помощью функций регулярных выражений нужно получить из URL необходимые данные, после чего передать их в обработчик. Пример:

// Конфигурация маршрутов URL проекта.
$routes = array
(
	// Главная страница сайта (http://localhost/)
	array(
	// паттерн в формате Perl-совместимого реулярного выражения
	'pattern' => '~^/$~',
	// Имя класса обработчика 
	'class' => 'Index',
	// Имя метода класса обработчика
	'method' => 'index'
	),
	
	// Страница регистрации пользователя (http://localhost/registration.xhtml)
	array(
	'pattern' => '~^/registration\.xhtml$~',
	'class' => 'User',
	'method' => 'registration',
	),
	
	// Досье пользователя (http://localhost/userinfo/12345.xhtml)
	array(
	'pattern' => '~^/userinfo/([0-9]+)\.xhtml$~',
	'class' => 'User',
	'method' => 'infoInfo',
	// В aliases перечисляются имена переменных, которые должны быть в дальнейшем созданы 
	// и заполнены значениями, взятыми на основании разбора URL адреса. 
	// В данном случае в переменную user_id должен будет записаться числовой 
	// идентификатор пользователя - 12345
	'aliases' => array('user_id'),
	),

	// Форум (http://localhost/forum/web-development/php/12345.xhtml)
	array(
	'pattern' => '~^/forum(/[a-z0-9_/\-]+/)([0-9]+)\.xhtml$~',
	'class' => 'Forum',
	'method' => 'viewTopick',
	// Будут созданы переменные:
	// forum_url = '/web-development/php/'
	// topic_id = 12345
	'aliases' => array('forum_url', 'topic_id'),
	),

	// и т.д.
);

Как видно из примера, так нужно будет описать все виртуальные URL адреса проекта. Напишем обработчик таких URL адресов:

<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);

// Тут нужно подключить через include файл с маршрутами, который описали выше.

// Назначаем модуль и действие по умолчанию.
$module = 'Not_Found';
$action = 'main';

// Массив параметров из URI запроса.
$params = array();

// Для того, что бы через виртуальные адреса можно было также передавать параметры
// через QUERY_STRING (т.е. через "знак вопроса" - ?param=value),
// необходимо получить компонент пути - path без QUERY_STRING, т.к. в ином
// случае виртуальный адрес попросту не совпадет ни с одним паттерном из массива $routes.
// Данные, переданные через QUERY_STRING, также как и раньше будут содержаться в 
// суперглобальных массивах $_GET и $_REQUEST.
$url_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

foreach ($routes as $map)
{
	if (preg_match($map['pattern'], $url_path, $matches))
	{
		// Выталкиваем первый элемент - он содержит всю строку URI запроса
		// и в массиве $params он не нужен.
		array_shift($matches);

		// Формируем массив $params с теми названиями ключей переменных,
		// которые мы указали в $routes
 		foreach ($matches as $index => $value)
		{
			$params[$map['aliases'][$index]] = $value;
		}

		$module = $map['class'];
		$action = $map['method'];

		break;
	}
}

echo "\$module: $module\n";
echo "\$action: $action\n";
echo "\$params:\n";
print_r($params);

Теперь при запросе

http://localhost/forum/web-development/php/12345.xhtml

мы получим информацию о нужном нам обработчике (классе и методе класса), а так же массив параметров, с помощью которых и получим из СУБД информацию о теме в форуме:

$module: Forum
$action: viewTopick
$params:
Array
(
    [forum_url] => /web-development/php/
    [topic_id] => 12345
)

Соответственно, инстанцирование нужного обработчика, т.е. класса Forum и запуск его метода viewTopick аналогичны запуску обработчика из примера №1.

CSS и JS

После публикаци статьи была масса вопросов относительно того, что не подгружаются картинки, CSS, JS и в целом любые иные подключаемые файлы. Это не удивительно, если вы указываете пути в виде `../images/image.jpg`. Пишите всегда путь относительно корня сайта `/images/image.jpg` и у вас не будет никогда проблем. Подробнее про пути можно прочесть здесь.