В последнее время очень много говорится о схеме проектирования MVC, почти все популярные PHP-фреймворки уже давно перешли на эту схему. Что же касается Kohana, то начиная с версии 3, реализована иерархическая схема MVC – HMVC. Преимущества HMVC, всем понятны и мы не будем их здесь обсуждать.
Я, как желающий сменить статус «программиста-любителя» на «программиста-профессионала» решил, что уже достаточно изучил PHP и пора начинать работать с фреймворками, выбор моего самого первого фреймворка пал на именно Kohana, т.к. в нем реализуется HMVC, ну и вообще, его много хвалят за простоту.
В реальности все оказалось не просто, но особенно напрягало отсутствие изолированности триад MVC, триады разбивались на части и помещались в разные папки. В моем понимании, триада Model-View-Controller, должны быть изолированны от других триад, и находиться в одной папке, конечно, это можно было реализовать в Kohana, но все будет выглядеть очень «коряво» и запутанно. Еще было не понятно, почему не используется такая замечательная возможность PHP как пространство имен? Просмотрел еще несколько HMVC-фреймворков, но не к одному «душа не легла». Тогда я решил самостоятельно реализовать HMVC.
Сразу установил несколько директив:
- Должно использоваться пространство имен
- Автозагрузка классов работает на пространстве имен
- В автозагрузку можно добавлять несколько папок
- Каждое приложение имеет свою папку, на которую устанавливается автозагрузка
- Каждая триада, в том числе и шаблоны, помещается в отдельную папку, соответственно, находятся в своем пространстве имен
- По умолчанию, части MVC называются своими именами, Controller, Model и View, в каждой папке триады должна быть папка templates для шаблонов
- Очень простой роутинг, URI просто разбивается в массив
- Controller похож на Kohana_Controller
- Controller получает информацию о том, что от него хотят в конструкторе, разбирает информацию URI-массива, после чего запускает метод action()
- Метод action() проверяет методы объекта и запускает соответственно action_действие()
- Генерация HTML, осуществляется методом контроллера render()
Потом добавил еще несколько:
- Роутинг осуществляет расширенный контроллер, он разбивает URI в массив, сначала проверяет свои actions, если не находит, то ищет зарегистрированный контроллер
- Результат работы модели сохраняется в контроллере, передается в View, при создании в методе контроллера render()
- Метод action() публичный, созданный контроллер можно еще раз запустить и сгенерировать HTML
- View похож на Kohana_View, для приложения, View необходимо «вытащить» в глобальное пространство имен при помощи наследования, заодно немного настроив, все остальные View должны быть наследниками глобального View
- Контроллеры и View должны знать в какой папке и в каком пространстве имен они находятся, для этого они наследуются от специального класса NameSpaceAdapter, это нужно для правильного поиска View из контроллера, а так же папки templates из родительского View
- Главный шаблон для каждой триады — template.php
- Контроллеры автоматически передают в View свой action, на основании этой информации View из главного шаблона template.php самостоятельно ищет нужный шаблон action.php, поэтому не должно быть action_template(), это приведет к зацикливанию шаблонов
- Для администрирования можно создать шаблоны с префиксом admin_action.php, которые можно вызвать только если установлена константа ADMIN
В итоге получилась небольшая HMVC структура, Model не играет роли, поэтому код не привожу, скажу только, что склоняюсь к статическому классу.
Итак, самый первый класс Autoload:
class Autoload {
static $dirs=array();
// необходимо добавлять папку с последнем слэшем!
static function add( $dir ){
if( !in_array($dir,self::$dirs) ){
self::$dirs[]=$dir;
}
}
static function findClass( $className ){
$path=str_replace('\','/',$className).'.php';
foreach( self::$dirs as $dir ){
$file=$dir.$path;
if( is_file($file) ){
return $file;
}
}
return false;
}
static function loadClass( $className ){
if( $file=self::findClass( $className ) ){
require $file;
}
}
}
spl_autoload_register( '\Autoload::loadClass' );
Autoload::add( __DIR__.'/');
Класс NameSpaceAdapter:
namespace lib;
class NameSpaceAdapter {
public function getDirectory(){
return dirname( Autoload::findClass( get_class($this) ) ).'/';
}
public function getNameSpace(){
$c=trim(str_replace('\',' ',get_class($this)));
$path=explode(' ',$c);
if( count($path) ){
array_pop($path);
return ('\'.implode('\',$path));
}
return null;
}
}
Класс Controller:
namespace lib;
class Controller extends NameSpaceAdapter {
protected $view_data=array();
protected $params=array();
protected $action=null;
protected $uri=null;
public function __construct( $a_uri=null , $controller_uri=null ){
$this->uri=$controller_uri;
if( is_array($a_uri) && count($a_uri) ){
$action=$a_uri[0];
if( is_numeric($action) ){
$this->action='show';
$this->params=$a_uri;
}else{
$this->action=array_shift($a_uri);
$this->params=$a_uri;
}
}else{
$this->action='index';
}
$this->action();
}
public function action( $action=null, array $params=null){
if( $action ) $this->action=$action;
if( $params ) $this->params=$params;
$method='action_'.$this->action;
if( !method_exists($this,$method) ){
$this->action=null; // стираем на всякий случай
$method='action404';
}
if( $this->before_action() ){
$this->$method();
}else{
$this->setError('Мeтод не доступен');
}
return $this;
}
protected function before_action(){ return true; } // в наследуемых классах здесь можно что-нибудь проверить
protected function before_view(){ } // в наследуемых классах можно что-нибудь сделать
protected function action404(){
$this->setError('Страница не найдена');
}
protected function action_index(){ throw new Exception('<pre>Метод index не реализован'); }
protected function action_show(){ throw new Exception('<pre>Метод show не реализован'); }
protected function setError( $message ){
$this->action='error'; // нужен будет подшаблон error.php
$this->view_data['title']='Ошибка';
$this->view_data['message']=$message;
}
public function render(){
$this->before_view();
// Создание View, берется пространство имен самого последнего потомка, используется NameSpaceAdapter
$viewClass=$this->getNameSpace().'\View';
$view=new $viewClass();
if( is_array($this->view_data) && count($this->view_data) ){
foreach( $this->view_data as $key=>$value ){
$view->set($key,$value);
}
}
if( $this->action ) $view->set('action',$this->action);
$view->set('uri',('/'.$this->uri));
if( defined('ADMIN') ){ // admin_action.php
if( $this->action ) $view->set('admin_action','admin_'.$this->action);
}
return $view->render();
}
public function __toString(){ return $this->render(); }
}
Класс Main — расширение Controller:
namespace libController;
class Main extends libController {
protected $controllers=array(); //array( 'uri'=>array( 'title'=>'','class'=>''),...
protected $content_controller; // вызванный контроллер
protected $auth_class='\lib\auth\BasicAdmin'; // класс, проверяющий администратора
function __construct(){
$this->checkAdmin();
$this->init();
$a_uri=$this->read_URI();
$this->call_controller($a_uri);
}
protected function read_URI(){
$arr=explode('?',$_SERVER["REQUEST_URI"]);
$uri=$arr[0];
$dirt_uri=explode('/',$uri);
$a_uri=array();
foreach( $dirt_uri as $i){
if( !empty($i) ) $a_uri[]=$i;
}
return $a_uri;
}
protected function call_controller($a_uri){
/*
метод роутера
сначала ведется поиск собственных action, если action присутствует,
то объект запускается как обычный контроллер
далее ищем контроллеры из списка, если найден, то запускается,
а ссылка на него присваивается $this->content_controller
если путь так и не найден, то запускаем собственный action404
если параметр $a_uri пустой,
то запускаем собственный конструктор с пустыми параметрами, будет вызван index
*/
// обнуление внутренних свойств, т.к. метод может быть запущен из собственных action
$this->action=null;
$this->params=null;
if( is_array($a_uri) && count($a_uri) ){
$method='action_'.$a_uri[0];
// если имеется собственный метод, или передается номер для action_show
if( method_exists($this,$method) || is_numeric($a_uri[0]) ){
parent::__construct($a_uri);
}else{ // метода нет, поиск контроллера
$controller_uri=array_shift( $a_uri );
if( isset($this->controllers[ $controller_uri ]) ){
$this->content_controller=new $this->controllers[ $controller_uri ]['class']( $a_uri , $controller_uri );
}else{
$this->action404();
}
}
}else{
parent::__construct(); // будет вызван action_index
}
}
protected function checkAdmin(){
$auth=new $this->auth_class();
if( $auth->login() ){
define('ADMIN',true);
$this->prepareForAdmin(); // подготовить контроллер для администратора
}
}
public function render(){
/*
проверяется необходимость отображения собственного View,
если определена констанкта AJAX, то выводим отображение только найденного контроллера
для работы AJAX обязательно должен быть content-контроллер
*/
if( defined('AJAX') ){
if( $this->content_controller ){
return $this->content_controller->render();
}else{
return 'Ошибка - контроллер не найден';
}
}else{
if( $this->content_controller ) $this->view_data['content']=$this->content_controller->render();
//$this->view_data['controllers']=$this->controllers;
return parent::render();
}
}
public function action_ajax(){
/*
был запрос uri /ajax/...
устанавливаем констанкту AJAX
*/
define('AJAX',true);
$this->call_controller( $this->params); // снова роутинг
}
public function action_login(){/* ничего не надо делать будет загружен подшаблон login.php */}
public function action_logout(){
$auth=new $this->auth_class();
$auth->logout();
$this->action('index');
}
public function init(){ /* для потомков */}
protected function prepareForAdmin(){/* подготовка контроллера для администратора */}
}
Класс View:
namespace lib;
class View extends NameSpaceAdapter {
static $public_uri; // папка картинок, силей и скриптов, присваивается с последним слешем
static $scripts=array(); // можно подгрузить скрипты, использовать ассоциативный массив
static $styles=array(); // можно подгрузить стили, использовать ассоциативный массив
static $global_data=array(); // глобальные данные
protected $data=array(); // собственные данные, имеют приоритет относительно глобальных
protected $templates_dir='templates'; // папка шаблонов по умолчанию, без последнего слеша
protected $template='template.php'; // главный файл шаблона по умолчанию, поэтому НЕЛЬЗЯ иметь action='template'
static function render_styles(){
/*
генерирует дополнительные скрипты, можно устанавливать из вызываемых контроллеров
вызывать внутри шаблона View::render_styles()
*/
$html='';
if( is_array(static::$styles) && count(static::$styles) ){
foreach( static::$styles as $style ){
$html.='<link href="'.static::$public_uri.'styles/'.$style.'" rel="stylesheet" type="text/css" />'."n";
}
}
return $html;
}
static function render_scripts(){
$html='';
if( is_array(static::$scripts) && count(static::$scripts) ){
foreach( static::$scripts as $script ){
$html.='<script language="javascript" src="'.static::$public_uri.'scripts/'.$script.'"></script>'."n";
}
}
return $html;
}
protected function getActionFile(){
$dir=$this->getDirectory().$this->templates_dir.'/';
if( defined('ADMIN') && isset($this->data['admin_action']) ){ // поиск admin_action.php
$file=$dir.$this->data['admin_action'].'.php';
if( !file_exists($file) ) $file=$dir.$this->data['action'].'.php';
}elseif( isset($this->data['action']) ){
$file=$dir.$this->data['action'].'.php';
}
if( $file && file_exists($file)){
return $file;
}else{
return null;
}
}
public function set( $name, $value=null ){
if( is_array($name) ){
foreach( $name as $key=>$value ){
$this->data[$key]=$value;
}
}else{
$this->data[$name]=$value;
}
return $this;
}
public function render(){
/*
функция запускает шаблоны для отображения данных
берется папка по умолчанию (templates), относительно класса
первый файл для отображения template.php, или admin_template.php
извлекаются переменные из View::$global_data - это глобальные переменные
*/
$dir=$this->getDirectory().$this->templates_dir.'/';
if( defined('ADMIN') ){
$template=$dir.'admin_'.$this->template; // вдруг есть admin_template.php
if( !file_exists($template) ) $template=$dir.$this->template;
}else{
$template=$dir.$this->template;
}
extract( static::$global_data );
extract( $this->data, EXTR_OVERWRITE );
$public_uri=static::$public_uri;
ob_start();
require ($template);
return ob_get_clean();
}
static function microRender( $template, $data ){
/*
важно использовать эту функцию в нужном пространстве имен,
шаблон запускается "напрямую" без template.php
*/
$dir=dirname( Autoload::findClass(get_called_class()) );
$file=$dir.'/templates/'.$template.'.php';
if( file_exists($file) ){
if( is_array($data) ) extract($data);
ob_start();
require ($file);
return ob_get_clean();
}else{
return "не удалось найти файл шаблона $file<br>";
}
}
public function __toString(){
return $this->view->render();
}
}
Далее очень краткий пример реализации
Реализация главного шаблона:
namespace main;
class Controller extends libControllerMain {
protected $controllers=array(
'pages'=>array(
'title'=>'Страницы',
'class'=>'\pages\Controller'
)
);
protected function action_index(){
$static_page=new static_pagesController(array('get_page','index'));
$this->view_data['content']=$static_page->render();
}
protected function action_about(){
$static_page=new static_pagesController(array('get_page','about'));
$this->view_data['content']=$static_page->render();
}
protected function action_contacts(){
$static_page=new static_pagesController(array('get_page','contacts'));
$this->view_data['content']=$static_page->render();
}
protected function prepareForAdmin(){
if( defined('ADMIN') ){
// добавление в список контроллеров, доступных для вызова по uri только администратору
$this->controllers['banners']=array(
'title'=>'Баннеры',
'class'=>'\banners\Controller'
);
$this->controllers['static_pages']=array(
'title'=>'Статические страницы',
'class'=>'\static_pages\Controller'
);
}
return true;
}
protected function before_view(){
if( !defined('AJAX') ){
/*
небольшая экономия ресурсов
без необходимости не трогаем модель и View
*/
}
}
}
View главного контроллера, примерно так же выглядят View остальных триад
namespace main;
class View extends View {
function __construct(){
static::$scripts['jquery']='jquery-1.9.0.js';
static::$styles['main']='main.css';
}
}
Пример главного шаблона template.php вызываемого контроллера
<div class='pages'>
<?php
if( $file=$this->getActionFile() ){
require $file;
}else{
echo "Ошибка шаблона";
}
?>
</div>
Приведенный код является частью моего проекта «по вечерам», немного обрезано в хабра-редакторе, поэтому что-то может не работать, выкладывать demo не готов — его пока нет.
Таким образом я получил то, что хотел:
- ЧПУ
- легкость
- пространства имен
- триады расположены изолированно, в том числе и шаблоны
- администрирование можно встроить прямо в триаду при помощи префиксов admin_ для шаблонов
- низкая связанность классов контроллер -> View и главный_контроллер -> контроллер, а также View -> шаблон
- автоматический роутинг, его можно сделать более гибким перекрыв action404()
- легко реализовать AJAX запросы
- можно очень легко подключать свой набор скриптов и стилей для каждого контроллера
Правда, здесь нет модели и кэширования, а также, нужно очень хорошо подумать о безопасности.
Хотелось бы услышать мнение профессионалов о недостатках, а может быть и вообще не стоит изобретать велосипед.
Автор: lexGolubtsov