PHP: Простой роутинг с помощью библиотеки PHRout

Сегодня я поделюсь с вами опытом как достаточно быстро и с минимальными трудозатратами можно создать свою систему маршрутизации (роутинга) в вашем php приложении. Поможет нам в этом замечательная библиотека PHRoute.

PHRoute — это отличная библиотека которая обеспечивает быструю реализацию маршрутизатора на основе регулярных выражений. В этом уроке мы рассмотрим, как его использовать в типовом проекте. Кроме того заглянем на его реализации в ядре, дабы лучше разобраться в вопросах маршрутизации.

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

Итак приступим, создаём файл нашего проекта composer.json

{
    "name": "mark/test-router",
    "type": "project",
    "require": {
        "phroute/phroute": "^2.1",
    },
    "license": "mit"
}

Давайте так-же организуем перенаправление всех запросов в файл index.php. Для этого в корне вэб сервера необходимо создать файл .htaccess со следующим содержимым:

RewriteEngine on

RewriteBase /
RewriteCond %{HTTP_HOST} (.*)
RewriteCond %{REQUEST_URI} /$ [NC]
RewriteRule ^(.*)(/)$ $1 [L,R=301]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]

Теперь из папки вашего проекта выполняем команду composer install. В корне должна пояаится папка Vendor.

Для лучшего понимания концепции PHRoute, неплохо бы создать образец проекта для работы. Сегодня мы сделаем с вами базовый API базы данных книг.

Вот схема базы данных, которую мы собираемся использовать:

Если вы хотите сделать некоторые тесты, это дамп схемы SQL, который я использовал (с некоторыми дополнительными фиктивными данными):

CREATE TABLE IF NOT EXISTS authors (id int(10) unsigned NOT NULL AUTO_INCREMENT, name varchar(250) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=3;

    INSERT INTO authors (id, name) 
    VALUES 
    (1, 'Dan Brown'), 
    (2, 'Paulo Coelho');

    CREATE TABLE IF NOT EXISTS categories (id int(10) unsigned NOT NULL AUTO_INCREMENT, name varchar(250) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB  DEFAULT CHARSET=utf8 AUTO_INCREMENT=3;

    INSERT INTO categories (id, name) 
    VALUES 
    (1, 'Thriller'), 
    (2, 'Novel');

    CREATE TABLE IF NOT EXISTS books (id int(10) unsigned NOT NULL AUTO_INCREMENT,   title varchar(250) NOT NULL, isbn varchar(50) NOT NULL, year int(11) NOT NULL,   pages int(11) NOT NULL, author_id int(10) unsigned NOT NULL, category_id int(10) unsigned NOT NULL, PRIMARY KEY (id), KEY author_id (author_id,category_id), KEY category_id (category_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=7;

    INSERT INTO books (id, title, isbn, year, pages, author_id, category_id) 
    VALUES 
    (1, 'The Zahir', '0-06-083281-9', 2005, 336, 2, 2), 
    (2, 'The Devil and Miss Prym', '0-00-711605-5', 2000, 205, 2, 2), 
    (3, 'The Alchemist', '0-06-250217-4', 1988, 163, 2, 2), 
    (4, 'Inferno', '978-0-385-53785-8', 2013, 480, 1, 1), 
    (5, 'The Da Vinci Code', '0-385-50420-9', 2003, 454, 1, 1), 
    (6, 'Angels & Demons', '0-671-02735-2', 2000, 616, 1, 1);

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

Прежде чем мы начнем с конкретных маршрутов, давайте проанализируем основную структуру приложения. Это то, что мы собираемся добавить в наш файл index.php

<?php

include __DIR__ . '/vendor/autoload.php';

use Phroute\Phroute\RouteCollector;
use Phroute\Phroute\Dispatcher;

    function processInput($uri){
        $uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

            return $uri;
    }

    function processOutput($response){
        echo json_encode($response);
    }

    // function getPDOInstance(){
    //     return new PDO('mysql:host=localhost;dbname=booksapi;charset=utf8', 'root', '');
    // }

    $router = new RouteCollector();

    $router->get('/', function(){
        return 'Like at Home!';
    });

    $router->get('hello', function(){
        return 'Hello, PHRoute!';
    });

  $dispatcher =  new Dispatcher($router->getData());

    try {

        $response = $dispatcher->dispatch($_SERVER['REQUEST_METHOD'], processInput($_SERVER['REQUEST_URI']));

    } catch (Phroute\Exception\HttpRouteNotFoundException $e) {

        var_dump($e);
        die();

    } catch (Phroute\Exception\HttpMethodNotAllowedException $e) {

        var_dump($e);
        die();

    }

    processOutput($response);

?>

У нас есть три служебных метода: processInput, processOutput и getPDOInstance. Мы будем использовать первые два, чтобы убедиться, что мы получаем правильный ввод и правильный вывод. Третий подготовит необходимый экземпляр PDO. 

Обратите внимание: второй параметр метода array_slice - «3» из-за моей индивидуальной настройки конкретного проекта. Измените его на ваш базовый URL-адрес.

Теперь мы объявляем наши маршруты, используя объект $router, экземпляр класса RouteController. Затем в методе диспатчер происходит волшебство $dispatcher->dispatch(), он принимает два параметра: метод запроса $_SERVER (GET, POST и т. д.) И определенный uri запрос. С помощью этой информации диспетчер вызывает правильный маршрут и выполняет код при закрытии. Возвращаемое значение хранится в переменной $response, которая передается методу processOutput(), который отображает его как строку JSON.

Как вы можете видеть, в этом конкретном примере мы объявили один маршрут: hello.

Обратите внимание: если вы хотите, вы можете улучшить структуру приложения. Создайте новый файл и назовите его routes.php. Затем подключите его из основного файла index.php сразу после инициализации объекта $ router. Таким образом все ваши маршруты будут содержаться в отдельном файле. Теперь вы знаете все, что вам нужно о базовой структуре нашего примера.

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

Начинаем с самого простого: список авторов.

$router->get('authors', function(){      
    $db = getPDOInstance();

    $sql = 'SELECT * FROM authors;';  
    $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));

    $st->execute();

    $result = $st->fetchAll(PDO::FETCH_CLASS);
	    
    return $result;  
});

В первой строке мы указываем название нашего маршрута — authors. Проверим наш роут, если вы перейдёте по адресу: ваше_приложение/autors, должнен полуится вот такой вот массив данных:

[{"id":"1","name":"Dan Brown"},{"id":"2","name":"Paulo Coelho"}]

Отлично! Теперь давайте попробуем добавить параметр, что бы получить все книги одного автора основываясь на его id.

Для этого ипользуем конструкцию на подобии этой:

$router->get('author/{id}', function($id){      
    $db = getPDOInstance(); 
    $sql = 'SELECT * FROM `authors` WHERE `id` = :id';     
    
    $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
    $st->execute(array(':id' => $id));

    $result = $st->fetchAll(PDO::FETCH_CLASS);
 
    return $result;
});

Вы можете передать параметр, используя плейсхолдер {variable_name}, с тем же выбранным именем, что и параметр для закрытия. В этом примере у нас есть плейсхолдер {id}, соответствующий параметру $id. Вы же можете указать любой параметр, который вам нужен.

Иногда параметр может быть необязательным. Давайте сделаем еще один пример: если мы используем URL books, мы хотим получить список всех книг базы данных. Но, если мы укажем id, как параметр, например: books/1, мы получим список книг по данной категории, вот так:

$router->get('books/{category_id}?', function($category_id = null){         
    $db = getPDOInstance();
         
    if($category_id == null)
    {
	    $sql = 'SELECT * FROM `books`;';
    	$params = array();
    }
    else
    {
    	$sql = 'SELECT * FROM `books` WHERE `category_id` = :category_id;';
        $params = array(':category_id' => $category_id);
    }

    $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
    $st->execute($params);

    $result = $st->fetchAll(PDO::FETCH_CLASS);
 
    return $result;   
});

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

До сих пор мы создали только маршруты для GET запросов. Как насчет других HTTP запросов? Не вопрос! вот вам пример:

$router->get($route, $handler);    // used for GET-only requests
$router->post($route, $handler);   // used for POST-only requests
$router->delete($route, $handler); // used for DELETE-only requests
$router->any($route, $handler);    // used for all verbs

Давайте сделаем пример маршрута принимающего POST данные. Пришло время добавить новую книгу в нашу коллекцию!

$router->post('book', function(){       
        $db = getPDOInstance();
        $bookData = $_POST;

        $sql = 'INSERT INTO table_name (id, title, isbn, year, pages, author_id, category_id) VALUES (NULL, :title, :isbn, :year, :pages, :author_id, :category_id);';

        $params = array(
    	    ':title' => 'The Winner Stands Alone',
    	    ':isbn' => '978-88-452-6279-1',
    	    ':year' => 2009,
    	    ':pages' => 361,
    	    ':author_id' => 2,
    	    ':category_id' => 2
        );

        $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        $result = $st->exec($params);

        if($result)
        {
    	    return $db->lastInsertId();
        }
        else
        {
    	    return false;
        }
});

Представим, что у нас есть форма для заполнения данными книги: её экшен содержит путь, который мы создали прямо сейчас, для приёма POST данных.

Теперь мы пойдём дальше: пришло время «защитить» наши маршруты! Сейчас выходит, что каждый кто передаст post данные по маршруту books сможет добавить данные в нашу базу данных. В серьёзных проектах это разумеется неопустимо. В этом случае нас выручат фильтры. Фильтры очень похожи на маршруты: у них есть имя и связанное с ним закрытие, которое выполняется, когда фильтр вызывается где либо.

Давайте разберёмся в чём разница? Фильтр можно легко вызвать до (или после) маршрута. Привожу пример:

$router->filter('logged_in', function(){    
        if(!$_SESSION['user_id']){
            header('Location: /login');
            return false;
        }
    });

    $router->post('book', function(){       
        $db = getPDOInstance();
        $bookData = $_POST;

        $sql = 'INSERT INTO table_name (id, title, isbn, year, pages, author_id, category_id) VALUES (NULL, :title, :isbn, :year, :pages, :author_id, :category_id);';

        $params = array(
    	    ':title' => 'The Winner Stands Alone',
    	    ':isbn' => '978-88-452-6279-1',
    	    ':year' => 2009,
    	    ':pages' => 361,
    	    ':author_id' => 2,
    	    ':category_id' => 2
        );

        $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        $result = $st->exec($params);

        if($result)
        {
    	    return $db->lastInsertId();
        }
        else
        {
    	    return false;
        }
    }, array('before' => 'logged_in'));

Прежде всего, мы объявили фильтр с помощью метода filter() объекта $router. Синтаксис такой же, как и для маршрута. Мы даем ему имя и закрытие, которое будет выполнено в правильное время. Ок, но что такое «правильное время»? Мы решаем это так: просто добавили дополнительный параметр к методу post(). Этот параметр является массивом, в котором мы указываем ключ before с именем фильтра (logged_in). С этого момента, перед каждым вызовом маршрута books методом Post, будет также вызван фильтр logged_in (и выполнена его подстановка). Таким образом мы проверяем переменную $session user_id, чтобы узнать, вошёл ли пользователь в систему.

Существует также ключ after, который используется для запуска фильтра сразу после вызова маршрута. Вот вам пример:

$router->filter('clean', function(){    
        // cleaning code after the route call...
    });

    $router->post('book', function(){       
        $db = getPDOInstance();
        $bookData = $_POST;

        $sql = 'INSERT INTO table_name (id, title, isbn, year, pages, author_id, category_id) VALUES (NULL, :title, :isbn, :year, :pages, :author_id, :category_id);';

        $params = array(
    	    ':title' => 'The Winner Stands Alone',
    	    ':isbn' => '978-88-452-6279-1',
    	    ':year' => 2009,
    	    ':pages' => 361,
    	    ':author_id' => 2,
    	    ':category_id' => 2
        );

        $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        $result = $st->exec($params);

        if($result)
        {
    	    return $db->lastInsertId();
        }
        else
        {
    	    return false;
        }
    }, array('after' => 'clean'));

Если вам нужно, вы также можете указать несколько фильтров одновременно. Все, что вам нужно сделать, это использовать массив строк, а не одну строку.

$router->filter('filter1', function(){    
        // filter 1 operations...
    });

    $router->filter('filter2', function(){    
        // filter 2 operations...
    });

    $router->post('book', function(){       
        $db = getPDOInstance();
        $bookData = $_POST;

        $sql = 'INSERT INTO table_name (id, title, isbn, year, pages, author_id, category_id) VALUES (NULL, :title, :isbn, :year, :pages, :author_id, :category_id);';

        $params = array(
    	    ':title' => 'The Winner Stands Alone',
    	    ':isbn' => '978-88-452-6279-1',
    	    ':year' => 2009,
    	    ':pages' => 361,
    	    ':author_id' => 2,
    	    ':category_id' => 2
        );

        $st = $db->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
        $result = $st->exec($params);

        if($result)
        {
    	    return $db->lastInsertId();
        }
        else
        {
    	    return false;
        }
    }, array('after' => array('filter1', 'filter2')));

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

$router->filter('logged_in', function(){    
    if(!isset($_SESSION['user_id'])) 
    {
        header('Location: /login');
        return false;
    }
});

$router->group(array('before' => 'logged_in'), function($router){

    $router->post('book', function(){
        // book insert code...
    });

    $router->post('author', function(){
        // author insert code...
    });

    $router->post('category', function(){
        // category insert code...
    });

});

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

Давайте теперь представим, что наш проект растёт, в месте с ним будет расти и колличество кода. Соответственно структура роутера станет раздутой и неаккуратной. Как же решить эту проблему? Всё очень просто - нам на помощь приходят контроллеры!

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

Прежде всего, давайте посмотрим, как устроена структура нашего контроллера. Взгляните на этот пример (мы можем поместить его прямо в файл routes.php):

<?php

    class Author {
        public function getIndex()
        {
            // get author list data here...
            return $result;
        }
        
        public function postAdd()
        {           
            // add a new author to the database
            return $insertId;
        } 
    }
    

    $router->controller('author', 'Author');

Мы создали класс Author. В этом классе мы используем два метода: getIndex() и postAdd(). Затем с помощью метода controller() объекта $router мы связываем URL-адрес автора с классом Author.

Итак, если мы введем URL автора в нашем браузере, метод getIndex() будет вызываться автоматически. То же самое относится к методу postAdd(), который будет привязан к URL-адресу author / add (POST).

Эта функция автоматического распознавания имён довольно интересна, но на самом деле её недостаточно.

Контроллер находится на ранней стадии разработки и нуждается во многих улучшениях. Одним из них является возможность определять параметры для методов контроллера. Или, может быть, простой способ определить фильтры для некоторых методов контроллера (а не «все или ничего»).

Заключение. Ещё многое нуждается в доработке, особенно на стороне контроллеров. Как разработчик, я думаю, было бы здорово иметь базовый базовый класс контроллера для обработки всей грязной работы (с фильтрами, параметрами методов и т. д.). К сожалению документация по данному вопросу отсутствует.

С другой стороны, PHRoute поставляется с очень быстрым маршрутизатором. На странице GitHub проекта, вы можете увидеть некоторые статистические данные о сравнении с основным маршрутизатором Laravel: результаты потрясающие. В худшем случае PHRoute примерно в 40 (да, 40) раз быстрее.