Фронт-контроллер и роутинг в PHP
В прошлом уроке мы добавили в контроллер 2 экшена и стали проверять в index.php GET-параметр. В зависимости от этого параметра мы решали, какой из экшенов вызвать и что передать в качестве аргументов. А что будет, когда нам на сайте понадобится более 100 страниц? Для каждого добавлять if? Согласитесь, неудобно. В этом уроке мы сделаем удобную систему для обработки адресов сайта – роутинг (от англ. routing - маршрутизация).
Если вы не работали ранее с регулярными выражениями – пройдите урок по регуляркам в PHP.
Apache RewriteEngine
Для начала немного магии. Создайте в директории www файл .htaccess и запишите в него следующее содержимое:
RewriteEngine On
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteRule ^(.*)$ ./index.php?route=$1 [QSA,L]
Это – специальный файл с конфигурацией для веб-сервера Apache. Если забыли – то это именно он обрабатывает запросы от пользователя и передаёт их дальше интерпретатору PHP. Подробнее об этом читайте в статье "как работает PHP". Когда Apache находит в директории файл с именем .htaccess он понимает, что это его конфиг и применяет его для директории, в которой этот конфиг лежит (и для вложенных директорий тоже).
RewriteEngine – это такой механизм в сервере Apache, который позволяет перенаправлять запросы. А теперь давайте рассмотрим каждую строку файла отдельно.
- RewriteEngine On – включаем режим перенаправления запросов
- RewriteCond %{SCRIPT_FILENAME} !-d – если в директории есть папка, соответствующая адресу запроса, то отдать её в ответе
- RewriteCond %{SCRIPT_FILENAME} !-f – если в директории есть файл, соответствующий адресу запроса, то вернуть его в ответе
- RewriteRule ^(.*)$ ./index.php?route=$1 [QSA,L] – если файл или папка не найдены, то для такого запроса выполнится этот пункт. В таком случае веб-сервер перенаправить этот запрос на скрипт index.php. При этом скрипту будет передан GET-параметр route со значением запрошенного URI. $1 – это значение, выдернутое с помощью регулярки по маске ^(.*)$. То есть весь URI будет передана в этот GET-параметр.
Давайте теперь это проверим. Откроем в браузере адрес http://myproject.loc/abracadabra.
Опа! Видим текст «Главная страница». Значит мы попали на index.php. Давайте теперь попробуем в index.php вывести GET-параметр route. Уберём пока код, добавленный на предыдущих уроках и оставим только автозагрузку классов и вывод этого GET-параметра.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
var_dump($_GET['route']);
Снова откроем тот же адрес http://myproject.loc/abracadabra и увидим следующее:
string 'abracadabra' (length=11)
Давайте попробуем другой адрес - http://myproject.loc/hello/username
string 'hello/username' (length=14)
ЧПУ
Такие адреса через слэши называются ЧПУ – Человеко Понятные УРЛы. То есть адреса, которые нормально воспринимаются человеком.
Согласитесь
http://myproject.loc/hello/username
лучше чем
http://myproject.loc/?action=hello&name=username
На таких ЧПУ-адресах мы и будем разрабатывать нашу систему.
Роутинг
Ну а теперь мы научимся обрабатывать такие адреса красивым и простым способом – с помощью регулярных выражений.
Для начала давайте сделаем по-простому – с помощью регулярки научимся понимать, что текущий адрес: http://myproject.loc/hello/* , где * - вообще любая строка.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);
var_dump($matches);
Обратите внимание – в качестве ограничителя шаблона регулярного выражения мы использовали тильду - ~. Мы выбрали её вместо слэша, чтобы не экранировать слэш в адресной строке. Напомню, что в качестве ограничителя может выступать вообще любой символ.
Перейдём по адресу http://myproject.loc/hello/username и увидим наши совпадения по регулярке:
array (size=2)
0 => string 'hello/username' (length=14)
1 => string 'username' (length=8)
Нулевой элемент – полное совпадение по паттерну. Первый элемент – значение, попавшее в маску (.*), то есть всё, что идёт после hello/.
Давайте теперь добавим проверку того, что если $matches не пустой, то будем создавать контроллер MainController и вызывать у него экшен hello. В качестве аргумента будем передавать ему значение из массива по ключу 1.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$controller = new \MyProject\Controllers\MainController();
$controller->sayHello($matches[1]);
return;
}
Посмотрим, что получилось.
Привет, username
Отлично! Давайте теперь добавим обработку случая, когда мы просто зашли на http://myproject.loc/. В таком случае переменная route будет пустой строкой. Регулярка для такого случая - ^$. Да, просто начало строки и конец строки. Проще не бывает!
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$pattern = '~^hello/(.*)$~';
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$controller = new \MyProject\Controllers\MainController();
$controller->sayHello($matches[1]);
return;
}
$pattern = '~^$~';
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$controller = new \MyProject\Controllers\MainController();
$controller->main();
return;
}
Перейдём теперь на страницу http://myproject.loc/ и увидим сообщение «Главная страница».
Остаётся только добавить обработку случая, когда ни одна из этих регулярок не подошла и просто вывести сообщение о том что страница не найдена.
Давайте просто добавим в конце index.php строку:
...
echo 'Страница не найдена';
И проверим, что всё работает, перейдя по любому другому адресу: http://myproject.loc/blabla.
А теперь давайте посмотрим на получившийся код и подумаем, нет ли здесь чего-то общего? Да конечно есть! В обоих случаях мы проверяем регулярки и в зависимости от совпадения создаём нужный контроллер, с нужным методом и нужными аргументами. Но механизм-то одинаковый! Значит, будем делать универсальную систему роутинга.
Итак, нам нужно создать такую систему, которая будет на вход получать адрес, а на выходе возвращать имя контроллера, метод и параметры.
Давайте создадим отдельный файл с такой конфигурацией. Пусть это будет файл src/routes.php. Запишем в него следующее содержимое:
src/routes.php
<?php
return [
'~^hello/(.*)$~' => [\MyProject\Controllers\MainController::class, 'sayHello'],
'~^$~' => [\MyProject\Controllers\MainController::class, 'main'],
];
То есть это просто массив, у которого ключи – это регулярка для адреса, а значение – это массив с двумя значениями – именем контроллера и названием метода.
Теперь вернёмся в index.php и научимся обрабатывать этот файл. Для начала давайте просто положим этот массив в отдельную переменную.
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
var_dump($routes);
Результат:
array (size=2)
'~^hello/(.*)$~' =>
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'sayHello' (length=8)
'~^$~' =>
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'main' (length=4)
Что с этим делать? Да просто пробежаться по нему foreach-ом и найти соответствие по регулярке для текущего адреса. Как только совпадение найдено, нужно остановить перебор. Звучит несложно. Давайте сделаем это!
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$isRouteFound = true;
break;
}
}
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
var_dump($controllerAndAction);
var_dump($matches);
Я завел также специальную переменную $isRouteFound – на случай, если совпадений не было найдено, она останется false, как и до перебора. В таком случае мы выведем сообщение о том, что страница не найдена и завершим работу скрипта. В противном случае – выведем значение переменных $controllerAndAction и $matches.
Давайте проверим случай, когда нужный роут не найден - http://myproject.loc/blabla
Страница не найдена!
Всё правильно. Давайте теперь вернёмся на http://myproject.loc/
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'main' (length=4)
array (size=1)
0 => string '' (length=0)
Видим, что у нас есть имя нужного контроллера и имя метода. Всё, этого достаточно. Вот так это делается в PHP:
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$isRouteFound = true;
break;
}
}
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];
$controller = new $controllerName();
$controller->$actionName();
Да! Прямо вот так! Переменную можно использовать в качестве имени класса при создании объекта, и даже вместо имени метода!
Зайдите на http://myproject.loc/ и убедитесь, что всё прекрасно работает.
Но у нас осталась еще проблема с аргументами для методов.
Давайте вернёмся к предыдущему варианту кода, где мы просто вывели значения переменных:
...
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
var_dump($controllerAndAction);
var_dump($matches);
И перейдём по адресу http://myproject.loc/hello/username
Результат:
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'sayHello' (length=8)
array (size=2)
0 => string 'hello/username' (length=14)
1 => string 'username' (length=8)
Видим что у нас так же есть имя контроллера и имя метода. А также нужный нам аргумент в массиве $matches.
Но мы видим, что нужные нам аргументы всегда будут только после нулевого элемента, так как в нём лежит полное совпадение по паттерну. Не беда – просто удаляем этот ненужный элемент:
...
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
unset($matches[0]);
var_dump($controllerAndAction);
var_dump($matches);
Получаем следующую картину:
array (size=2)
0 => string 'MyProject\Controllers\MainController' (length=36)
1 => string 'sayHello' (length=8)
array (size=1)
1 => string 'username' (length=8)
Остаётся только один вопрос – как элементы массива передать в аргументы метода? Для этого в PHP есть специальный оператор троеточия:
method(...$array)
Он передаст элементы массива в качестве аргументов методу в том порядке, в котором они находятся в массиве.
Теперь доводим до ума наш скрипт:
<?php
spl_autoload_register(function (string $className) {
require_once __DIR__ . '/../src/' . $className . '.php';
});
$route = $_GET['route'] ?? '';
$routes = require __DIR__ . '/../src/routes.php';
$isRouteFound = false;
foreach ($routes as $pattern => $controllerAndAction) {
preg_match($pattern, $route, $matches);
if (!empty($matches)) {
$isRouteFound = true;
break;
}
}
if (!$isRouteFound) {
echo 'Страница не найдена!';
return;
}
unset($matches[0]);
$controllerName = $controllerAndAction[0];
$actionName = $controllerAndAction[1];
$controller = new $controllerName();
$controller->$actionName(...$matches);
Переходим по адресу http://myproject.loc/hello/username и видим что всё работает!
Вот мы и сделали роутинг. Теперь если нам понадобится добавить новый адрес на сайте то мы просто пропишем его в routes.php, и укажем имя контроллера и метода. Остальное произойдёт автоматически!
Ах да, наш index.php - скрипт, в котором происходит обработка входящих запросов и создаются другие контроллеры, называется фронт-контроллером.
Код с результатом этого урока на github.
Комментарии