Часть 4. Bun Framework - HTTP-компоненты + Router

2014-06-04 в 20:27 PHP Bun Framework HTTP Router Projects

В предыдущих частях было описано создание окружения для разработки фреймворка, а также разработан компонент конфигурации приложения и фреймворка.

Теперь, как и было обещано, переходим к разработке компонетов HTTP: Request и Response – которые будут абстрагировать нас от обработки глобальных переменных HTTP-запроса (или cli запроса), а Response — от вывода результата (HTTP-ответа или вывода в консоль). По поводу работы фреймворка через консоль, я напишу отдельную статью (или несколько), так как это того заслуживает, пока что поставим TODO в тех местах в приложении, где это будет требоваться.

Bun Framework HTTP Component


Bun\Core\Http\Request


Итак, начнем с запроса, что логично. Это будет довольно простой класс, часть кода которого я опущу, при желании можно глянуть в репозитории на BitBucket

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


<?php
namespace Bun\Core\Http;

/**
* Request object allows to access common known HTTP and SERVER PHP super global arrays
* and some common used requests parameters
*
* Class Request
*
* @package Bun\Core\Http
*/
class Request
{
    protected $argv = array();
    protected $server = array();
    protected $post = array();
    protected $query = array();
    protected $files = array();
    protected $host = '';
    protected $uri = '';
    protected $method = '';
    protected $queryString = '';
    protected $isAjax = false;
    protected $isConsole = false;
    protected $ip = '';
    protected $userAgent = '';

    /**
    * Initialize request
    */
    public function __construct()
    {
        $this->initFromGlobals();
    }

    /**
    * Allows to create request from global params
    *
    * @return Request
    */
    public static function createFromGlobals()
    {
        $request = new self();

        return $request;
    }

    /**
    * init request params from globals
    */
    protected function initFromGlobals()
    {
        global $argv;
        $this->argv = $argv;
        $this->server = isset($_SERVER) ? $_SERVER : array();
        $this->isAjax =
            isset($this->server['X_HTTP_REQUEST_WITH']) &&
            strtolower($this->server['X_HTTP_REQUEST_WITH'] === 'xhrhttprequest');
        $this->isConsole = defined('PHP_SAPI') && PHP_SAPI === 'cli';
        $this->post = $_POST;
        $this->query = $_GET;
        $this->uri = isset($this->server['REQUEST_URI']) ?
            $this->server['REQUEST_URI'] :
            '';
        $this->queryString = isset($this->server['QUERY_STRING']) ?
            $this->server['QUERY_STRING'] :
            '';
        $this->ip = isset($this->server['REMOTE_ADDR']) ?
            $this->server['REMOTE_ADDR'] :
            null;
        $this->userAgent = isset($this->server['USER_AGENT']) ?
            $this->server['USER_AGENT'] :
            '';
        $this->method = isset($this->server['HTTP_METHOD']) ?
            strtoupper($this->server['HTTP_METHOD']) :
            null;
        $this->files = isset($_FILES) ?
            $_FILES :
            array();
    }

    /**
    * @return array
    */
    public function getConsoleArguments()
    {
        return $this->argv;
    }

    /**
    * @return bool
    */
    public function hasConsoleArguments()
    {
        return count($this->argv) > 0;
    }

    /**
    * @param null $param
    *
    * @return mixed|null|array
    */
    public function getServer($param = null)
    {
        return $this->getArrayFieldParam('server', $param);
    }

    /**
    * @param null $param
    *
    * @return array|mixed|null
    */
    public function getPost($param = null)
    {
        return $this->getArrayFieldParam('post', $param);
    }

    /**
    * @param null $param
    *
    * @return array|mixed|null
    */
    public function getQuery($param = null)
    {
        return $this->getArrayFieldParam('query', $param);
    }

    //...

    /**
    * @param $arrayField
    * @param $param
    *
    * @return mixed|null|array
    */
    protected function getArrayFieldParam($arrayField, $param = null)
    {
        if($param === null) {
            return $this->$arrayField;
        }
        elseif(isset($this->$arrayField[$param])) {
            return $this->$arrayField[$param];
        }

        return null;
    }

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


Bun\Core\Http\Response


Так что переходим к следующему компоненту HTTP – Response. Пока я опишу создание только базового ответа Response, в дальнейших статьях также будут описаны дополнительные класс-наследники, вроде AjaxResponse, DownloadResponse, ConsoleResponse и т.п. В целом они почти не будут иметь какого-то осого функционала, просто будут упрощать разработку, тем что сразу отдавать ответ в правильном виде и с правильным заголовками.

Перейдем к коду:


<php
namespace Bun\Core\Http;

/**
* Response allows you to deliver your application content to client browser or terminal (or other client interface)
* with needed headers. It also allows application to detect how long response was preparing in controller
*
* Class Response
*
* @package Bun\Core\Http
*/
class Response
{
    /** @var mixed */
    protected $content;
    /** @var array */
    protected $headers = array();
    /** @var RunTimer */
    protected $timer;

    /**
    * @param string $content
    * @param array $headers
    * @param RunTimer $timer
    */
    public function __construct($content = '', $headers = array(), RunTimer $timer = null)
    {
        if ($content !== null) {
            $this->setContent($content);
        }

        if (count($headers) > 0) {
            $this->setHeaders($headers);
        }

        if($timer !== null) {
            $this->setTimer($timer);
        }
    }

    /**
    * @param string $content
    */
    public function setContent($content = '')
    {
        $this->content = $content;
    }

    /**
    * @param $header
    * @param int $code
    */
    public function setHeader($header, $code = 200)
    {
        $this->headers[$code] = $header;
    }

    /**
    * @param array $headers
    * @return $this
    */
    public function setHeaders($headers = array())
    {
        foreach ($headers as $code => $header) {
            $this->setHeader($header, $code);
        }

        return $this;
    }

    /**
    * @param RunTimer $timer
    */
    public function setTimer(RunTimer $timer)
    {
        $this->timer = $timer;
    }

    /**
    * @return mixed`
    */
    public function getContent()
    {
        return $this->content;
    }

    /**
    * @return array
    */
    public function getHeaders()
    {
        return $this->headers;
    }

    /**
    * @return RunTimer
    */
    public function getTimer()
    {
        return $this->timer;
    }

    /**
    * @return void
    */
    public function sendHeaders()
    {
        foreach ($this->headers as $code => $header) {
            header($header, true, (int)$code);
       }
    }
}

Код опять-таки довольно простой, через конструктор можно создать полноценный объект готовый к тому, чтобы возвратить в action внутри контроллера. Единственное неочевидное место – это параметр $timer класса Bun\Core\Http\RunTimer. Это всмопогательный класс, который помогает замерять время работы контроллера без неоходимости писать для этого специальный код программисту. При разработке компонетов контроллера мы еще разберем этот момент подробнее. Пока оставлю ссылку на код класса RunTimer в репозитории


Bun\Core\Http\Router


И в последней части статьи, пожалуй наиболее интересный компонент – роутер. Роутеры уже стали привычной частью веб-приложений на PHP. Давно минули времена, когда для получения ЧПУ или перенаправления какого-то URL на нужный скрипт приходилось мучать mod_rewrite в apache или реврайты nginx. Роутер будет разделен на две части – конфиг роутов, который будет реализован с помощью созданного в прошлой части компонента конфигурации; вторая часть – сам роутер, плюс небольшой вспомогательный класс RoutingResult, который просто структурирует данные результата роутинга, чтобы избавиться от нетипизированной передачи данных в массиве.

Задача роутера будет сматчить url из запроса в один из урлов, сконфигурированных в приложении. Для удобства конфигурации сделаем возможность в конфиге использовать параметры в url, вида /user/edit/:id – где id будет подставляться из url запроса. А также добавим возможность делать параметры в url необязательными или приводить их к какому-либо набору, пример: /public/.*(|:file)\.(jpg|jpeg|png|gif). В таком случае имя изображения перейдет в параметр file и в этот роут попадут только запросы картинок.

Приведу пример конфигурации роутера, которая будет использована в модуле Core:


<php
namespace Bun\Core\Config;

/**
* Class RouterConfig
*
* @package Bun\Core\Config
*/
class RouterConfig extends AbstractConfig
{
    protected $name = 'router';

    protected $config = array(
        '/' => array(
            'controller' => 'Bun\\Core\\Controller\\BunController'
        ),
        '/favicon.ico' => array(
            'controller' => 'Bun\\Core\\Controller\\BunController',
            'action'     => 'favicon'
        ),
        '/bun/phpinfo' => array(
            'controller' => 'Bun\\Core\\Controller\\BunController',
            'action'     => 'phpinfo',
        ),
    );
}

Тут все просто – только простое точное сопоставлени url к котнроллеру и его экшену. Если параметр action опущен в настройках роута, то считается, что он равен "index". Теперь сразу приведу код класса, который будет использован для представления результата работы роутера:


<php
namespace Bun\Core\Http;

/**
* Class RoutingResult
*
* @package Bun\Core\Router
*/
class RoutingResult
{
    protected $controllerClass;
    protected $actionName;
    protected $actionParams;

    /**
    * @return string
    */
    public function getControllerClass()
    {
        return $this->controllerClass;
    }

    /**
    * @param $class
    * @return $this
    */
    public function setControllerClass($class)
    {
        $this->controllerClass = $class;

        return $this;
    }

    /**
    * @return string
    */
    public function getActionName()
    {
        return $this->actionName;
    }

    /**
    * @param $actionName
    * @return $this
    */
    public function setActionName($actionName)
    {
        $this->actionName = $actionName;

        return $this;
    }

    /**
    * @return array
    */
    public function getActionParams()
    {
        return $this->actionParams;
    }

    /**
    * @param array $params
    * @return $this
    */
    public function setActionParams($params = array())
    {
        $this->actionParams = $params;

        return $this;
    }

    /**
    * @return string
    */
    public function getActionNotFoundExceptionMessage()
    {
        return 'Action ' . $this->getActionName() . ' not found in controller: ' . $this->getControllerClass();
    }

    /**
    * @return string
    */
    public function getControllerNotFoundExceptionMessage()
    {
        return 'Controller not found: ' . $this->getControllerClass();
    }
}

Как видно, ничего особенного, просто типизируем возвращаемое роутером значение. Теперь перейдем к созданию самого роутера, привожу код с комментариями:


<php
namespace Bun\Core\Http;

use Bun\Core\Config\AbstractConfig;
use Bun\Core\Config\ConfigAwareInterface;
use Bun\Core\Exception\RoutingException;
use Bun\Core\Router\RoutingResult;

/**
* Class Router
*
* @package Bun\Core\Router
*/
class Router implements ConfigAwareInterface, RequestAwareInterface
{
    const DEFAULT_ROUTING_ACTION = 'index'; // экшн по-умолчанию
    /** @var AbstractConfig */
    protected $config; // тут будем хранить сервис конфигов
    /** @var Request */
    protected $request; // тут сервис запроса
    /** @var array */
    protected $routes = array(); // тут настроенные роуты

    protected $methods = array(
        'GET',
        'POST',
        'PUT',
        'PATCH',
        'DELETE'
    ); // массивчик с методами

    /**
    * Контсруктор пустой - зависимости получаем через сеттеры
    */
    public function __construct()
    {
    }

    /**
    * @param AbstractConfig $config
    */
    public function setConfig(AbstractConfig $config)
    {
        $this->config = $config;
    }

    /**
    * @param Request $request
    */
    public function setRequest(Request $request)
    {
        $this->request = $request;
    }

    /**
    * Основной метод, который выполняет роутинг
    * @return RoutingResult
    * @throws RoutingException
    */
    public function route()
    {
        // если запрос консольный, роутинг выполняется отдельно
        if ($this->request->isConsole()) {
            return $this->routeTool();
        }

        // получаем из конфига настройки роутов
        $routes = $this->config->get('router');
        // из запроса берем uri
        $uri = explode('?', $this->request->getUri());
        $uri = $uri[0];
        $routesUris = array_keys($routes); // отдельно берем uri роутов

        $routingResult = new RoutingResult(); // создаем путой результат роутинга
        $matchedRoute = in_array($uri, $routesUris) ?
            $matchedRoute = $routes[$uri] : // если uri напрямую попадает в конфиг, берем результат из настроек
            $matchedRoute = $this->match($routes); // иначе выполняем матчинг роутов

        // если нашелся сматченный роут
        if ($matchedRoute) {
            // заполняем объект результата роутинга
            $routingResult
                ->setControllerClass($matchedRoute['controller'])
                ->setActionName(isset($matchedRoute['action']) ?
                    $matchedRoute['action'] :
                    self::DEFAULT_ROUTING_ACTION
                )
                ->setActionParams(isset($matchedRoute['params']) ?
                    $matchedRoute['params'] :
                    array()
                );

            return $routingResult; // и возвращаем его, роутинг закончен )
        }

        // если ничего не сматчили – бросам типизированное исключение
        throw new RoutingException(
            'Unable to match route for: ' . $this->request->getMethod() . ': ' . $uri
        );
    }

    /**
    * @return RoutingResult
    * @throws RoutingException
    */
    protected function routeTool()
    {
        // TODO пока отложим реализацию роутинга запросов из консоли;
    }

    /**
    * Матчинг роутов по регуляркам
    * @param array $routes
    * @return bool|array
    */
    protected function match($routes)
    {
        $uri = explode('?', $this->request->getUri());
        $uri = $uri[0]; // опять берем uri из запроса
        $method = $this->request->getMethod(); // берем метод
        $routesUri = array_keys($routes);
        $matchedRoute = false;
        // идем по порядку по всем роутам
        foreach ($routesUri as $routeUri) {
            // матчить будем только те, где есть параметры в настройках
            if (strpos($routeUri, ':') !== false) {
                $routeMethods = isset($routes[$routeUri]['method']) ?
                    $routes[$routeUri]['method'] :
                    $this->methods;
                // если в конфигах вызов роута ограничен списком методов, то берем их, иначе все методы
                $regexRouteUri = str_replace('/', '\\/', $routeUri); // делаем регулярку для матчинга
                $regexRouteUri = '/' . preg_replace('/:(\w+)/', '\w+', $regexRouteUri) . '$/';
                if (preg_match($regexRouteUri, $uri, $matches) && in_array($method, $routeMethods)) {
                    // если uri сматчился и метод в списке разрешенных для роута
                    $matchedRoute = $routes[$routeUri];
                    // матчим параметры из uri
                    preg_match_all('/:\w+/', $routeUri, $routeParams);
                    preg_match_all('/\w+/', $routeUri, $routeParts);
                    preg_match_all('/\w+/', $uri, $uriParts);
                    $actionParams = array();
                    // заполняем массив параметров экшена контроллера
                    foreach ($routeParts[0] as $key => $paramName) {
                        if (in_array(':' . $paramName, $routeParams[0])) {
                            $actionParams[$paramName] = isset($uriParts[0][$key]) ?
                                $uriParts[0][$key] :
                                null;
                        }
                    }
                    $matchedRoute['params'] = $actionParams;

                    break; // завершаем цикл матчинга
                }
            }
        }

        return $matchedRoute; // возвращаем результат (отстуствие результата - тоже резуьтат)
    }
}

Код довольно краток и прост. Роутер пока не умеет ничего лишнего, но нам и не надо. Функционал данного класса способен покрыть потребности 80% веб-приложений на PHP. По коментариям в коде, думаю должно быть все понятно. Отдельно отмечу момент полуения зависимостей в классе. Т.к. класс будет являться сервисом, то мы имплементируем специальные интерфейсы нужных нам сервисов. Внедерние зависимостей будет сделано автоматически контейнером зависимостей, нам при реализации и использовании особо заботиться об этом не нужно – только прописать интерфейсы зависимостей и реализовать нужные сеттеры. О компонент контерйнера зависимостей я расскажу уже в слежующей статье, т.к. мы вплотную приблизились к необходимости работы с ним.

В статье еще хотелось, конечно привести unit тесты созданных классов, но время поджимает (как это обычно бывает, когда надо писать тесты). Опубликую статью так, в комментах дам ссылки позже на реализацию тестов. Актуальный коммит на момент написания статьи 843ffa9 BitBucket Bun Framework



Автор   

Комментарии (1)    написать


Jacob Akulov

Накатал пару тестов, не полноценных, но просто для демонстрации того, что написанные компоненты работают:
Тест на BitBucket.org

 Ответить   



Написать комментарий:

Чтобы комментрировать укажите свои данные или войдите через один из социальных сервисов

Загрузка...