Записки Вредного программиста

enjoy, motherfuckers ;)

Пишем свой первый фреймворк на PHP

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

Структура папок

Так как на меня большое влияние в последнее время оказал Zend Framework, структура папок, а также несколько еще штук будут очень похожи.

В корне мы имеем две папки app и library. Также в корне есть два файла (опять же все как у ZF) .htaccess (будет перенаправлять все запросы в index.php) и сам index.php, который эти самые запросы принимать будет.

.htaccess и index.php

.htaccess полностью взял с ZF

1
2
3
4
5
6
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]
RewriteRule ^.*$ index.php [NC,L]

Мне этого было более, чем достаточно. По сути этот .htaccess перенаправляет все запросы, если они не ведут на существующую директорию или файл нашему index.php.

index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
// объявляем нужные константы
define('APPLICATION_PATH', realpath(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'app'));
define('LIBRARY_PATH', realpath(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'library'));
// добавляем путь к library к indlude path
set_include_path(implode(DIRECTORY_SEPARATOR, array(
            realpath(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'library'),
            get_include_path()
        )));

// подключаем Автолоадер
require_once 'MVC/Autoload/Autoloader.php';

// регистрируем наш автолоадер
spl_autoload_register(array('MVC_Autoload_Autoloader', 'autoload'));

// создаем инстант нашего "Фронт Контроллера"
// в кавычках он потому, что это вовсе не паттерн Фронт Контроллер )
$frontController = new MVC_FrontController($_SERVER);
// запускаем наше приложение
$frontController->run();

папка library

В данной папке будет пока две директории MVC и Smarty, первая – это наш фреймворк, вторая как вы уже догадались – шаблонизатор :)

Рассмотрим поподробнее папку MVC MVC_Autoload_Autoloader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

class MVC_Autoload_Autoloader
{

    public static function autoload($className)
    {
        // все как в ZF, чтобы отыскать класс заменяем в нем
      // '_' на '/'
        $className = str_replace('_', '/', $className);
        $classPath = LIBRARY_PATH . DIRECTORY_SEPARATOR . $className . '.php';
        if (file_exists($classPath) && is_readable($classPath)) {
         // подключаем его, если файл имеется и мы имеем к нему доступ
            require_once $classPath;
        } else {
            //throw new MVC_Exception("Class name '{$className}' not found");
        }
        //echo $classPath;
    }

   // тут я планировал указывать какие еще префиксы загружать автоматически
    public static function registerAutoload()
    {

    }
}

MVC_Autoload_Resource – будет отвечать за подгрузку контроллеров, правда получился он не особо гибким и пути были жестко прописаны в код (очень не хорошо получилось).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

class MVC_Autoload_Resource
{
   // думал будет у меня несколько ресурсов
   // аля контроллеры, модели и прочие штуки
    protected static $_resources = array(
        'controller' => '@App_Controller_(\w+)Controller@',
    );

   // метод без проверок, т.к. фреймворк создавался для ознакомительных целей
    public static function autoload($className)
    {
      // пробегаемся по всем ресурсам
      // и смотрим на что заканчивает класс
        foreach(self::$_resources as $resource => $path) {
         // если на то, что нужно подгружаем его
            if (ucfirst($resource) == substr($className, -strlen($resource))) {
                if (preg_match(self::$_resources[$resource], $className, $matches)) {
                    self::load($matches[1], $resource);
                }
            }
        }
    }

    protected static function load($name, $resourceType)
    {
        switch ($resourceType) {
            case 'controller':
                require_once APPLICATION_PATH . DIRECTORY_SEPARATOR
                        . 'controllers' . DIRECTORY_SEPARATOR
                        . "{$name}Controller.php";


                break;

            default:
                break;
        }
    }

}

Стукрутра папок в приложении (App)

3 папки: config, controllers, views config – содержит на данный момент всего один файл с настройками приложения application.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php

$settings = array(
    'application' => array(
        'applicationName' => 'first application'
    ),
   // самое интересное, наверное, в этом фреймворке
    'routes' => array(
      // все рауты имеют имя, шаблон или регулярное выражение
      // контроллер, экшн и параметры
        'static' => array(
            'template' => '^(\w+)\.html$',
            'controller' => 'static',
            'action' => 'show',
            'params' => array(
                'name' => 1
            )
        ),
        'main' => array(
            'template' => null,
            'controller' => 'index',
            'action' => 'index'
        ),
        'dynamic' => array(
            'template' => '^(\w+)\/(\w+)$',
            'controller' => 'dynamic',
            'action' => 'show',
            'params' => array(
                'category' => 1,
                'article' => 2
            )
        )
    ),
    'view' => array(
      // где будут лежать наши шаблоны (views)
        'viewPath' => APPLICATION_PATH . DIRECTORY_SEPARATOR . 'views',
      // имя нашего шаблона (layout)
        'template' => 'template.tpl'
    )
);

Вскользь хочу упоминуть лишь некоторые классы MVC_Registry – стандартный паттерн Registry без излишеств, нужен нам для того, чтобы хранить данные и предоставлять доступ к ним из любого места в приложении.

MVC_Request – класс запроса, который мы строим в нашем “Фронт контроллере” и передаем в контроллеры приложения. Содержит параметры в запросе, активный контроллер и экшн.

MVC_View – класс, который просто инициализует Smarty и устанавливает все нужные нам значения путей для шаблонов.

“Front Controller”

Этот класс – точка входа для нашего приложение, хоть и должен был он реализован быть как Одиночка (Singleton)

MVC_FrontController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
<?php

class MVC_FrontController
{

    protected $_routes;
    protected $_settings;
    protected $_controller;
    protected $_action;
    protected $_params;

    public function __construct($request)
    {
        $this->_initConfigs();
        $this->_initResources();
        $this->_initRoutes();
    }

    protected function _parseRequest()
    {
        return $_SERVER['REQUEST_URI'];
    }

    public function run()
    {
        $uri = $this->_parseRequest();

        $activeRoute = $this->_checkActiveRoute($uri);
        if (null === $activeRoute)
            throw new MVC_Exception('Cannot find active route');
        $this->_dispatch($activeRoute);

        $controllerName = sprintf('App_Controller_%sController', ucfirst($this->_controller));
        $controllerObj = new $controllerName(
                        new MVC_Request(
                                array('params' => $this->_params,
                                    'controller' => $this->_controller,
                                    'action' => $this->_action)
                        )
        );
        $methodName = $this->_action . 'Action';
        if (method_exists($controllerObj, $methodName)) {
            $controllerObj->$methodName();
            $controllerObj->render();
        } else {
            throw new MVC_Exception('action not found');
        }
    }

    protected function _initConfigs()
    {

        require_once APPLICATION_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'application.php';

        MVC_Registry::getInstance()->set('settings', $settings);
        $this->_settings = $settings;
    }

    /**
     * init resource autoloading
     */
    protected function _initResources()
    {
        spl_autoload_register(array('MVC_Autoload_Resource', 'autoload'));
    }

    /**
     * init routes
     */
    protected function _initRoutes()
    {
        if (isset($this->_settings['routes'])) {
            $this->_routes = $this->_settings['routes'];
        } else {
            throw new MVC_Exception('Routes must be specified');
        }
    }

    /**
     * get active route name
     *
     * @param string $uri
     * @return string active route name
     */
    protected function _checkActiveRoute($uri)
    {
        $uri = substr($uri, 1);
        if (trim($uri)) {
            $activeRoute = null;

            foreach($this->_routes as $name => $routeSettings) {
                if (!$routeSettings['template'])
                    continue;
                if (preg_match('@' . $routeSettings['template'] . '@', $uri, $matches)) {
                    if (isset($routeSettings['params'])) {
                        foreach($routeSettings['params'] as $paramName => $param) {
                            $this->_params[$paramName] = $matches[$param];
                        }
                    }
                    $activeRoute = $name;
                }
            }
        } else {
            $activeRoute = 'main';
        }

        return $activeRoute;
    }

    /**
     * dispatch
     *
     * @param string $activeRoute active route name
     */
    protected function _dispatch($activeRoute)
    {
        if (isset($this->_routes[$activeRoute])) {
            $this->_controller = $this->_routes[$activeRoute]['controller'];
            $this->_action = $this->_routes[$activeRoute]['action'];
        }
    }

}

Я старался комментировать не особо очевидные места, но если возникнут вопросы, обязательно задавайте их в комментериях внизу заметки. Метод _checkActiveRoute() определяет в зависимости от REQUEST_URI активный раут. И заполняет параметры для контроллера нашего приложения.

Метод _dispatch() по активному рауту определяет нужный в данный момент контроллер и экшн.

Метод run() запускает наш контроллер, передавая ему объект запроса с нужными нам параметрами. После запуска нужного экшна мы запускаем метод контроллера render() который и выводит контент.

MVC_Controller_Abstract

Этот класс раширяют все контроллеры нашего приложения, единственный метод, на котором стоит заостроить внимание это render()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function render()
    {
        $templatePath = $this->_view->template_dir . DIRECTORY_SEPARATOR;
        $templatePath .= strtolower(
                $this->getRequest()->getController()
                . DIRECTORY_SEPARATOR
                . $this->getRequest()->getAction() . '.tpl'
        );
        if (file_exists($templatePath) && is_readable($templatePath)) {
            $this->getView()->assign('tplName', $templatePath);

            if (null === $this->_view->template)
                $this->getView()->display('template.tpl');
            else {
                $this->getView()->display($this->getView()->template);
            }
        } else {
            throw new MVC_Exception("Template '{$templatePath}' not found");
        }
    }

Допустим мы прошли по адресу example.com/ нашего приложения. Наш “Фронт Контроллер” запустит app/controllers/IndexController.php и метод indexAction(). В нем, к примеру, мы присвоим переменной name значение vredniy

1
2
3
4
public function indexAction()
    {
        $this->getView()->assign('name', 'Vredniy');
    }

Дальше метод MVC_Controller_Abstract::render() отыщет наш шаблон (layout). В данном случает это, находящийся в папке app/views, файл template.tpl. Приведу его содержимое

1
2
3
4
5
6
7
8
9
10
<html>
    <head>
        <title>title</title>
    </head>
    <body>
        <h1>template</h1>
        {include file="$tplName"}
        <h2>footer</h2>
    </body>
</html>

Это и есть основной шаблон для нашего приложения. Вы спросите, а как же тогда выводится переменная name, значение которой мы присваивали в IndexController::indexAction(). Все просто: встроенная конструкция Smarty {include file=“$tplName”} заменяется содержимым соответствующего экшна видом. Метода render() в MVC_Controller_Abstract

1
$this->getView()->assign('tplName', $templatePath);

Сам же вид экшна имеет вид

1
2
<b>{$name|upper}</b>
<h2>MAin page</h2>

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

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

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

Модель в MVC

Добавил я два класса: первый это MVC_Db_Exception, чтобы выбрасывать эксепшны не просто MVC_Exception. Второй – освновной MVC_Db_Abstract, который мы будем расширять в модели. Приведу его код:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php

class MVC_Db_Abstract
{

    /**
     * PDO object
     *
     * @var Pdo
     */
    private $_connection;
    /**
     * PDO options
     *
     * @var array
     */
    protected $_options = array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'UTF8'");

    /**
     * PDO attributes
     *
     * @param array|string $attribs
     */
    public function __construct()
    {
        $this->_initConnection();
    }

   // инициализуем соединение, если его еще нет
    private function _initConnection()
    {
        if (!$this->_connection instanceof PDO) {
            $settings = MVC_Registry::get('settings');
            $dbSettings = $settings['database'];
            $dsn = sprintf('%s:host=%s;dbname=%s', $dbSettings['adapter'], $dbSettings['params']['host'], $dbSettings['params']['dbname']);
            try {
                $this->_connection = new PDO($dsn, $dbSettings['params']['username'], $dbSettings['params']['password'],
                                $this->_options);
            } catch (PDOException $e) {
                echo 'Connection failed: ' . $e->getMessage();
            }
        }
    }

   // возвращаем соединение, которое и будем использовать в моделях
    public function getConnection()
    {
        return $this->_connection;
    }

}

И внимание, кто пробует это проделать по заметке, будьте внимательны, потому что я чуть дописал MVC_Autoload_Resource, чтобы он помимо контроллеров мог подгружать еще и наши модельки. Самая актуальная версия в репозитории.

Использование модели

Создадим папку app/models, где и будут лежать наши модели. В ней создадим файл PageModel.php с таким содержимым

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

class App_Model_PageModel extends MVC_Db_Abstract
{

    /**
     * get page id
     *
     * @param int
     */
    public function getPage($id)
    {
        $statementString = 'SELECT id, alias, content FROM pages WHERE %s = ?';
        if (is_int($id)) {
            $statementString = sprintf($statementString, 'id');
        } else {
            throw new MVC_Db_Exception('id must be int');
        }
        $statement = $this->getConnection()->prepare($statementString);
        $statement->execute(array($id));
        return $statement->fetch();
    }

}

Данная модель умеет извлекать из таблицы pages нужные нам данные с помощью PHP Data Object (PHP) по id. Дамп базы данных я выложил в репозитории в папке data/dumps

Теперь в контроллере попробуем использовать нашу модель. Я взял для примера IndexController, в index экшн которого я дописал следующее.

1
2
3
4
5
6
7
8
9
public function indexAction()
{
  // создаем нашу модель
    $model = new App_Model_PageModel();
  // извлекаем из нее нужные нам данные и
  // отдаем их Smarty, т.е. во View
    $this->getView()->assign('data', $model->getPage(1));
    $this->getView()->assign('name', 'Vredniy');
}

В шаблоне views/index/index.tpl я добавил три строчки, которые и будут выводить содержимое:

1
2
3
<b>{$name|upper}</b>
<h2>{$data.alias}</h2>
{$data.content}

Вот вроде и все, еще раз удачи вам :)

Комментарии