HMVC в пространстве имен

в 8:42, , рубрики: cms, hmvc, php, Песочница, пространство имен, метки: , ,

В последнее время очень много говорится о схеме проектирования 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js