Pull to refresh

PHPLego: Плагины к сайту своими руками

Reading time11 min
Views14K


Доброго утра, дорогие Хаброчитатели!

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

В этой статье я хочу поделиться скромным микровелосипедом, который помогает мне в нелегком деле сайтостроительства.

Наше первое знокомство с вами оказалось очень интересным, и я искренне признателен вам, за вашу конструктивную критику. Надеюсь продолжить в том же ключе.

Итак, для себя я сформулировал задачу по следующим криетриям:

1) Каждый модуль должен содержать все необходимое для работы в одной папке — и шаблоны, и модель, и контроллер. Дабы его легко можно было скопипастить, подправить — и вуаля — новый модуль.
2) Модуль ничего не должен знать о тех, кто его создает — все необходимые ему для работы данные он получает через конструктор. Это для того, чтобы модуль работал не только на моем сайте, но и на всех сайтах моих друзей и клиентов без всякого допиливания напильником.
3) Для того, чтобы пользоваться модулем его не должно быть нужно где-либо регистрировать или инклудить дополнительные файлы. Это тупо раздражает.
4) Модуль может состоять из модулей. Т.е. должна быть поддержка вложенных модулей.
5) Ссылки (a href=...) внутри шаблонов модулей должны быть относительными, не зависящими от того, на какой глубине вложенности находится модуль. Чтобы банально не править шаблоны, если мы перемещаем модуль из одного родительского модуля в другой.
6) Сам сайт тоже должен быть модулем, раз уж на то пошло. Дабы можно было купить у друга уже рабочий сайт, положить себе в папку и встроить весь его на какую-нибудь страницу без лишних переделок.

Ну вот, для одной статьи я думаю достаточно, приступим к реализации.


Файловая структура проекта


Для начала набросаем структуру файлов проекта:
/nome/user/www/
|---[.myengine]				// папка нашего движка 
|   |---[classes]			// модули движка
|   |---autoload.php			// этот файл нужно заинклудить, чтобы были доступны модули
|   `---README.TXT			// инструкция как пользоваться нашим движком
|---[classes]				// модули проекта
|   |---[articles]			// модуль статей
|   |   |---[m]				// классы модели 
|   |   |   `---article.class.php	// класс статьи (имя класса: articles_m_article)
|   |   |---[view]			// шаблоны и CSS модуля статей
|   |   |   |---[css]			// папка стилей модуля
|   |   |   |   `---style.css		// файл стилей модуля
|   |   |   |---article_list.tpl	// шаблон списка статей
|   |   |   `---one_article.tpl		// шаблон просмотра одной статьи
|   |   `---controller.class.php	// контроллер модуля статей (имя класса: articles_controller)
|   |---[comments]			// модуль комментарий
|   |---[fotos]				// модуль фотографий
|   |---[site]				// модуль нашего сайта (батя модуль)
|   `---[users]				// модуль пользователей
|---.setup.php				// настройки проекта (пароли к базе, всякие константы и т.п.)
`---index.php				// точка входа на сайт



Автозагрузка модулей


Для чистоты системы, устроим так, чтобы в любом месте сайта, где мы хотим использовать модули нужно было заинклудить всего лишь один файл, какой-нибудь autoload.php. И сделаем так, чтобы путь к папке модулей был настраиваемым (какая-нибудь глобальная переменная) или, даже лучше, пусть это будет несколько путей. Ну это может понадобится, например, для того чтобы сделать две папки модулей — одна приватная, только для себя, а другая расшаренная — для коллективной разработки.

В нашем случае есть папка /.myengine/classes — это модули нашего движка, какие-то модули, которые мы используем во всех наших проектах. А /classes — это папка модулей самого проекта.

Итак, сам файл autoload.php:
<?php

// это магическая функция PHP,  она вызовется каждый раз, когда мы напишем new SomeClass()
function __autoload($class_name){
    $class_folder = 'classes'; // тут задается имя папки модулей
    
    // Локальные модули 
    //в каждой папке проекта может быть папка $class_folder. Она и называется локальными модулями
    $class_paths[] = dirname($_SERVER['SCRIPT_FILENAME'])."/$class_folder/";
    
    // Модули движка
    $class_paths[] = __DIR__."/classes/";

    //добавим пути из глобальной переменной $CLASS_PATHS
    if(!empty($GLOBALS["CLASS_PATHS"])){
        if(!is_array($GLOBALS["CLASS_PATHS"])) throw new Exception('$CLASS_PATHS must be array!');
        $class_paths = array_merge($class_paths, $GLOBALS["CLASS_PATHS"]);
    }
    
    //Группировка по подпапкам (для примера возьмем класс с именем A_B_C)
    $slashed_class_name = str_replace("_", "/", $class_name); // A/B/C
    $short_path = substr($slashed_class_name, 0, strrpos($slashed_class_name, '/')); // A/B

    foreach($class_paths as $class_path){
        // если класс A_B_C находится в файле /A/B/C.class.php
        $file_full_name = "{$class_path}/{$slashed_class_name}.class.php";
        if(file_exists($file_full_name)){
            require_once($file_full_name);
            return;
        }

        // если класс A_B_C находится в файле /A/B/C/A_B_C.class.php
        // просто иногда так красивее - имя файла равно имени класса (и в редакторе файлы легче различать)
        $file_full_name = "{$class_path}/{$slashed_class_name}/{$class_name}.class.php";
        if(file_exists($file_full_name)){
            require_once($file_full_name);
            return;
        }
    }
}

?>


Небольшие пояснения к файлу автозагрузки. Здесь мы сделали возможность добавлять пути к модулям в глобальный массив $CLASS_PATHS. Автолоад переберет пути моделей в таком порядке:
1) сначала посмотрит в папке classes рядом с вызываемым файлом
2) если не нашел, посмотрит в модулях движка
2) если не нашел и там, будет перебирать все папки, добавленные в $CLASS_PATHS.

Слишком много добавлять путей в $CLASS_PATHS я бы не рекомендовал, — все таки каждое обращение к файловой системе на предмет существования файла — это время. Хоть и небольшое, но все таки.

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

<?php
include __DIR__.'.myengine/autoload.php'; // инклудим автозагрузку модулей

//также тут всякие настройки базы данных, и вообще любые настройки проекта
?>


А все .setup.php файлы в подпапках проекта будут подключать .setup.php верхнего уровня:

<?php
// подключаем .setup.php верхнего уровня:
include __DIR__.'/../.setup.php';
?>


Это удобно потому что все файлы, создающие модули всегда содержат одну и ту же строчку:

<?php
include '.setup.php'; // -- вот эта строчка всегда одинакова во всех файлах

// Имеется ввиду файлы, создающие модули
$m = new SomeModule();
$m->run();
echo $m->getOutput();
?>


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

Вообще, инклудить файлы — это сродни склеиванию проекта изолентой, если их много и они сложные — программа превращаетяс в тарелку с макаронами, в котором один файл тянет за собой другой, а тот третий — что неудобно. Поэтому мы избавились от иклудов — классы модулей вообще ничего не иклудят — они сами инклудятся автозагрузкой. А PHP-файлы исполняющие модули всегда содержать только один инклуд — include ".setup.php".

Примечание: некоторые файлы проекта начинаются с '.' (точки) для того, чтобы при сортировке по имени они были сверху


Класс-контроллер модуля


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

abstract class Lego{

	.................. // здесь код из статьи http://habrahabr.ru/company/microset/blog/109481/

	abstract public function getDir(); //этот метод потомка, который должен вернуть путь к модулю


	// Получаем веб папку модуля (т.е. путь, который можно ввести в браузере)
	public function getWebDir(){
		$viewdir = str_replace('\\', '/', $this->getViewDir()); // для винды, заменяем слеши на прямые
		//внимание, микровелосипед! Отрезаем от пути к файлу DOCUMENT_ROOT:
		return str_ireplace($_SERVER['DOCUMENT_ROOT'], '', $viewdir).'/'.$dirname; 
	}

	// возвращет список всех Ява-скриптов, необходимых для модуля
	public function getJavascripts(){
		$js = array();
		$h = @opendir($this->getDir()."/js");
		while($file = @readdir($h))
			if(preg_match("/(\.js|\.js\.php)$/i", $file))
    			$js[] = $this->getWebDir()."/js/".$file;
		return $js;
	}

	// возвращает список всех стилей, необходимых для модуля
	public function getStylesheets(){
		$css = array();
		$h = @opendir($this->getDir()."/view/css");
		while($file = @readdir($h))
			if(preg_match("/(\.css|\.css\.php)$/i", $file))
    			$css[] = $this->getWebDir()."/view/css/".$file;
		return $css;
	}

	// получить блок, для вставки в шапку, для подключения всех скриптов и стилей модуля
	public function getHeaderBlock(){
		$csses = $this->getStylesheets();
		$jses = $this->getJavascripts();
		$ret = "";
		foreach($csses as $one) 
			$ret .= "\n<link rel='stylesheet' href='{$one}' type='text/css' media='screen' />\n";
		foreach($jses as $one) 
			$ret .= "\n<script type='text/javascript' src='{$one}'></script>\n";
		return $ret;
	} 

	//функция рендерит шаблон модуля (принимает просто имя шаблона, без пути)
	public function fetch($template){
		return Smarty::fetch($this->getDefaultDir().'/view/'.$template);
	}
}


Таким образом мы отвязали расположение файлов стилей, ява-скриптов и шаблонов от проекта в целом. Модуль можно перемещать из папки в папку, переименовывать и система будет продолжать работать. Теперь нам осталось самое интересное: как внутри шаблонов указывать ссылки? Ведь они тоже должны быть отвязаны от проекта в целом и от расположения модулей.

Относительные ссылки в шаблонах


Если мы вспомним предыдущую статью, каждый Lego-объект арендует свою переменную-массив в адресной строке, имя которой совпадает с именем модуля. Для того, чтобы сослаться на какой-то метод контроллера модуля, достаточно взять текущую адресную строку, и подменить в ней данные, относящиеся только к текущему модулю. Для ленивой работы с GET-параметрами адресной строки, давай создадим класс UriConstrucor:

// класс для работы с адресной строкой
class UriConstructor{
	public $arr;
	public function __construct($arr = false){
		$this->arr = $arr ? $arr : $_GET;
	}
	
	// задать значение в адресной строке (может принимать массив в качестве значения)
	public function put($key, $val){
		$this->arr = array_replace_recursive($this->arr, array($key => $val));
		return $this;
	}

	// удалить переменную из адресной строки
	public function remove($key){
		unset($this->arr[$key]);
		return $this;
	}

	// если мы решили начать создавать строку с нуля
	public function clear(){
		$this->arr = array();
		return $this;
	}

	
	// установить параметры в адресной строки для конкретного лего
	public function set($lego_name, $action /*....*/){
		if(isset($this->arr[$lego_name]) && !is_array($this->arr[$lego_name])) 
			unset($this->arr[$lego_name]);
		$this->arr[$lego_name]['act'] = $action;
		$params = func_get_args();
		array_shift($params);
		array_shift($params);
		foreach($params as $key=>$one){
			$this->arr[$lego_name][$action][$key] = $one;
		}
		return $this;
	}

	// частный случай предыдущей функции, устанавливаем метод и аргументы для текущего лего
	public function setAct($action /*....*/){
		$lego = Lego::current();
		$params = array($lego->getName());
		$params = array_merge($params, func_get_args());
		return call_user_func_array(
			array($this, "set"),
			$params
		);
	}

	// возвращает не просто get-строку, полный урл (с именем скрипта и вопросиком)
	public function url($path = false){
		if(!$path) $path = $_SERVER['SCRIPT_NAME'];
		return $path.'?'.$this;
	}

	// если объект приводится к строке - он превращается в GET-строку
	public function __toString(){
		return http_build_query($this->arr);
	}

	// для дебага иногда полезно посмотреть GET-строку как массив	
	public function asArray(){
		return $this->arr;
	}
}


А в базовый класс Lego добавляем еще пару методов:

abstract class Lego{

	.................. 

	// создаем конструктор урлов
	public function uri(){
		return new UriConstructor();
	}
	
	// вот он, тот самый метод, который мы будем вызывать из шаблона в атрибуте href
	// ему передается имя action-метода контроллера, и произвольно число параметров, в него передаваемое 
	public function actUri($action /* params */){
		$params = func_get_args();
		array_unshift($params, $this->getName());
		return call_user_func_array(
			array($this->uri(), "set"),
			$params
		);
	}
}


А в шаблонах мы пишем ссылки вот так:
<a href="{$lego->actUri('allfotos')->url()}">Все фотографии</a>

Или так, с аргументом, айдишником фотки:
<a href="{$lego->actUri('showonefoto', $id)->url()}">Следующая фотография</a>

//на самом деле в вывод вставится строка вида:
<a href="/index.php?.....прочие_лего_параметры...&fotos[act]=showonefoto&fotos[0]=123">....</a>



Предварительно, конечно, нужно передать сам лего объект шаблонизатору, чтобы была доступна переменная $lego. Это можно сделать в методе run() базового класса Lego:
abstract class Lego{

	.................. 

	// создаем конструктор урлов
	public function run(){
		Smarty::assign("lego", $this); //во всех шаблонах $lego - это лего текущего модуля
		..... //код из предыдущей статьи
	}
	
}


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

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

Приведем код типового модуля:

/*
Модуль фотографий, отображает все фотографии, привязанные к объекту $entity_id класса $entity_name
*/

class fotos_controller extends Lego{
	private $entity_id;
	private $entity_name;
	private $num_for_page = 5;
	
	// конструктор принимает идентификатор объекта (обычно пользователя), чьи фото нужно отобразить
	function __construct($name = false, $entity_id = 0, $entity_name = "User"){
		parent::__construct($name);
		$this->entity_id = $entity_id;
		$this->entity_name = $entity_name;
	}

	
	// тот самый метод, который заставляет нас переопределить базовый класс Lego. 
	public function getDir(){ return __DIR__; } // определять его - наша карма навеки

	// главная страница модуля, тображает все фотографии
	function action_index(){
	    Database::query("select * from `fotos` 
	        where `entity_name`='{$this->entity_name}' and `entity_id`={$this->entity_id} and deleted = 0
	        order by created desc");
	    $fotos = Database::fetchObjects();
	    Output::assign("fotos", $fotos);
	    return $this->fetch("allfotos.tpl");
	}

	// так будет отображаться модуль в главной полосе
	function action_mainbar(){
	    $offset = $this->_get($this->getName()."_offset", 0);
	    Database::query("select * from `fotos` 
	        where `entity_name`='{$this->entity_name}' and `entity_id`={$this->entity_id} and deleted = 0
	        order by created desc
	        limit {$offset}, ".($this->num_for_page+1));
	    $fotos = Database::fetchObjects();
	    Output::assign("fotos", $fotos);
	    Output::assign("offset", $offset);
	    Output::assign("num_for_page", $this->num_for_page);
	    
	    return $this->fetch("lego_fotos.tpl");
	}

	// в боковой полосе он будет отображаться так же как и в главной
	function action_sidebar(){
	    return $this->action_mainbar();
	}

	// обработчик на загрузку фотографии (сохраняет фото из POST)
	function action_submit(){
		$f = new tbl_fotos();
		$f['entity_name'] = $this->entity_name;
		$f['entity_id'] = $this->entity_id;
		$f['user_id'] = User::getCurrentUser()->getId();
		$f['text'] = $this->_post($this->getName()."_text");
		$f['file_id'] = FotoStorage::putFromPost($this->getName()."_file");
		if($f['file_id']) $f->insert();

		$this->_goto($this->actUri("mainbar")->url()); //_goto - это обычный header("Location: ...
	}

	// просмотр одной фотографии в полном размере
	function action_showone($foto_id){
		$f = new tbl_fotos($foto_id);
		Output::assign("foto", $f);
		$ret = $this->fetch("showone.tpl");

		// КОММЕНТАРИИ. Фотогафию можно комментировать
		$c = new comments_controller("foto_comments", "tbl_fotos", $f->getId());
		$c->run();
		return $ret.$c->getOutput(); //склеиваем выводы двух модулей
	}

	
	// кнопка "установить фото основным"
	function action_set_as_main($foto_id){
		Auth::authorize();
		$f = new tbl_fotos($foto_id);
		$user = $f->getOwner();
		$user['foto'] = $f['file_id'];
		$user->update();
		$this->_goto($this->actUri("showone", $foto_id)->url());
	}

	
	// кнопка "удалить фото"
	function action_delete($foto_id){
		Auth::authorize();
		$f = new tbl_fotos($foto_id);
		//FileStorage::delete($f['file_id']);
		if($f->isMain()){
			$user = User::getCurrentUser();
			$user['foto'] = "";
			$user->update();
		}
		$f['deleted'] = 1;
		$f->update();
		$this->_goto($this->actUri("showone", $foto_id)->url());
	}

	// кнопка "восстановить удаленную фотографию"
	function action_restore($foto_id){
		Auth::authorize();
		$f = new tbl_fotos($foto_id);
		$f['deleted'] = 0;
		$f->update();
		$this->_goto($this->actUri("showone", $foto_id)->url());
	}
}


Каждый модуль, в том числе и корневой модуль сайта, выполняется следующими строчками кода.
Например, это index.php в корне проекта:
<?php
include ".setup.php"; // подключаем автоподгрузку классов

$lego = new site_controller();	// Создаем батя-контроллер сайта
$lego->run();		// запускаем его
echo $lego->getOutput();// Вуаля! Выводим сайт нашему дорогому пользователю
?>


Вот собственно и все.
Как добавить Lego-модулям живительного AJAX, можно почитать тут.
Потестить, как это работает с AJAX-ом на деле можно тут.

Надеюсь кому-то пригодится эта статья.
Всем удачного Лего-программирования. :)
Спасибо за внимание! Всегда ваш, Йожик.
Tags:
Hubs:
+35
Comments51

Articles

Information

Website
xn--e1afggmlij4g.xn--p1ai
Registered
Founded
Employees
1 employee (me only)
Location
Россия