Подробный пример использования ООП, а также шаблонов проектирования MVC, Singleton. Вы узнаете также как можно проводить рефакторинг кода, и как использовать Ajax совместно с JSON. Рекомендовано для начинающих
В этой публикации будет продолжен разговор, начатый в статье об основах MVC. На этот раз мы рассмотрим несколько более сложный пример, и тем самым убьём сразу несколько зайцев ... .
Многие уже, наверное, видели на сайтах простой и эффективный элемент: звёздочки, указывающие рейтинг статьи или какого-нибудь другого материала. Давайте сделаем собственный скрипт на PHP, используя уже знакомый нам шаблон MVC. Однако сразу предупрежу, что мы будем создавать учебный код, подвергать его рефакторингу, поэтому конечный результат, разумеется, получится не таким идеальным, как обычно происходит в нормальных боевых условиях.
Как и в прошлый раз, начнём с проектирования и диаграмм. Итак, сперва нужно спроектировать модель будущей системы рейтинга. Она достаточно проста, если установить следующие требования:
Итак, все требования к модели предъявлены, можно попробовать сделать диаграмму классов, которая пока, будет состоять всего из одного класса, назовём его Voter.
Этот класс содержит на удивление много методов, но в то же время, это придаёт классу достаточную гибкость при модификации в будущем (а мы его ещё будем модифицировать). Несмотря на то, что суть методов ясна из названий, перечислим их и дадим краткие пояснения к тому, что они делают.
setFilename() – устанавливает имя файла, в котором хранятся результаты оценки (рейтинга). Если файла не существует, он создаётся;
getDescriptor() – возвращает дескриптор файла в режиме чтения, который хранится в поле descriptor;
getWriteDescriptor() – возвращает дескриптор файла в режиме записи. Хранится в поле descriptor;
closeDescriptor() – закрывает дескриптор файла;
_setReadLock() – устанавливает блокировку файла на чтение;
_setWriteLock() – устанавливает блокировку файла на запись;
_unLock() – снимает блокировку с файла;
getData() – возвращает массив с данными из файла – результатами рейтинга;
setData() – устанавливает новые данные;
writeData() – записывает массив data в файл в виде строки;
getFromData(chapter, key) – возвращает конкретные данные для раздела chapter по ключу key;
setToData(chapter, key, newData) – устанавливает новые данные newData для раздела chapter по ключу key;
getVotesSum(chapter) – возвращает сумму баллов для раздела chapter;
setVotesSum(chapter) – устанавливает сумму баллов голосовавших для раздела chapter;
getVotesCount(chapter) – возвращает количество голосовавших для раздела chapter;
setVotesCount(chapter) – устанавливает количество голосовавших для раздела chapter;
getRate(chapter) – возвращает текущий рейтинг для раздела chapter;
setNewRate(chapter) – устанавливает текущий рейтинг для раздела chapter;
Сперва необходимо объяснить, зачем вообще нужен ключ в функциях getFromData и setToData. Дело в том, что в файле данные будут храниться в виде массива. Данные возможно хранить в виде массива, если предварительно их сериализовать (создать их строковое представление). Соответственно, для хранения данных нужен массив, вида:
Array ( [раздел] => Array ( [сумма_баллов] => 100, [количество_голосов] => 23 ), [раздел_2] => ... }
Исходя из структуры данного ассоциативного массива, нужны два ключа: один для хранения суммы баллов (назовём его sum), и один для хранения количества голосов (соответственно, с названием count).
Теперь можно взглянуть и на код, который был создан согласно данной схеме.
Посмотреть код
class Voter { private $descriptor; private $filename = 'vote.txt'; private $data = array(); private $isReaded = false; public function __construct() { $this->setFilename($_SERVER['DOCUMENT_ROOT'].'/'.$this->filename); } public function setFilename($file) { if (!file_exists($file)) { $fp = @fopen($this->filename, 'w') or myError('Не могу создать файл "'.$file.'"'); fclose($fp); } $this->filename = $file; } protected function getDescriptor() { if (is_resource($this->descriptor)) { return $this->descriptor; } $this->descriptor = @fopen($this->filename, 'r+') or myError('Не могу открыть файл "'.$this->filename.'"'); return $this->descriptor; } protected function getWriteDescriptor() { $this->descriptor = @fopen($this->filename, 'w+') or myError('Не могу открыть файл "'.$this->filename.'" на запись'); return $this->descriptor; } protected function closeDescriptor() { if (is_resource($this->descriptor)) { fclose($this->descriptor); } } private function _setReadLock() { if (!@flock($this->getDescriptor(), LOCK_SH)) { myError('Не могу заблокировать на чтение файл "'.$this->filename.'"'); } } private function _unLock() { if (!@flock($this->getDescriptor(), LOCK_UN)) { myError('Не могу разблокировать файл "'.$this->filename.'"'); } } private function _setWriteLock() { if (!@flock($this->getDescriptor(), LOCK_EX)) { myError('Не могу заблокировать файл на запись "'.$this->filename.'"'); } } protected function getData() { if ($this->isReaded) { return $this->data; } $this->_setReadLock(); $data = @file_get_contents($this->filename); $this->isReaded = true; if (strlen($data) < 6) { return array(); } else { $data = unserialize($data); if (!is_array($data)) { myError('Не могу прочесть данные из файла "'.$this->filename.'"'); } else { $this->data = $data; unset($data); } } $this->_unLock(); return $this->data; } function setData($data) { if (is_array($data)) { $this->data = $data; } } protected function writeData() { $this->_setWriteLock(); if (-1 == fwrite($this->getWriteDescriptor(), serialize($this->getData()))) { myError('Не могу записать данные в файл!'); } $this->_unLock(); } protected function getFromData($chapter, $key) { $a = $this->getData(); if (isset($a[$chapter][$key])) { return (float)($a[$chapter][$key]); } return 0; } protected function setToData($chapter, $key, $newData) { $a = $this->getData(); $a[$chapter][$key] = (float)($newData); $this->setData($a); unset($a); } public function getVotesSum($chapter) { return $this->getFromData($chapter, 'summ'); } public function setVotesSum($chapter, $sum) { $summa = $this->getVotesSum($chapter); $summa += $sum; $this->setToData($chapter, 'summ', $summa); } public function getVotescount($chapter) { return $this->getFromData($chapter, 'count'); } public function setVotesCount($chapter) { $ct = $this->getVotesCount($chapter); $ct += 1; $this->setToData($chapter, 'count', $ct); } public function getRate($chapter) { $count = ($this->getVotesCount($chapter) > 0) ? $this->getVotesCount($chapter) : 1; return round(($this->getVotesSum($chapter)/$count), 2); } public function setNewRate($chapter, $mark) { $this->setVotesCount($chapter); $this->setVotesSum($chapter, $mark); $this->writeData(); } public function __destruct() { $this->closeDescriptor(); } }
Резонный вопрос, который напрашивается сам собой: а зачем нужно было создавать настолько объёмный интерфейс? И действительно, зачем? Давайте проведём рефакторинг и таким образом убьём нашего первого зайца. Мартин Фаулер сказал, что излишняя косвенность вредна: в исходном коде есть несколько методов, которые вполне можно упразднить без риска запутать код. Первый кандидат – это метод, вызываемый в деструкторе. Закрытие дескриптора файла не настолько важная операция, чтобы выделять на неё целый метод.
Далее, целых три метода на блокировку файла. Возможно, это было бы полезным в более сложных случаях, но сейчас лучше сделать один метод, и передавать ему в качестве входного параметра тип блокировки.
Теперь можно заметить, что весь класс достаточно чётко делится на закрытые и открытые методы. Закрытые методы отвечают за сугубо внутренние аспекты модели (работа с файловой системой), а открытые (за исключением метода setFileName) используются исключительно для получения информации о предметной области (то есть для всего того, что необходимо нам для вывода информации о рейтинге).
Следовательно, мы можем очень удачно разделить этот класс на двое. В этом случае мы получим достаточно привлекательную возможность при случае заменить низкоуровневый класс на любой другой. Итак, разделим наш класс на два, причём целесообразно один наследовать от другого (хотя, конечно, можно сделать агрегирование или делегирование обязанностей).
Как можно заметить, модель выглядит заметно лучше. Здесь мы не только выделили отдельный класс (FileKeeper) и убрали несколько методов, но и выполнили переименование некоторых функций. И, хотя здесь всё ещё можно найти немало мест для рефакторинга (как то всё же сделать делегирование обязанностей и перестать напрямую обращаться к полю filename, создав для этого отдельный метод), мы можем утверждать, что код достаточно гибок и расширяем. Последнее утверждение докажем практическим способом сразу после того, как посмотрим на слегка сокращённый код данного этапа.
Посмотреть код
/** * FileKeeper – класс для работы с файловой системой * */ class FileKeeper { // ... опустим повторяющиеся части кода private function setLock($flag = LOCK_SH) { if (!@flock($this->getDescriptor(), $flag)) { myError('Не могу заблокировать на чтение файл "'.$this->filename.'"'); } } protected function getData() { if ($this->isReaded) { return $this->data; } $this->setLock(); // ... $this->setLock(LOCK_UN); // ... } // ... protected function writeData() { $this->setLock(LOCK_EX); // здесь мы заменили методы установки блокировки файла одним методом setLock() // ... $this->setLock(LOCK_UN); } protected function getValue($chapter, $key) { // ранее метод назывался getFromData() // ... } protected function setValue($chapter, $key, $newData) { // ранее метод назывался setToData() // ... } // метод closeDescriptor упразднён public function __destruct() { if (is_resource($this->descriptor)) { fclose($this->descriptor); } } } /** * Voter – основной класс модели * (код приведён полностью) */ class Voter extends FileKeeper { public function getVotesSum($chapter) { return $this->getValue($chapter, 'summ'); } public function setVotesSum($chapter, $sum) { $summa = $this->getVotesSum($chapter); $summa += $sum; $this->setValue($chapter, 'summ', $summa); } public function getVotesCount($chapter) { return $this->getValue($chapter, 'count'); } public function setVotesCount($chapter) { $ct = $this->getVotesCount($chapter); $ct += 1; $this->setValue($chapter, 'count', $ct); } public function getRate($chapter) { $count = ($this->getVotesCount($chapter) > 0) ? $this->getVotesCount($chapter) : 1; return round(($this->getVotesSum($chapter)/$count), 2); } public function setRate($chapter, $mark) { $this->setVotesCount($chapter); $this->setVotesSum($chapter, $mark); $this->writeData(); } }
Следующим этапом в разработке модели станет решение использовать не файлы, а базу данных. Для этого достаточно использовать тот же интерфейс предоставления доступа к данным, какой предлагает FileKeeper. И, соответственно, класс Voter следует наследовать от нового класса, который мы назовём очень просто: DataBase.
Однако, к некоторому сожалению, полного разделения на данный момент мы не имеем. Класс Voter всё-таки несколько отражает тот факт, что использует файловую систему, да и нельзя иначе: при установлении нового рейтинга необходимо записать сразу все данные внутреннего массива data в файл (метод writeData).
В случае же базы данных мы не можем оперировать сразу со всей таблицей рейтинга, потому что, раз у нас есть БД, то и количество статей (или других материалов) должно быть весьма велико. Поэтому чтение и запись должны происходить с какой-то определённой строкой в таблице.
Поэтому придётся изменить и класс Voter, что, впрочем, в нашем случае не является огромным минусом, потому как пока мы имеем дело только с моделью системы голосования, и поэтому можем изменять её как угодно, лишь бы соблюдался заранее заданный интерфейс.
Впрочем, как видно из схемы, классы получатся весьма небольшими и простыми. Поэтому переход на БД – это шаг к упрощению кода. Единственное, что целесообразно изменить ещё – это название класса Voter, лучше назвать его DBVoter.
Перед тем как взглянуть на третью модификацию кода, необходимо создать таблицу в БД. Выберем в качестве сервера БД MySQL и создадим БД, которую назовём php5, а в базе данных создадим таблицу, согласно следующему запросу:
CREATE DATABASE `php5`; CREATE TABLE `vote` ( `id` smallint(5) unsigned NOT NULL auto_increment, `chapter` varchar(254) NOT NULL, `summ` float default NULL, `count` smallint(5) unsigned default NULL, PRIMARY KEY (`id`), UNIQUE KEY `chap` (`chapter`) ) ENGINE=MyISAM DEFAULT CHARSET=cp1251;
Структура таблицы предполагает абсолютно такое же использование, как и использование сериализованного массива в случае файлов. Для каждого раздела (chapter) задаются количество голосов (count) и сумма набранных баллов (summ).
Посмотреть код
define('HOST', 'localhost'); define('USER', 'root'); define('PASSWORD', ''); define('DATABASE', 'php5'); /** * DataBase – класс, представляющий необходимые средства для работы с БД */ class DataBase { private $descriptor; private $table_name = 'vote'; private $_database_name = DATABASE; private $_host = HOST; private $_user = USER; private $_password = PASSWORD; private $data = array(); private $isReaded = false; public function __construct() { $this->descriptor = mysql_connect($this->_host, $this->_user, $this->_password) or myError('Не могу подключиться к БД!'); if (!mysql_select_db ($this->_database_name, $this->descriptor)) { myError('Не могу выбрать базу "'.$this->_database_name.'"!'); } } private function query($query) { $res = mysql_query($query) or myError('Не могу выполнить запрос: "'.$query.'" '. mysql_errno().': '.mysql_error()); return $res; } protected function getData($chapter) { if (isset($this->data[$chapter])) { return $this->data[$chapter]; } $query = 'SELECT * FROM vote WHERE chapter = "'.$chapter.'" LIMIT 1'; $result = $this->query($query); $row = mysql_fetch_assoc($result); $this->data[$chapter] = (is_array($row) && (count($row) > 0)) ? $row : -1; mysql_free_result($result); unset($row); return $this->data[$chapter]; } protected function getValue($chapter, $key) { $a = $this->getData($chapter); if (isset($a[$key])) { return (float)($a[$key]); } return 0; } protected function setValue($chapter, $array = array()) { if (count($array) < 2) { myError('Не переданы необходимые параметры'); } $a = $this->getData($chapter); if ($a != -1) { $query = 'UPDATE '; $where = ' WHERE chapter="'.$chapter.'"'; } else { $query = 'INSERT INTO '; $array['chapter'] = $chapter; $where = ''; } $query.= $this->table_name.' SET '; foreach ($array as $key => $value) { $query.= $key.'="'.$value.'", '; } $query = substr($query, 0, -2); $query.= $where; $this->query($query); unset($this->data[$chapter]); unset($a); } function __destruct() { if (is_resource($this->descriptor)) { mysql_close($this->descriptor); } } } /** * DBVoter – класс, работающий на основе БД */ class DBVoter extends DataBase { public function getVotesSum($chapter) { return $this->getValue($chapter, 'summ'); } public function getVotesCount($chapter) { return $this->getValue($chapter, 'count'); } public function getRate($chapter) { $count_ = ($this->getVotesCount($chapter) > 0) ? $this->getVotesCount($chapter) : 1; return round(($this->getVotesSum($chapter)/$count_), 2); } public function setRate($chapter, $mark) { $array = array( 'count' => ($this->getVotesCount($chapter) + 1), 'summ' => ($this->getVotesSum($chapter) + $mark) ); $this->setValue($chapter, $array); } }
Согласитесь, код получился намного проще, нежели тот, с которого мы начинали. Конечно, можно было сразу сделать нечто похожее, но тогда мы бы не узнали, каким образом нужно улучшать код (ООП порой провоцирует к созданию слишком больших классов; классов с огромным количеством методов; к созданию слишком сложных архитектур с запутанным наследованием и делегированием).
Единственное изменение, о котором следует упомянуть, так это то, что теперь для записи рейтинга используется ассоциативный одномерный массив, с ключами для суммы голосов и количества проголосовавших соответственно.
Ещё одно изменение заключается в том, что в случае БД необходимо после каждой записи в таблицу, обновлять данные для раздела, поскольку т.к. мы перешли на БД, то в любую секунду возможно изменение данных.
Напоследок, мы сделаем ещё одно небольшое изменение, которое не столь необходимо, но знать о нём и применять его, следует в любом случае. Речь пойдёт о применении шаблона Singleton, который используется, если необходимо, чтобы во время исполнения скрипта, можно было инстанцировать только один экземпляр класса. Это необходимо, например, если при инициализации класса происходит соединение с БД и извлечение из неё некоторых данных, необходимых для работы в дальнейшем.
В нашем случае подобное не происходит, но, тем не менее, применить данный шаблон практически ничего не стоит. Для этого необходимо, во-первых, сделать защищёнными конструктора классов FileKeeper и Database, во вторых, объявить в них статическую переменную $instance, и в-третьих, создать особый метод getInstance() в классах Voter и DBVoter:
// в классы Voter и DBVoter следует вставить следующий код private static $instance = false; protected function __construct() { parent::__construct(); } private function __clone() {} public static function getInstance() { if (!is_object(self::$instance)) { $c = __CLASS__; self::$instance = new $c; } return self::$instance; }
Обратите внимание на то, что подобного рода метод (getInstance()) вставляется в классы-потомки, но не в классы-родители, потому что в противном случае, создавался бы объект родителя, но не потомка.
На этом моменте работу с моделью можно считать оконченной. Можно переходить к проектированию и кодированию контроллера.
В классическом понимании шаблона MVC, контроллер является связующим звеном между моделью и представлением, и его вполне можно заменить в любой момент. В случае же нашего конкретного примера, с помощью контроллера мы можем управлять источником данных. Например, передавать данные не просто методом POST или GET, а, скажем, узнавать данные при помощи сессии или брать их из файлов cookies. Самым оригинальным способом будем считать передачу информации из файлов.. .
Контроллер должен предоставлять нам один-единственный метод DisplayRating(), больше от него ничего не требуется. В этом методе он должен самостоятельно определять – отправил ли пользователь свою оценку, и следует её обработать и выдать результат (или ошибку); или следует вывести текущие результаты и форму для оценки статьи.
Сразу уточним: поскольку наша система рейтинга не предполагает регистрации пользователей, то, в принципе, любой пользователь может проголосовать несколько раз. Однако, проверку на накрутку всё же следует сделать, и выполняться она будет именно в контроллере (если бы у нас была регистрация пользователей, эта проблема, вероятно, решалась бы в модели).
И, ещё одно замечание по поводу переменной $chapter. Как уже упоминалось выше, в ней хранится уникальное значение, однозначно определяющее тот материал, который оценивается. Это может быть id из базы, либо, например, url статьи. Поэтому в контроллер может передаваться значение для $chapter (id из БД), либо класс сам может назначить значение этой переменной (из строки запроса или каким-либо другим способом).
И снова дадим пояснения к методам класса:
displayRating() – обеспечивает вывод рейтинга в нужной форме;
updateRating() – обрабатывает данные пользователя (засчитывает его голос);
checkMark() – проверка переданной оценки на корректность;
checkChapter() – этот метод введён здесь символически. В случае наличия дерева сайта и хранения его разделов в таблице, здесь следовало бы проверять переданный номер раздела, сверяя его с БД;
checkForSpam() – символическая проверка на накрутку голосов и спам. Также может быть перенесена в модель;
К данной схеме, практически, пока нет претензий, за исключением одной. В ней присутствует агрегация, в то время как «Банда четырёх» (Gang of Four) советует пользоваться динамическим связыванием, или, например, абстрактной фабрикой (Abstract Factory). Для нашего случая это не столь необходимо, но провести при необходимости подобный рефакторинг труда не составит. А пока взглянем на получившийся код.
Посмотреть код
// параметр, передаваемый из $_GET define('VOTE', 'v'); // максимальное количество баллов define('MAX_VOTE', 5); class VoteController { private static $voter; private static $chapter; private static function init() { if (!is_object(self::$voter)) { self::$voter = Voter::getInstance(); } self::$chapter = $_SERVER['PHP_SELF']; } public static function displayRating($chapter_ = false) { self::init(); $error = false; $chapter = self::checkChapter($error, $chapter_); if ($error !== false) { VoteView::displayError($error); return true; } $error = self::updateRating($chapter); // добываем количество голосов и среднюю оценку $total = self::$voter->getVotesCount($chapter); $rate = self::$voter->getRate($chapter); // вывод формы для голосования или результатов if ($error === false) { if (!isset($_REQUEST[VOTE])) { self::checkForSpam(); VoteView::displayRateForm($rate, $total, VOTE, MAX_VOTE); } else { if (!self::checkForSpam(true)) { VoteView::displayError('Подозрение на накрутку рейтинга'); return false; } VoteView::displayResults($rate, MAX_VOTE); } } else { VoteView::displayError($error); } return true; } private static function updateRating($chapter = false) { $error = false; if (isset($_REQUEST[VOTE])) { $mark = (int) $_REQUEST[VOTE]; $error = self::checkMark($mark); if ($error === false) { self::$voter->setRate($chapter, $mark); return $error; } else { return myError($error, true); } } return false; } private static function checkMark($mark) { if (($mark <= 0) || ($mark > MAX_VOTE)) { return 'Недопустимое значение оценки!'; } return false; } private static function checkChapter(&$error, $chapter_ = false) { $chapter = ($chapter_ !== false) ? $chapter_ : self::$chapter; if (is_numeric($chapter)) { $chapter = (int) $chapter; if ($chapter == 0) { $error = 'Неверное значение раздела голосования!'; } } else { $chapter = (string) $chapter; if ($chapter === '') { $error = 'Неверное значение раздела голосования!'; } } return $chapter; } private static function checkForSpam($set = false) { session_start(); if ($set === false) { $_SESSION['vote_ticket'] = md5('some_hash'.microtime()); } else { if ((!isset($_SESSION['vote_ticket'])) || (strlen($_SESSION['vote_ticket']) != 32)) { return false; } unset($_SESSION['vote_ticket']); return true; } } }
После того, как мы посмотрели на код, к нему практически сразу возникают претензии. Главная из них заключается в такой строчке:
if (isset($_REQUEST[VOTE]))
В малых проектах это, может быть, и не важно, но в больших подобная запись может вызвать существенные осложнения при изменении, поэтому в данном случае необходимо сделать выделение метода, и вместо упомянутой выше строчки, вызывать статическую функцию:
private static function getVoteRequest() { if (isset($_REQUEST[VOTE])) { if (intval($_REQUEST[VOTE]) > 0) return (int) $_REQUEST[VOTE]; } return false; }
После введения функции getVoteRequest(), у нас появится единая функциональная точка управления потоком входящих данных (в данном случае, передаваемый из строки запроса параметр значения оценки), и мы может его безболезненно изменить в будущем: заменить на другой параметр, изменить его тип на массив или на объект. Кроме того, необходимо сделать небольшой рефакторинг, а также изменить место проверки на накрутку рейтинга.
Посмотреть код
public static function displayRating($chapter_ = false) { // ... if (self::getVoteRequest()) { if (!self::checkForSpam(true)) { $error = 'Подозрение на накрутку рейтинга'; } else { // приём оценки от пользователя $error = self::updateRating($chapter); if ($error === false) { // вывод результатов $rate = self::$voter->getRate($chapter); VoteView::displayResults($rate, MAX_VOTE); return true; } } } // вывод формы для голосования или ошибки if ($error === false) { self::checkForSpam(); // добываем количество голосов и среднюю оценку $total = self::$voter->getVotesCount($chapter); $rate = self::$voter->getRate($chapter); VoteView::displayRateForm($rate, $total, VOTE, MAX_VOTE); } else { VoteView::displayError($error); } return true; } private static function updateRating($chapter = false) { if (($error = self::checkMark(self::getVoteRequest())) === false) { self::$voter->setRate($chapter, self::getVoteRequest()); return $error; } return myError($error, true); } private static function getVoteRequest() { if (isset($_REQUEST[VOTE])) { if (intval($_REQUEST[VOTE]) > 0) return (int) $_REQUEST[VOTE]; } return false; }
Диаграмму классов, пожалуй, изменять не будем. Но приведённый выше код показывает, насколько полезно не обращаться к одним и тем же данным явно; к тому же, подобный подход избавляет от многих лишних проверок на корректность, а также устраняет дублирование.
На этом этапе разработку контроллера можно считать завершённой.
Представление нашей системы рейтинга – самая лёгкая с точки зрения PHP и самая сложная, с точки зрения программирования на стороне клиента. Но перед тем, как обсудить вопросы юзабилити, приведём ещё не обсуждавшийся, но уже использовавшийся функционал, вроде функции myError().
// проверяем, запускаем ли мы наши скрипты локально function isLocal() { return (($_SERVER['REMOTE_ADDR'] === '127.0.0.1')); } // для преобразования данных в JSON, необходимо перевести их в UTF function makeGoodForJSON($str) { return stripslashes(trim(mb_convert_encoding(rawurldecode($str), 'utf-8', 'utf-8'))); } // вывод данных по запросу Ajax function jsOutput($key, $str) { header('Content-type: text/javascript'); echo json_encode(array($key => makeGoodForJSON($str))); exit; } // проверка, нужен ли вывод в виде JSON для Ajax'a function isJs() { return ((isset($_REQUEST['js'])) ? true : false); } // вывод ошибок в зависимости от того, локальный режим или нет; // необходим JSON или нет // флаг noPrint указывает, выводить ли ошибку, или возвращать её function myError($msg_, $noPrint = false) { $msg = (isLocal()) ? $msg_ : 'Ошибка! Попробуйте ещё раз'; if (isJs()) { jsOutput('error', $msg); } $ms = '<p><b><font color="#ff0000">Error:</font> '.$msg.'</b></p>'; if ($noPrint === true) { return $ms; } else { echo $ms; } }
Из приведённого выше кода ясно, что общаться между собой клиент и сервер, будут асинхронным способом (Ajax), причём сервер будет отсылать ответ в формате JSON. О причинах подобного выбора говорить особо не будем, скажем лишь, что пересылать данные в виде XML в нашем случае смысла особого нет, поскольку изначально речи об XHTML не шло, а вот JSON теперь поддерживается PHP, и, что особенно приятно, работать с ним на стороне клиента – очень удобно и практично, а главное – просто.
Единственное, что стоит отметить особо: функция json_encode() входит в состав PHP 5.2+, но в сети достаточно классов, которые способны самостоятельно осуществлять конвертацию в JSON (например, этот).
Основа внешнего вида системы рейтинга взята отсюда, но основные принципы её работы изменены полностью, в том числе изменена и вёрстка.
Итак, нам понадобится изображение:
HTML будет в виде:
<!-- RATING --> <p class='rate'><strong>Оценка материала:</strong></p> <div id='ratingRoot'> <div id='currentRating' style='width: <?=$width;?>px;'> </div> <div id='rating'> <a href="?v=1" title="1 б."> 1</a> <a href="?v=2" title="2 б."> 2</a> <a href="?v=3" title="3 б."> 3</a> <a href="?v=4" title="4 б."> 4</a> <a href="?v=5" title="5 б."> 5</a> </div> <div class='rate'>На данный момент нет голосовавших</div> </div>
Также, нам понадобится следующий CSS-код:
@media print {
#ratingRoot {display: none}
}
#ratingRoot {position: relative; width: 280px}
#rating {
position: absolute;
z-index: 2;
width: 127px;
height: 25px;
top: 0;
left: 0;
background: url('all_star.gif') repeat-x 0 0
}
#rating A {
z-index: 11;
text-indent: -1000px;
background: none;
text-decoration: none;
display: block;
float: left;
width: 25px;
height: 25px
}
#rating A.r {background: url('all_star.gif') no-repeat 0 -24px}
#rating A.hi, #rating A:hover {background: url('all_star.gif') no-repeat 0 -49px}
#currentRating {
z-index: 1;
position: absolute;
top: 0;
left: 0;
height: 25px;
width: 90px;
background: url('all_star.gif') repeat-x 0 -24px
}
#ratingRoot DIV.rate, P.rate {
text-indent: 1px;
font: normal 11px/1em Tahoma, 'Arial', sans-serif;
color: #333;
padding: 25px 0 3px 2px
}
P.rate {padding: 0 0 3px 2px}
Несомненным достоинством данного подхода является тот факт, что мы можем достаточно точно отображать дробные результаты голосования, не ограничиваясь лишь целыми или половинчатыми звёздочками. Достигается этот эффект так:
- Основному блоку rating задан фон (backround) следующим образом:
- Вводится дополнительный блок currentRating, ширина которого пропорционально зависит от количества голосов. Например, если рейтинг 5 – это 127 пикселей, то рейтинг 2,5 балла – это 63.5 пикселя. В этом блоке установлен background (фон) со звёздочками жёлтого цвета:
Если эти два блока позиционировать абсолютно (при этом задав родительскому элементу свойство position: relative) и положить блок currentRating под rating, то можно будет добиться идеального отображения текущей оценки:
Теперь обсудим то, что касается подсвечивания звёздочек, при наведении на них курсора мыши. Отметим, что обычно, подсвечиваются все звёздочки, начиная с самой первой, и заканчивая той, на которую навели курсор. Эту задачу можно решать многими способами, начиная от задания уникальных идентификаторов и номеров ссылкам, и заканчивая заданием особых функций для каждой ссылки.
Мы поступим несколько иным способом. Во-первых, условимся, что рейтинг будет работать и с выключенным JavaScript (правда, в таком случае боты поисковых систем могут несколько повлиять на рейтинг, но ведь для них существует специальный тэг noindex. К тому же, достаточно дописать условие в функции контроллера getVoteRequest(), и поисковые боты учитываться не будут). Во-вторых, при изменении шкалы рейтинга (переход на десятибалльную систему, например), код на клиенте не должен меняться.
JavaScript-код будет работать следующим образом: специальная функция будет после загрузки html-кода обходить всё дерево ссылок, что есть в заранее заданном блоке для рейтинга. Каждой ссылке будет назначаться обработчик на наведение курсора (OnMouseOver) и на удаление курсора (OnMouseOut), а также действие на щелчок мышью. Как не трудно догадаться, по наведению на ссылку звёздочки будут подсвечиваться, по удалению курсора подсветка будет удаляться (причём в силу вышеизложенного подхода с двумя абсолютно позиционируемыми блоками, у нас нет необходимости задавать подсветку для каждой звёздочки в отдельности, что очень удобно), а на щелчок будут отправляться данные на сервер.
function initalizeRating() { var rates = document.getElementById('rating'); if ((typeof rates) == 'undefined') return false; var links = rates.getElementsByTagName('A'); var classes = new Array(); for (i = 0; i < links.length; i++) { links[i].onmouseover = function (x) {return function () {lightAll(x);}} (i); links[i].onmouseout = function (x) {return function () { lightAll(x, true);}} (i); links[i].onclick = function (x) {return function () {return rateIt((x + 1));}} (i); } } function lightAll(n, flag) { var classNm = (flag == true) ? '' : 'hi'; var rates = document.getElementById('rating'); var links = rates.getElementsByTagName('A'); for (i = 0; i <= n; i++) { links[i].className = classNm; } } function rateIt(rate) { return sendRate(rate); }
Приведённый выше код нуждается в одном пояснении. Точнее, функция InitalizeRating(), в которой используется такой приём, как замыкание. Дело в том, что в JavaScript не удастся сделать много разных функций, вида
links[i].onmouseover = function (i) {alert(i);} ;
Если в цикле запустить подобный код, то, если i будет изменяться от 1 до 10, результатом окажется десять раз подряд одно и то же сообщение 10. Это связано с тем, что функция обращается не к своей области действия, а к области действия точки вызова. Чтобы всё-таки сделать так, как нужно (передавать i-тому элементу значение i с необходимой нам обработкой последнего), необходимо использовать замыкание и переопределить область действия с помощью ещё одной функции.
Подсветка ссылок происходит в функции lightAll(), где, начиная с первой ссылки и до по порядкового номера вызвавшей событие ссылки, меняется класс у элементов. Соответственно, при удалении курсора, класс у ссылок убирается. Сам класс hi, как видно из CSS-кода, задаёт фон у самой ссылки, который перекрывает фон родительских элементов.
С помощью такого метода мы можем сделать любое удобное количество звёздочек.
Осталось реализовать две вещи: на стороне клиента отправку запроса серверу и получение от него результатов; и на стороне сервера вывод результатов как без запроса от клиента, так и с ним.
Начнём с серверной части, а именно – создадим представление. Заодно посмотрим, как осуществить вывод не только формы рейтинга, но и результатов голосования пользователя.
Итак, как уже ясно из приведённого выше кода контроллера, в представлении достаточно сделать три метода: вывод формы, вывод результатов и вывод ошибки. В представление необходимо передать для построения формы значение рейтинга, общее количество голосов; систему, по которой производится оценка (это может быть пятибалльная или десятибалльная система), и название параметра для ссылок.
Посмотреть код
// количество "звёздочек" define('STAR_COUNT', 5); // Максимальная ширина блока = (ширина одной звёздочки = 25px) * кол-во звёздочек define('BLOCK_WIDTH', 25*STAR_COUNT); class VoteView { public static function displayRateForm($rate, $total, $parName = 'v', $rateCount = 5) { $width = round((BLOCK_WIDTH/$rateCount)*$rate, 0); ?> <!-- RATING --> <p class='rate'><strong>Оценка материала:</strong></p> <div id='ratingRoot'> <div id='currentRating' style='width: <?=($width);?>px;'> </div> <div id='rating'> <? for ($i = 1; $i <= STAR_COUNT; $i++) { echo '<a href="?'.$parName.'='.$i.'" title="'.$i.' б."> '.$i.'</a>'; } ?> </div> <? if ($total == 0) { ?> <div class='rate'>На данный момент нет голосовавших</div> <? } else { ?> <div class='rate'>Текущая оценка <strong><?=$rate;?></strong> из <?=$rateCount;?><br> Всего проголосовавших: <strong><?=$total;?></strong></div> <? } ?> </div> <script type='text/javascript' src='rate.js'></script> <script type='text/javascript' src='ajax.js'></script> <script type='text/javascript'> <!-- initalizeRating(); --> </script> <? } public static function displayResults($rate, $rateCount = 5) { // это выражение лучше выделить в отдельную функцию $width = round((BLOCK_WIDTH/$rateCount)*$rate, 0); $s = ''; if (!isJS()) { $s.= '<div id="ratingRoot">'; } $s.= '<div id="currentRating" style="width: '.$width.'px;"> </div>'; $s.= '<div id="rating"> '; /* for ($i = 1; $i <= STAR_COUNT; $i++) { $s.= '<div> '.$i.'</div>'; } */ $s.= ' </div> <div class="rate">Спасибо, Ваш голос учтён!</div>'; if (!isJS()) { $s.= '</div>'; } if (!isJS()) { echo $s; } else { jsOutput('response', $s); } } public static function displayError($error) { if (!isJS()) { ?> <p class='error'><?=$error;?></p> <? } else { jsOutput('error', '<p class=\'error\'>'.$error.'</p>'); } } }
В представлении крайне простой код, и мы специально не стали использовать ни шаблонизаторов, ни, тем более, XSLT-преобразований, хотя могли бы.
Несколько комментариев напрашиваются сами собой. Во-первых, мы явно задаём ссылки. Это верно только для простейших случаев, без использования mod_rewrite, без сложной иерархической системы. Во-вторых, предполагается, что скрипт, где будет размещена форма рейтинга, сам будет обрабатывать голос пользователя. Если же система создана иначе, если в ней создан отдельный скрипт, тогда передавать одно значение оценки недостаточно, необходимо передать также ещё chapter, то есть id раздела или статьи (как правило, это число).
Впрочем, это не настолько сложная задача, чтобы на ней останавливаться подробно.
И ещё одно замечание. В модели явно делается различие на запрос от Ajax-приложения (в этом случае возвращается JSON), и на обычный HTTP-запрос. Это не совсем верно. Вернее было бы создать два различных представления и автоматически выбирать нужное, используя уже упоминавшуюся выше Factory.
Мы ещё не завершили рассмотрение системы рейтинга, но уже может прийти в голову резонный вопрос: а зачем всё так сложно? Почему так много кода?
И действительно, с помощью обычного функционального подхода, мы бы решили эту задачу за двадцать минут, и кода было бы раз так в шесть меньше. Но целью статьи было не только показать, как использовать шаблоны, но и показать, к чему это может привести. ООП часто провоцирует к «раздуванию» кода, к созданию сложных иерархических зависимостей и запутанных архитектур. Всё это, в конечном счёте, будет требовать обслуживания, и, порой, чтобы добавить даже небольшую функциональность, требуется написать достаточно большое количество кода, удовлетворяющего интерфейсу системы и правильно вписывающегося в иерархию. Более того, не знакомый с иерархией и архитектурой системы человек, порой просто не сможет ничего в ней изменить без совета со стороны разработчиков.
Такие проблемы, действительно, имеют место быть. Но, как и у любой другой медали, есть и своя положительная сторона. Системы, спроектированные и реализованные с помощью ООП и шаблонов, гораздо более гибки и позволяют вносить коренные изменения куда легче, нежели обычный код «в лоб».
Остальные особенности кода (например, расчёт ширины блока с текущим значением рейтинга или вставку отдельного js-скрипта и вызов функции) оставим на разбор читателю. Нам же осталось осветить две вещи: отправление и получение данных через Ajax, и непосредственный вызов модуля рейтинга в php-коде. Начнём с первого.
Как можно было предположить, речь пойдёт о реализации JavaScript-функции sendRate().
Мы будем рассматривать не совсем полную реализацию Ajax-клиента, поскольку нам не столь необходимы возможности определения типа возвращённых данных; возможности передачи заголовков серверному скрипту, или, например, возможности обработки возвращённого XML. Главное – это рассмотреть основные принципы работы и выяснить, как работать с JSON.
Вообще, основная методика работы с Ajax (то бишь с компонентами XMLHttpRequest или ActiveX XMLHTTP) уже давно известна и отработана. Однако, даже здесь можно столкнуться с проблемами, при задании собственных функций, выполняющихся во время загрузки или после получения ответа от сервера. Эти проблемы, как это ни странно, решаются при помощи замыкания, но уже в более интересном варианте: при помощи встроенного метода каждой функции call().
Но JavaScript тем славен, что его реализация в разных браузерах весьма и весьма неоднорода. Поэтому предупрежу сразу, приведённый ниже код не будет работать в IE5 и Opera 8. Способы «заставить» работать код есть, но их рассмотрение выходит за рамки статьи.
Итак, остановимся на работе с JSON. Суть подобного обмена данными состоит в том, что на клиенте мы получаем настоящий и полноценный объект (или массив, или объект, содержащий массив), и можем благополучно с ним работать, если у нас есть заранее известные свойства или методы(!) объекта. Если вернуться к серверному коду представления, можно обратить внимание на функцию jsOutput, которая принимает строку и ключ. Так вот, если передать этой функции, например, параметры
jsOutput('key', 'value');
{"key":"value"}
var jsonObj = eval('(' + json + ')');
var a = jsonObj.key; // a = "value"
Хотя, для наших целей достаточно было обычной обработки ответа сервера и присвоение его документу через свойство innerHTML, всё равно JSON позволяет более гибко обрабатывать ответы сервера. Скажем, можно задать целую систему правил обработки, в зависимости от наличия или отсутствия свойств в объекте.
В нашем случае, если объект содержит свойство response – был получен нормальный ответ сервера, а если свойство error – произошла ошибка.
Ниже приведён исходный код, решающий задачу отправки рейтинга и приёма ответов от него.
function ajax() {}; ajax.prototype.init = function() { this.req_ = null; if (typeof window.XMLHttpRequest != "undefined") this.req_ = new XMLHttpRequest(); else if (typeof ActiveXObject != "undefined") { try { this.req_ = new ActiveXObject("Microsoft.XMLHTTP"); } catch (error1) { if (this.req_ === null) try { this.req_ = new ActiveXObject("Msxml.XMLHTTP"); } catch (error2) { this.req_ = null; } } } } ajax.prototype.isReady = function() { state = this.req_.readyState; return (state && (state < 4)); } ajax.prototype.onRedyStateChangeFunc = function () { aj = this; if (this.req_.readyState == 1) aj.onLoading.call(); if (this.req_.readyState == 4 && this.req_.status == 200) aj.onComplete.call(aj, aj.req_); } ajax.prototype.process = function (url, parameters, onComplete, onLoading) { this.onLoading = onLoading; this.onComplete = onComplete; aj = this; if (!this.req_) { this.init(); } this.req_.onreadystatechange = function () { aj.onRedyStateChangeFunc.call(aj); } this.req_.open("POST", url, true); this.req_.send(parameters); return true; } function sendRate(rate) { var ajaxObj = new ajax(); var href = window.location.href; var url = (href.indexOf('?') == -1) ? href + '?v='+ rate + '&js=1' : href + '&v=' + rate + '&js=1'; ajaxObj.process(url, '', rateResults, loading); return false; } var storedHTML = ''; function loading() { var el = document.getElementById('ratingRoot'); if (!el) return ''; el.className = 'loading'; if (storedHTML == '') storedHTML = el.innerHTML; el.innerHTML = ''; if (document.createElement) { var img = document.createElement("img"); img.src = 'loading.gif'; el.appendChild(img); } } function rateResults() { var json = this.req_.responseText; try { var jsonObj = eval('(' + json + ')'); } catch (e) { return false; } if (typeof jsonObj != 'object') return false; if (jsonObj.error && typeof jsonObj.error == "string") { alert(jsonObj.error); return false; } else { var el = document.getElementById('ratingRoot'); if (el && jsonObj.response) { el.innerHTML = jsonObj.response; } } }
Здесь объект ajax отвечает за взаимодействие с сервером, функция loading() выводит пользователю информацию о том, что его голос обрабатывается, а функция rateResults() обрабатывает ответ сервера и выводит результаты. На сервере, кстати, можно было бы и не узнавать новое значение рейтинга, с учётом голоса пользователя, который отправил свою оценку, а «подправить» текущую оценку (то есть ширину блока currentRating) с помощью средств клиента.
Обратите внимание на то, что в функции sendRate() в адрес скрипта добавляется не только параметр оценки ("v"), но и параметр "js". Это сделано для того, чтобы сервер мог отличить обычный запрос от запроса посредством Ajax (см. php-функцию isJs()).
Объект для работы с Ajax рекомендуется заменить своим или готовым.
Наконец, осталось сделать одну-единственную вещь: разместить необходимый код на сервере. Сразу отметим, что скрипт будет сразу создавать вывод, а не возвращать его (чтобы изменить ситуацию – необходимо будет не только подправить код в представлении, но и слегка поменять контроллер. Как этого избежать? Неплохая задача для самостоятельных размышлений).
Чтобы вывести данные о рейтинге, достаточно вставить следующий код:
VoteController::displayRating(); // здесь chapter = $_SERVER['PHP_SELF']
VoteController::displayRating(10); // здесь chapter = 10 – ID статьи
Вот и всё. Шаблон Facade в действии – одна строка и ничего лишнего. Единственное, что ещё нужно сделать, это написать в скрипте до вывода в браузер, следующие строки
if (isJs()) { VoteController::displayRating(); // или VoteController::displayRating(10); }
self::chapter = $_SERVER['PHP_SELF'];
self::chapter = $_REQUEST['chapter'];
Вот и всё. Осталось лишь собрать всё сделанное выше в одно целое, разбить по файлам и заархивировать.
Посмотреть весь PHP-код статьи.
Скачать все материалы (*.zip, ~11.2Kb, содержит php-файлы, изображения, css и js-файлы, а также пример использования (по умолчанию, используется файловая система). Для использования БД необходимо поменять настройки в файле config.php, а также поставить в нём флаг USE_DB в true).
On-line пример работы рейтинга с помощью файловой системы
On-line пример работы рейтинга с помощью базы данных (работает медленнее, потому что подключается к удалённому серверу).
Для того чтобы проверить, как примеры работают при выключенном JavaSript, откройте ссылку-звёздочку в новом окне.
Возможно, в статье вскоре появится код, написанный без использования ООП для сравнения. А пока, на этом всё.
20 ноября 2007 – 2 января 2008 года
Оценка материала:
Вроде тут неточность: "В классическом понимании шаблона MVC, контроллер является связующим звеном между моделью и представлением, и его вполне можно заменить в любой момент." -)
Вцелом отлично!
По "GoF" это именно так. Если есть источник, который утверждает иначе, можете его привести. Ознакомлюсь.
Возникает противоречие с акронимом: "Model View Controller pattern – шаблон Модель Вид Представление"
Спасибо за статьи. Достаточно доходчиво, и с примерами. Вот, бы ещё для Java Swing...
Неточность небольшая:
Выберем в качестве сервера БД MySQL и создадим в базе данных под названием php5 таблицу, согласно следующему запросу:
CREATE TABLE `vote` (
`id` smallint(5) unsigned NOT NULL auto_increment,
Имена php 5 и vote не совпадают :-)
Спасибо за статью. Надеюсь статьи по этой тематике будете продолжать писать.
Имелось в виду название БД php5, а не таблицы. Перестроил предложение
Спасибо за статью! Интересно почитать ход мысли. Вроде бы я это все знаю, но вот так все точно, четко, собранно. Сразу захотелось рефакторить кучу своего кода!
Простите, в какой программе вы делаете свои диаграммы классов?
в Dia делает, скорее всего
Это Rational Rose ;) сто про)
А как сделать, чтобы на странице выводилось 2 таких рейтинга для разных статей?
Уряяяяя!