На главную страницу
Оглавление статей
При выводе древовидного форума возникает проблема быстродействия, так как обработка деревьев требует рекурсивных алгоритмов, что в случае форума на основе базы данных означает как минимум один SQL запрос на каждое выводимое сообщение. При большом размере треда вследствие большого количества запросов быстродействие страницы падает катастрофически. Рассмотрим способ, позволяющий избежать этого и вывести древовидный форум при помощи всего одного запроса SQL (плюс один дополнительный запрос для нужд разбиения на страницы).
Если рассмотреть задачу подробно, оказывается, что проблему представляет только сортировка писем внутри треда. Применим стандартный прием для таких ситуаций: предварительную сортировку, осуществляемую еще на этапе вставки письма в тред. Для этого в таблице писем создадим дополнительное поле X типа INT для этой цели. При этом проблема быстродействия перемещается в момент вставки письма, но тут мы ее легко решаем при помощи алгоритма со сборкой мусора, где мусором является неравномерность распределения значений поля X по допустимому диапазону (от 0 до MAXINT).
Итак, если Вы еще не заснули от теоретического описания ситуации, перейдем к практической реализации. Запросы SQL я буду давать в диалекте MySQL, а алгоритмическую часть в синтаксисе PHP, потому что на данный момент пара PHP/MySQL является наиболее распространенным и приемлемым вариантом для построения подобных вещей.
Опишем таблицу для хранения сообщений. В данном случае я дам только поля, необходимые для функционирования алгоритма, и абстрактное поле Data, в котором хранятся данные, относящиеся к письму. В реальных условиях это поле заменяется на поля TEXT Body, VARCHAR(255) Subject, VARCHAR(255) FromUser и так далее все, что Вам надо для конкретной реализации.
CREATE TABLE Messages (INT UNSIGNED ID NOT NULL PRIMARY KEY ,INT UNSIGNED ForumID NOT NULL,INDEX(ForumID) ,INT UNSIGNED ThreadID NOT NULL,INDEX(ThreadID) ,INT UNSIGNED ParentID NOT NULL DEFAULT 0,INDEX(ParentID) ,INT UNSIGNED X NOT NULL DEFAULT 0,INDEX(X) ,INT UNSIGNED Level NOT NULL DEFAULT 0,INDEX(Level) ,TEXT Data );
Итак, что нам нужно уметь делать с этим хозяйством: а) вставлять письмо, начинающее новую тему; б) вставлять ответ на уже существующее письмо; в) выводить все это красивыми ступеньками, причем на каждом уровне письма отсортированы по времени в обратном порядке, то есть самый последний ответ сверху, ближе всего к оригиналькому письму (на которое отвечали), остальные уезжают вниз. Рассмотрим каждое из этих действий.
Исходные данные: $ForumID, $data. Мы должны сформировать номер для нового треда и вставить письмо. Делается это двумя запросами:
// Нашли новый ThreadID mysql_query("SELECT @NewThreadID:=MAX(ThreadID)+1 FROM Messages"); // Вставили письмо mysql_query("INSERT INTO Messages SET ForumID=$ForumID,ThreadID=@NewThreadID,Data=$data");
Поля X и Level мы не устанавливаем, и они принимают значение по умолчанию, то есть ParentID=0 (это не ответ), X=0 (первое письмо в треде) и Level=0 (нулевой уровень, нет отступа). Обратите внимание на два момента. Во-первых, я использовал для хранения номера нового треда переменную MySQL, потому что снаружи MySQL это число мне просто не нужно. Этого делать не обязательно, Вы можете передавать это число из одного запроса в другой через переменную языка программирования. Второй момент связан с тем, что номер ThreadID является глобальным для всех форумов в данной базе. Тут есть некоторое искушение добавить в первый запрос WHERE ForumID=$ForumID и тем самым «сэкономить» идентификаторы тредов, но не надо ему поддаваться. Потому что есть такая дополнительная операция, как перенесение треда в другой форум, и нам лучше будет не искать новый ThreadID при этом.
Самая интересная ситуация, потому что именно тут мы осуществляем всю грязную работу. Сначала опишем, каким образом формируются поля, а потом общий алгоритм. Исходные данные: переменная $parent, в которой лежит запись исходного письма (мы ее туда взяли по mysql_fetch_object()), а также переменная $data, где находятся полученные от пользователя данные.
Теперь все вместе
function getRecord($sql) { $rs=mysql_query($sql); $record=mysql_fetch_object(); mysql_free_result($rs); return $record; } function getNextX($parent) { $rs=mysql_query("SELECT X FROM Messages WHERE ThreadID=$parent->ThreadID AND X>$parent->X
ORDER BY X LIMIT 1"); $record=mysql_fetch_object($rs); mysql_free_result($rs); if($record) return $record->X; return MAXINT; } $maxX=getNextX($parent); // Если у нас некуда вставлять запись... if($maxX-$parent->X < 2) { // Сколько писем-то? $cnt=getRecord("SELECT COUNT(*) as Messages FROM Messages WHERE ThreadID=$parent->ThreadID"); $step=(int)(MAXINT / $cnt->Messages); if($step<2) die("Can't insert message!"); // Перенумеруем! $cursor=0; $rs=mysql_query("SELECT * FROM Messages WHERE ThreadID=$parent->ThreadID ORDER BY X"); while($message=mysql_fetch_object($rs)) { mysql_query("UPDATE Messages SET X=$cursor WHERE ID=$message->ID"); if($message->ID == $parent->ID) $maxX=($parent->X=$cursor)+$step; $cursor+=$step; } mysql_free_result($rs); } // Вычисляем новый X $newX=(int)(($parent->X + $maxX)/2); // Все готово, вставляем сообщение mysql_query("INSERT INTO Messages SET ForumID=$parent->ForumID, ThreadID=$parent->ThreadID," ."ParentID=$parent->ID, Level=1+$parent->Level, X=$newX, Data=$data");
Примерно так. Естественно, этот фрагмент кода можно рассматривать только как чисто условный, так как с целью упрощения тут делаются вещи, которые обычно делаться не должны: «обработка» ошибки при помощи die(), использование низкоуровневых функций php для обращения к mysql безо всякого враппера, и так далее.
Итак, теперь у нас есть сортировка внутри тредов, так что мы можем смело вывести весь форум одним запросом. Исходные данные $ForumID.
function printMessage($message) { $offset=offsetStart+$message->Level*offsetStep; echo "<DIV STYLE=\"margin-left: $offset\">"; // Печатать заголовок сообщения... echo ... echo "</DIV>"; } $rs=mysql_query("SELECT * FROM Messages WHERE ForumID=$ForumID ORDER BY ThreadID DESC, X"); while($message=mysql_fetch_object($rs)) printMessage($message); mysql_free_result($rs);
Правда, реальность несколько отличается от данной теории. Проблема в том, что форум надо выводить не весь, а постранично, и хорошо бы бить на страницы по границам тредов. Для этого нам придется сделать дополнительный запрос, чтобы выбрать все треды, нужные для данной страницы. Исходные данные: $ForumID, $PageSize количество тредов на страницу, $CurrentPage текущая страница.
$startRecord=$PageSize*$CurrentPage; $threads=array(); $rs=mysql_query("SELECT ThreadID FROM Messages THERE ForumID=$ForumID GROUP BY ThreadID " "ORDER BY ThreadID DESC LIMIT $startRecord,$PageSize"); while($thread=mysql_fetch_object()) $threads[]=$thread->ThreadID; mysql_free_result($rs); $rs=mysql_query("SELECT * FROM Messages WHERE ThreadID IN (".join(',',$threads).")
ORDER BY ThreadID DESC, X"); while($message=mysql_fetch_object($rs)) printMessage($message); mysql_free_result($rs);
В данном случае сортировка осуществляется по номеру треда, но можно также сортировать по «свежести» сообщений, если хранение этой информации предусмотрено (ORDER BY MAX(CreatedAt_field)), или еще как-нибудь.
Как видите, данный принцип позволяет свести наиболее частую операцию показ дерева форума к минимальному алгоритму всего с парой запросов. Форумы на этом принципе, реализованные на ASP, а затем PHP, эксплуатируются автором несколько лет и показали высокую эффективность данного подхода.
Комментарии на akul@inbox.ru