CRUD / Создание роутера

Будем разбираться с ссылками. Начнём с большой синей кнопки, которая должна открывать страницу с общей информаций об объекте.

Раньше у нас было по отдельному контроллеру и по отдельному шаблону на каждую страницу. Собственно, AndromedaController.php и OrionController.php.

Ну и когда объектов всего две штуки это в принципе нормально работает.

Но если вы работаете с БД, то у вас объектов может быть очень много и даже наши 5 штук уже заставляют задуматься о целесообразности такого подхода.

И что же делать?

А мы просто делаем один шаблон и один контроллер, внутри которого принимаем решение какой объект выводить. Что же касается того, как определить какой объект показывать, тут приходит на помощь возможность задавать шаблон url.

То есть например сейчас когда мы хотим зайти на страницу Андромеды, то мы используем url /andromeda, если хотим посмотреть Орион,то идем на /orion. Сейчас у нас объектов стало больше и на всех имен уже не напасёшься (хотя есть способы конечно).

Поэтому можно использовать более универсальный подход. У нас у каждого объекта есть уникальный номер, который хранится в поле id

мы можем использовать его вместо явного имени в ссылке, и заходить в Галактику Андромеды, например по адресу /space-object/1, в галактику Сигара – /space-object/4. Таким образом получается шаблон вида:

/space-object/{id}

где id – это идентификатор объекта, для того чтобы можно было сопоставлять шаблон с адресом url, нам потребуется опять воспользоваться регулярным выражением.

С помощью регулярки мы можем описать такой шаблон следующим образом:

/space-object/\d+

\d+ - означает одна и более символов цифр идущих подряд (то есть по сути просто любое число)

Такое выражение будет соответствовать например таким комбинациям

что почти подходит, но я все же хочу чтобы проверка шла целиком всей строки, для этого я добавлю символы начала ^ и конца $ строки в регулярку, вот так:

^/space-object/\d+$

тогда совпадать будут уже только 3 первые строки

и еще я хочу как-то получить доступ собственно к числу.

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

^/space-objects/(\d+)$

и смотрим как его распознает система:

В принципе то что надо. Попробуем это реализовать, но сначала

Рефакторим

Идем в index.php и удаляем все из роутера:

<?php
// ...

// оставляем только главную страницу
if ($url == "/") {
    $controller = new MainController($twig);
}

// ...

я хочу чтобы роутер у меня стал отдельным классом. В который можно надобавлять url и соответствующих им контроллеров. А роутер будучи уже классом сам внутри будет принимать решение какой Controller под текущий url выбрать.

Но надо придумать где этот класс расположить.

Так как то, что мы по сути делаем это создаем микрофреймворк. Давайте создадим папку framework. И перенесем в нее BaseController.php, TwigBaseController.php

теперь создадим файл autoload.php, чтобы нам не пришлось вручную прописывать пути к этим файлам, пишем в нем

<?php

spl_autoload_register(function($class) {
    $fn = __DIR__ . '\\' . $class . '.php';
    if (file_exists($fn)) {
	    require_once $fn; 
    }
});

идея этого файла такая: тут используется специальная функция spl_autoload_register, которая регистрирует функцию которая будет вызываться когда в коде php будет упоминаться класс который не импортирован явно.

Собственно, каждый раз, когда, где-то идет обращение к такому неивзестному классу буде происходить вызов функции

function($class) {
    $fn = __DIR__ . '\\' . $class . '.php';
    if (file_exists($fn)) {
	    require_once $fn; 
    }
}

функция формирует абсолютный путь к файлу, то есть в $class приходит имя класса, например, TwigBaseController,

в __DIR__ – путь к папке в которой лежит autoload.php

через точку в PHP можно склеивать строки, то есть __DIR__ . '\\' . $class . '.php'; даст нам абсолютный путь к файлу TwigBaseController.php что-то вроде C:\Users\m\Desktop\php_01\framework\TwigBaseController.php

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

И теперь нам надо везде убрать require_once с TwigBaseController

и добавить autoload в index.php

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

Создаем класс под роутер

Создаем файлик Router.php в папке framework

и пишем в нем:

<?php

// сначала создадим класс под один маршрут
class Route {
    public string $route_regexp; // тут получается шаблона url
    public $controller; // а это класс контроллера

    // ну и просто конструктор
    public function __construct($route_regexp, $controller)
    {
        $this->route_regexp = $route_regexp;
        $this->controller = $controller;
    }
}

теперь сам роутер:

<?php

class Route {
    // ...
}

class Router {
    /**
     * @var Route[]
     */
    protected $routes = []; // создаем поле -- список под маршруты и привязанные к ним контроллеры

    protected $twig; // переменные под twig и pdo
    protected $pdo;

    // конструктор
    public function __construct($twig, $pdo)
    {
        $this->twig = $twig;
        $this->pdo = $pdo;
    }

    // функция с помощью которой добавляем маршрут
    public function add($route_regexp, $controller) {
        // по сути просто пихает маршрут с привязанным контроллером в $routes
        array_push($this->routes, new Route($route_regexp, $controller));
    }

    // функция которая должна по url найти маршрут и вызывать его функцию get
    // если маршрут не найден, то будет использоваться контроллер по умолчанию
    public function get_or_default($default_controller) {
        $url = $_SERVER["REQUEST_URI"]; // получили url

        // фиксируем в контроллер $default_controller
        $controller = $default_controller;
        // проходим по списку $routes 
        foreach($this->routes as $route) {
            // проверяем подходит ли маршрут под шаблон
            if (preg_match($route->route_regexp, $url)) {
                // если подходит, то фиксируем привязанные к шаблону контроллер 
                $controller = $route->controller;
               // и выходим из цикла
                break;
            }
        }

        // создаем экземпляр контроллера
        $controllerInstance = new $controller();
        // передаем в него pdo
        $controllerInstance->setPDO($this->pdo);

        // вызываем
        return $controllerInstance->get();
    }
}

тут не совсем понятно, как twig передать, так как в TwigBaseController.php он передавался через конструктор,

Давайте пойдем в TwigBaseController.php и будем его также передавать через setter:

<?php
require_once "BaseController.php";

class TwigBaseController extends BaseController {
    public $title = "";
    public $template = "";
    protected \Twig\Environment $twig;

    // убираем
    // public function __construct($twig)
    // {
    //     $this->twig = $twig;
    // }

    // добавляем
    public function setTwig($twig) {
        $this->twig = $twig;
    }

    public function getContext() : array
    {
        // ...
    }
    
    public function get() {
        // ...
    }
}

возвращаемся обратно в Router и добавляем в нем:

class Router {
    // ...

    public function get_or_default($default_controller) {
        // ...

        $controllerInstance = new $controller();
        $controllerInstance->setPDO($this->pdo);
        
        // проверяем не является ли controllerInstance наследником TwigBaseController
        // и если является, то передает в него twig
        if ($controllerInstance instanceof TwigBaseController) {
            $controllerInstance->setTwig($this->twig);
        }

        // вызываем
        return $controllerInstance->get();
    }
}

Подключаем роутер в index.php

тут опять много всего вычищаем, пишем:

<?php
// ...
require_once "../controllers/Controller404.php";

// $url = $_SERVER["REQUEST_URI"]; УБИРАЕМ

$loader = new \Twig\Loader\FilesystemLoader('../views');
$twig = new \Twig\Environment($loader, [
    "debug" => true
]);
$twig->addExtension(new \Twig\Extension\DebugExtension());

// $controller = new Controller404($twig); УБИРАЕМ

$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");

$router = new Router($twig, $pdo);
$router->add("#/#", MainController::class);
$router->get_or_default(Controller404::class);

/*убираем
if ($url == "/") {
    $controller = new MainController($twig);
}

if ($controller) {
    $controller->setPDO($pdo);
    $controller->get();
}
*/

останется вот так:

<?php
// ...
require_once "../controllers/Controller404.php";

$loader = new \Twig\Loader\FilesystemLoader('../views');
$twig = new \Twig\Environment($loader, [
    "debug" => true
]);
$twig->addExtension(new \Twig\Extension\DebugExtension());

$pdo = new PDO("mysql:host=localhost;dbname=outer_space;charset=utf8", "root", "");

$router = new Router($twig, $pdo);
$router->add("#/#", MainController::class);
$router->get_or_default(Controller404::class);

тестируем

красота! =)

Давайте попробуем добавить еще какой-нибудь url:

<?php
// ...

$router = new Router($twig, $pdo);
$router->add("#/#", MainController::class);
$router->add("#/andromeda#", AndromedaController::class);

$router->get_or_default(Controller404::class);

тестим:

не срабатывает…

А! тут опять проблема с порядком, он находит первый попавшийся. Давайте сделаем так чтобы он всегда искал шаблон на полное соответствие. По идее надо писать так:

$router = new Router($twig, $pdo);
$router->add("#^/$#", MainController::class);
$router->add("#^/andromeda$#", AndromedaController::class);

тогда начинает работать:

вроде отлично, но приходится постоянно писать в начале #^ и в конце $#, пусть это автоматом добавляется в add, идем в Router.php и подкручиваем там:

class Router {
    // ...

    public function add($route_regexp, $controller) {
        // обернул тут в #^ и $#
        array_push($this->routes, new Route("#^$route_regexp$#", $controller));
    }

и теперь в index.php оставляем так:

$router = new Router($twig, $pdo);
$router->add("/", MainController::class);
$router->add("/andromeda", AndromedaController::class);

Так ведь куда проще читать и писать.

Добавляем контроллер под страницу объекта по id

Идем в папку controllers и создаем файлик ObjectController.php и загоняем в него болванку twig контроллера

смотрите, у нас в принципе шаблон под object уже есть, осталось взять данные из БД

<?php

class ObjectController extends TwigBaseController {
    public $template = "__object.twig"; // указываем шаблон

    public function getContext(): array
    {
        $context = parent::getContext();
        
        // готовим запрос к БД, допустим вытащим запись по id=3
        // тут уже указываю конкретные поля, там более грамотно
        $query = $this->pdo->query("SELECT description, id FROM space_objects WHERE id=3");
        // стягиваем одну строчку из базы
        $data = $query->fetch();
        
        // передаем описание из БД в контекст
        $context['description'] = $data['description'];

        return $context;
    }
}

теперь подключим это контроллер к роутеру в index.php:

<?php
require_once "../vendor/autoload.php";
require_once "../framework/autoload.php";
require_once "../controllers/MainController.php";
require_once "../controllers/ObjectController.php"; // добавил 
// ...

$router = new Router($twig, $pdo);
$router->add("/", MainController::class);
$router->add("/andromeda", AndromedaController::class);
// помните нашу регулярку, которую выше, делали, собственно вот сюда ее и загнали
$router->add("/space-object/(\d+)", ObjectController::class); 

$router->get_or_default(Controller404::class);

теперь попробуем открыть ссылку со space-object, у меня http://localhost:9007/space-object/3

смотрим в браузер:

а затем в phpMyAdmin

ну вроде подцепилась =)

А теперь попробуем с другим id, например: http://localhost:9007/space-object/1

ну чет тоже самое выводит. То есть нам надо как-то этот идентификатор передать в контроллер.

Передаем параметр из url в контроллер

Идем в Router.php и добавляем переменную matches вот так:

идея этой переменной следующая: когда у вас в регулярке /space-object/(\d+) присутствуют круглые скобочки, то та часть строки которая будет соответствовать тому что находится в скобках пойдет в matches, то есть если у вас всего одни набор скобочек то значение пойдет как второй элемент массива matches[1].

Ну то есть если у нас строка /space-object/42, то

Если бы у нас было /space-object/(\d+)/(\w+), а url вида /space-object/42/andromeda то

Собственно надо этот matches передать в controller. Идем в BaseController и добавляем поле params`:

<?php

abstract class BaseController {
    public PDO $pdo;
    public array $params; // добавил поле
    
    // добавил сеттер
    public function setParams(array $params) {
        $this->params = $params;
    }

    // ...
}

ну и вызываем эту функцию в Router.php

public function get_or_default($default_controller) {
    // ...

    $controllerInstance = new $controller();
    $controllerInstance->setPDO($this->pdo);
    $controllerInstance->setParams($matches); // передаем параметров

    if ($controllerInstance instanceof TwigBaseController) {
        $controllerInstance->setTwig($this->twig);
    }

    return $controllerInstance->get();
}

теперь давайте глянем что там оказывается, когда вызывается метод getContext, идем в ObjectController и вставляем туда:

<?php

class ObjectController extends TwigBaseController {
    public $template = "__object.twig";

    public function getContext(): array
    {
        $context = parent::getContext();
        
        // добавил вывод params
        echo "<pre>";
        print_r($this->params);
        echo "</pre>";

        // ...

        return $context;
    }
}

смотрим

как я и обещал в $this->params оказалась циферка из запроса.

Кстати в регулярках можно добавить имя к подвыражению, для этого надо после открывающей скобки добавить ?P<param_name>, вот так

и теперь если перегрузить страницу мы увидим, что появился дополнительный ключ у словарика:

давайте теперь этим параметром воспользуемся.

пробуем:

невероятно! =О

Немного о безопасности

Вот мы сейчас формируем запрос путем простой склейки строк. Потенциально это дыра в безопасности, потому что если по какой-то причине мы сделаем кривую регулярку, то возможный злоумышленник сможет подставить вместо цифры любое выражение, в том числе и запрос к БД.

Вот вам пример. Допустим мы сделали такую регулярку

$router->add("/space-object/(?P<id>.*)", ObjectController::class);

.* – означает набор символов любой длины. Такое часто используется, когда надо вывести просто какое-то произвольное название чтобы url был более читаемым

Запрос http://localhost:9007/space-object/1 сработает как обычно:

А теперь приходит какой-то нехороший человек и пишет

и вот у вас внутри уже получается двойной запрос:

SELECT description, id FROM space_objects WHERE id=1; UPDATE space_objects SET title = title + '1' WHERE id = 3;

и если мы как-то неудачно дергаем данные может получится что произойдет обновление данных, а то еще и удаление. Такая атака называется SQL Injection, то есть sql инъекция

Поэтому, когда вы формируете запрос вы никогда не должны использовать простую склейку строк. Создатели PDO знают про это и поэтому предлагают альтернативный способ передачи параметров. Используется она так:

// создам запрос, под параметр создаем переменную my_id в запросе
$query = $this->pdo->prepare("SELECT description, id FROM space_objects WHERE id= :my_id");
// подвязываем значение в my_id 
$query->bindValue("my_id", $this->params['id']);
$query->execute(); // выполняем запрос

// тянем данные
$data = $query->fetch();

работать будет так же:

Подцепляем urlы на главной странице

Идем в main.twig и заменяем там hrefы

и пробуем

2

Сделать чтобы

  • ссылки Картинка и Описание тоже брали данные из БД
  • избавится от отдельных контроллеров для объектов и сделать два универсальных для info и под image
  • в общем должно работать как обычно