ЧПУ, роутинг, единая точка входа на PHP

Александр Кичатов

Единая точка входа

Принцип работы единой точки входа очень прост - мы настраиваем веб-сервер так, чтобы все HTTP-запросы, вне зависимости от их URL, обрабатывались одним и тем же PHP скриптом - index.php.

HTTP-запросы отправляются на сервер, где перенаправляются на index.php
Перенаправление всех запросов на index.php

Благодаря этому у нас появляется возможность полноценно использовать ЧПУ. Ведь запрос с любым URL, даже site.ru/h804gh4hdg/srohhg4/sjip4 будет уходить на index.php.

А дальше мы уже сами решим, какой контент отображать на этой странице.

Однако в схеме выше есть одно упущение. Ведь если на сервер пришёл запрос к существующему файлу (style.css, script.js, logo.png и т.д) - сервер должен отдать этот файл, а не перенаправлять его.

Обработка запросов при использовании единой точки входа
Принцип работы единой точки входа

Вот и весь принцип единой точки входа. Именно так она работает в популярных CMS вроде WordPress и Opencart, в фреймворках Laravel, Symfony и т.д.

Единственный вопрос, который вам останется решить - что делать с запросами к существующим папкам.

Лично я предпочитаю также перенаправлять их на index.php.

На самом деле на сайтах часто используются 2 точки входа.

Первая - index.php, вторая - отдельный скрипт, предназначенный для работы с сайтом через консоль.

Плюсы единой точки входа

  • Позволяет использовать ЧПУ
  • Позволяет полностью управлять URL-адресами в PHP, в том числе хранить URL-адреса в базе данных
  • Скрипты с конфигами, важными функциями и библиотеками подключаются только 1 раз и становятся доступны везде. Не нужно дублировать их подключение где-либо ещё.

Единая точка входа с Apache

Для настройки единой точки входа необходимо добавить несколько строк в конфиг веб-сервера. Проще всего это сделать с помощью файла .htaccess.

Этот файл позволяет переопределять настройки Apache для определённых сайтов и папок.

Добавляем следующие настройки в .htaccess:

# Включаем перенаправление
RewriteEngine On
# Не применять к существующим файлам файлам
RewriteCond %{REQUEST_FILENAME} !-f
# Не применять к существующим директориям
RewriteCond %{REQUEST_FILENAME} !-d
# Редирект всех запросов на index.php
# L означает Last, нужен чтобы на этом этапе mod_rewrite сразу остановил работу.
# Короче, небольшое увеличение производительности.
RewriteRule .* index.php [L]

Чтобы перенаправление срабатывало для существующих директорий, удаляем строку с !-d в конце, вот так:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule .* index.php [L]

Готово. Получить URL адрес текущей страницы можно из переменной $_SERVER['REQUEST_URI'].

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

RewriteRule ^(.*)$ index.php?url_param=$1 [L,QSA]

Главное отличие в том, что URL-адрес текущей страницы будет храниться как в $_SERVER['REQUEST_URI'], так и в отдельном GET-параметре, в нашем случае $_GET['url_param'], причём этот URL будет очищен от GET-параметров.

Флаг QSA нужен, поскольку без него GET-параметры не будут работать, т.е. массив $_GET будет содержать только url_param и больше ничего.

Какой из двух вариантов выбрать - решать вам, лично мне больше нравится первый.

Единая точка входа с Nginx

Открываем конфиг домена и внутри секции server прописываем следующее правило:

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

Простой роутинг

Если единая точка входа настроена правильно, то при заходе по любому несуществующему URL-адресу, например /test должен запуститься файл index.php.

URL текущей страницы находится в переменной $_SERVER['REQUEST_URI']

<?php
var_dump($_SERVER['REQUEST_URI']); // /test

Теперь мы можем написать очень простой роутер, который смотрит на текущий URL и подключает соответствующий скрипт:

<?php
$uri = $_SERVER['REQUEST_URI'];

if($uri === '/')
	require 'pages/main.php';
elseif($uri === '/about')
	require 'pages/about.php';
else
	require 'pages/error404.php';

Внесём ещё пару доработок. Во-первых, зачастую URL-адреса должны работать вне зависимости от наличия GET-параметров, поэтому вырежем их из URI:

// /about?id=5 превратится в /about
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

Кроме этого, часто требуется получить доступ к определённой части URL. Для этого разобьём URL на части по слешу:

$segments = explode('/', trim($uri, '/'));

В переменной $segments для URL /products/15 будет лежать массив вида [0 => 'products', 1 => '15'].

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

<?php
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

$segments = explode('/', trim($uri, '/'));

if($segments[0] === 'admin')
{
	if($segments[1] === 'users')
		$file = 'admin_users.php';
	elseif($segments[1] === 'products')
		$file = 'admin_products.php';
	else
		$file = 'admin_404.php';
}
else
{
	if($uri === '/')
		$file = 'main.php';
	elseif($uri === '/about')
		$file = 'about.php';
	else
		$file = '404.php';
}

require 'pages/' . $file;

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

При хранении URL адресов в базе данных роутинг будет выглядеть примерно так (реальный код зависит от библиотеки, которую вы используете для взаимодействия с БД):

<?php
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = explode('/', trim($uri, '/'));

$current_page = $db->select('SELECT * FROM `pages` WHERE `url` = ?', [$uri])->first();

// Если статьи нет - показываем ошибку
if(!$current_page)
	require 'pages/404.php';
// Если статья есть - подключаем шаблон, в котором будет доступна переменная $current_page
else
	require 'pages/article.php';

Роутинг средствами htaccess

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

RewriteRule ^/product-(.*)_([0-9]+).php /redirectold.php?productid=$2

RewriteRule ^products/([^/]+)/([^/]+)/([^/<WBR>]+) product.php?category=$1&brand=$2&<WBR>product=$3

RewriteRule ^news/20[0-9]{2}/[0-9]{2}/[0-9]{2}/[^/]+\.html index.php

RewriteCond %{DOCUMENT_ROOT}/name/$1.php -f
RewriteRule ^([^/]+)/([^/]+)/?$ $.php1?action=$2 [L,NC,QSA]

RewriteCond %{DOCUMENT_ROOT}/name/$1.php -f
RewriteRule ^([^/]+)/([^/]+)/([^/]+)/?$ $1.php?action=$2&id=$3 [L,NC,QSA]

У этого подхода есть несколько недостатков:

  • Плохая читаемость правил
  • Нужно хорошо знать регулярки
  • Хранение правил роутинга в настройках веб-сервера - концептуально не очень хорошая идея

Короче, не используйте этот подход.

Мем больной ублюдок - люблю настраивать роутинг в htaccess

Структура URL адресов в админке

Обычно URL адреса в админке формируются по одной из следующих схем:

/модуль/действие/параметр1/значение1/параметр2/значение2
/модуль/действие/значение1/значение2

И сразу рассмотрим простой пример:

/products - просмотр каталога
/products/add - добавление товара
/products/update - обновление товара
/products/delete - удаление товара

Итак, мы видим, что модулем здесь является products, а действием, к примеру, add. Что теперь с этим делать?

Если вы знакомы с ООП и MVC, тогда модулем для вас будет название класса, а действием - метод этого класса, который нужно запустить. Если действие не указано, то принято запускать метод под названием index.

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

Перепишем пример, написанный нами в единой точке входа, под новую схему URL:

<?php
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = explode('/', trim($uri, '/'));

$file = 'pages/' . $segments[0] . '.php';

if(file_exists($file))
	require $file;
else
	require 'pages/404.php';

Итак, мы берём 1-ый фрагмент URL и проверяем, существует ли в папке pages файл с таким названием.

Т.е. при переходе на страницу /test/test2 скрипт проверит существование файла /pages/test.php. Если файл есть - PHP выполнит этот файл, в противном случае выполнится файл /pages/404.php.

Как видите, при таком подходе нам больше не нужно прописывать соответствие URL-адресов и PHP-файлов. PHP сам будет искать нужный файл в папке pages по первому фрагменту URL.

Теперь осталось только создать файл pages/products.php. Сделаем небольшую заготовку:

<?php
if(empty($segments[1]))
{
	// Отображаем каталог товаров
}
elseif($segments[1] === 'add')
{
	// Если запрос пришёл методом POST
	if($_SERVER['REQUEST_METHOD'] === 'POST')
	{
		// добавляем новый товар в базу
	}
	// Если запрос пришёл методом GET
	else
	{
		 // отображаем форму добавления товара
	}
}

Вот так выглядит обработка действий. Мы смотрим на второй фрагмент URL и ищем обработчик этого действия. Для каждого действия (add, update, delete) нужно прописать отдельный блок elseif.

Внутри обработчика add мы смотрим на то, каким методом пришёл запрос, GET или POST. Если GET - отображаем форму, если POST - добавляем товар.

Если вам не нравится вложенная проверка метода, можно сделать иначе. В файле index.php сохраним метод в отдельную переменную:

$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = explode('/', trim($uri, '/'));
$method = $_SERVER['REQUEST_METHOD'];
// ...
Затем в products.php меняем заготовку на следующую:
<?php
if(empty($segments[1]) && $method === 'GET')
{
	// Отображаем каталог товаров
}
elseif($segments[1] === 'add' && $method === 'GET')
{
	// отображаем форму добавления товара
}
elseif($segments[1] === 'add' && $method === 'POST')
{
	// добавляем новый товар в базу
}

Готово. Да, если вам не нравится, что в коде 2 раза встречается одно и то же действие, только с разными методами, можете использовать немного упрощённую схему URL-адресов из фреймворка Laravel:

(GET) /products - отображение товаров
(GET) /products?id=5 - отображение 5-ой страницы товаров
(GET) /products/create - отображение формы добавления товара
(POST) /products/store - сохранение товара из формы добавления
(GET) /products/edit/15 - отображение формы редактирования товара с id=15
(POST) /products/update - сохранение товара из формы редактирования
(POST) /products/destroy - удаление товара по его идентификатору в базе

Добавление префикса /admin/ в URL

Немного изменим код index.php:

<?php
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = explode('/', trim($uri, '/'));

if($segments[0] === 'admin')
{
	$file = 'pages/admin_' . $segments[1] . '.php';

	if(file_exists($file))
		require $file;
	else
		require 'pages/admin_404.php';
}
else
	require 'pages/404.php';

Теперь при запросе страницы /admin/products PHP будет искать файл с названием не products.php, а admin_products.php.

Переименуйте файл и не забудьте заменить в нём все $segments[1] на $segments[2], поскольку в $segments[1] теперь лежит модуль, а в $segments[2] действие.

Продвинутый роутер FastRoute

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

Если вы хотите, чтобы я написал отдельную статью по работе с FastRoute - пишите об этом в комментариях.

Комментарии