Фронт-контроллер и роутинг в 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.

loader
Домашнее задание

Создайте еще один экшн в контроллере – sayBye(string $name), который будет выводить «Пока, $name». Добавьте для него роут /bye/$name и убедитесь, что всё работает.

Комментарии
Этот урок набрал набрал достаточно большое количество комментариев и дальнейшее его комментирование отключено. Если вы хотели убедиться в правильности выполнения ДЗ или у вас возник вопрос по уроку, посмотрите ранее добавленные комментарии, кликнув по кнопке ниже. Скорее всего вы найдете там то, что искали. Если это не помогло - задайте вопрос в чате в телеграме - https://t.me/php_zone
Логические задачи с собеседований