
Доброго утра, дорогие Хаброчитатели!
Хотелось ли вам когда-нибудь сделать модули к сайту ненавязчивыми, такими, чтобы было достаточно положить модуль в папку, и не проделывать больше никакой работы по их подключению. Чтобы однажды написанный блок сайта можно было использовать на новых проектах снова, независимо от их структуры.
В этой статье я хочу поделиться скромным микровелосипедом, который помогает мне в нелегком деле сайтостроительства.
Наше первое знокомство с вами оказалось очень интересным, и я искренне признателен вам, за вашу конструктивную критику. Надеюсь продолжить в том же ключе.
Итак, для себя я сформулировал задачу по следующим криетриям:
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-ом на деле можно тут.
Надеюсь кому-то пригодится эта статья.
Всем удачного Лего-программирования. :)
Спасибо за внимание! Всегда ваш, Йожик.