Создание форума в ASP.NET

Введение

Форумы – они же ньюс-группы и эхо-конференции, – пожалуй, один из наиболее привлекательных сервисов, которые может предложить веб-ресурс своим посетителям. Огромная популярность некоторых сайтов рунета (думаю, читатель и сам догадается, о чем речь) не в последнюю очередь объясняется тем, что на этих сайтах можно найти интересные и профессионально сделанные форумы с высокой посещаемостью, где всегда смогут помочь дельным советом. Ясно, что популярность того или иного форума не в последнюю очередь объясняется качеством его реализации – если форум сделан слишком примитивно, это может отпугнуть посетителей, но, с другой стороны, если переборщить с различными «наворотами», то работа форума может всерьез перегрузить сервер, что тоже не очень хорошо скажется на посещаемости вашего веб-ресурса. Существует множество различных вариантов создания форума. Наиболее простые из них могут целиком избавить вас от больших объемов кодирования – например, если вы захотите довольствоваться только плоским режимом вывода сообщений, без разделения на темы. Конечно же, выбор той или иной реализации – это дело вкуса, но посвящать целую статью созданию плоского списка сообщений явно не стоит. Поэтому здесь мы рассмотрим более интересный вариант.

Настоящая статья описывает основные стадии создания иерархического форума с использованием ASP.NET. Из сообщений будет выстраиваться дерево, по которому удобно прослеживать развитие темы собеседниками:


Рисунок 1. Пример форума

Число сообщений в каждой ветке теоретически бесконечно. Сообщения форума выводятся постранично, причем количество тем на странице можно указывать вручную – выбрав нужное число из комбинированного списка, расположенного в заголовке форума. Для рендеринга такой страницы достаточно одного SQL-запроса, о котором мы поговорим чуть позже. Форум тестировался и прекрасно работает в браузерах MSIE версии 5 и выше, Mozilla 1.4, Opera версии 7 и выше. Форум был написан по принципу «раздельного кода» – все серверные сценарии отделены от aspx-кода. В качестве сервера SQL использовался MS SQL Server.

В настоящей статье описываются основные этапы создания серверных и клиентских сценариев данного форума. В приложении к статье можно найти готовую реализацию форума – проект для Visual Studio .NET 2003.

Создание шаблонов DHTML-кода

Общие принципы

Для начала скажем несколько слов об основной идеологии описываемой в настоящей статье реализации. Хотя мы и будем рассматривать здесь создание ASP.NET-проекта на языке C#, наш подход будет, скажем так, не совсем типичен для разработки веб-ресурсов с использованием ASP.NET. Естественно, когда речь заходит о преимуществах ASP.NET, то первое и, пожалуй, самое броское преимущество, которое хочется упомянуть – это удобные северные элементы управления, позволяющие скрыть от разработчика «сложные» детали DHTML. Благодаря таким control-ам, как DataGrid, можно сильно сократить время разработки – свести решение некоторых задач к «работе мышкой» в дизайнере веб-форм, причем никакого знания DHTML не потребуется. В этом смысле серверные элементы управления представляют собой готовые решения для наиболее часто используемой функциональности, но если даже в ASP.NET вам потребуется нечто большее, то об удобствах визуального дизайнера придется забыть.

То же самое справедливо и для настольных приложений – прежде всего для тех, которые уже строятся на готовом каркасе (framework), инкапсулирующем системные вызовы для упрощения работы по созданию пользовательского интерфейса. Так, чтобы разработать интерфейс с базовой функциональностью, достаточно провести лишь несколько минут в дизайнере форм или даже просто воспользоваться соответствующим мастером. Но если требуется нечто большее, чем стандартные элементы управления Windows, то без больших объемов ручного кодирования не обойтись. Даже в FCL для решения подобных задач, как правило, придется напрямую работать с системными функциями, хотя, бесспорно, одно из основных преимуществ данной библиотеки классов заключается в довольном сильном покрытии Windows API и возможности создавать пользовательские интерфейсы, «абстрагируясь» от API-функций.

В отношении веб-приложений своего рода «аналогом» Windows API может выступать DHTML-код – т.е. это, скажем так, тот самый «низкий уровень», на котором можно создавать веб-приложение. Описываемая здесь реализация форума как раз и является такой «низкоуровневой» реализацией. Значит ли это, что мы не будет использовать никаких преимуществ ASP.NET? Отвечая на этот вопрос, не стоит забывать о том, что, помимо удобных серверных control-ов, в числе преимуществ ASP.NET находится также и возможность писать серверную часть на таких современных объектно-ориентированных языках, как C#, и использовать богатые возможности библиотеки классов FCL. И надо сказать, что без этого преимущества создание самого «сердца» нашего проекта – класса ForumGenerator – стало было невозможным.

Принцип работы генератора форума достаточно прост. Вся работа на сервере будет ограничиваться лишь собственно созданием DHTML-кода форума – хотя это основной и наиболее сложный этап в реализации, – после чего «управление» перейдет к клиентским скриптам, а вся нагрузка с сервера будет снята. Результирующая страница форума складывается из готовых шаблонов HTML-кода, хранящихся в ресурсах сборки. Такой подход используется в прилагаемой к статье реализации форума и у него, разумеется, есть ряд существенных недостатков. Например, для изменения шаблонов DHTML придется производить перекомпиляцию всего проекта. Если же в разрабатываемом проекте придется менять шаблоны HTML достаточно часто, и необходимость перекомпиляции для совершения каждого изменения серьезно затруднит поддержку веб-ресурса, то лучше перенести шаблоны HTML из ресурсов сборки во внешний XML-файл – общие принципы реализации форума от этого ничуть не изменятся.

Так как практически весь код страницы будет формироваться динамически, то основная стартовая страница демонстрационного проекта – default.aspx – будет содержать лишь пустой контейнер (DIV), обрабатываемый на стороне сервера, и несколько скрытых полей (INPUT type=”hidden”), предназначенных для обмена данными между клиентскими и серверными скриптами.

ПРИМЕЧАНИЕ

Если вы хотите, чтобы по краям страницы не выводился бордюр, то, определяя для страницы стиль, не забудьте, что для удаления этого бордюра в MSIE надо указать margin:0px 0px 0px 0px, а в браузерах Mozilla и Opera – padding:0px 0px 0px 0px. Поэтому для корректного отображения страницы во всех рассматриваемых здесь браузерах надо использовать оба этих указания.

Для удобства все клиентские сценарии, которые используются в описываемой здесь реализации, размещены в отдельном файле forum.js. Поэтому на странице также должны содержаться ссылки на этот файл и на файл с таблицей стилей, которые будут определять внешний вид нашего форума – forum.css.

ПРИМЕЧАНИЕ

Для того чтобы курсор мыши при наведении на элемент HTML принимал вид руки, следует указывать в стиле не cursor:hand, так как эта директива не соответствует стандарту и ее умеют интерпретировать только Opera и MSIE, а cursor:pointer – и тогда вы добьетесь желаемого эффекта во всех браузерах. Не обращайте внимания на то, что редактор таблиц стилей VS .NET отмечает директиву cursor:pointer как ошибочную – это проблема VS .NET, а не ваша. Сам браузер MSIE будет вполне корректно интерпретировать данное указание.

Не забудьте также правильно указать кодовую страницу – windows-1251 – как в теле самой aspx-страницы, так и в конфигурационном файле веб-приложения (web.config). Например:

<globalization requestEncoding="windows-1251" 
responseEncoding="windows-1251"/>

Помимо основного файла default.aspx, в приложении будут использоваться файлы post.aspx, содержащий код для отправки сообщения в форум, и postbody.aspx, в который будет выводиться текст выбранного пользователем сообщения.

Основные шаблоны HTML

Начнем с написания DHTML-кода, который будет использоваться при рендеринге форума. Два основных элемента форума – это сообщение без ответов и сообщение с ответами (щелкнув по иконке которого, можно раскрыть все дерево дочерних сообщений). Наиболее удобно будет сделать данные элементы в виде таблиц. Так как все «события» форума (как, например, раскрытие ветки сообщений, создание ответа на сообщение и пр.) после его рендеринга будут обрабатываться только на клиенте, то необходимо добавить к телу каждого элемента специальные скрытые поля, в которых будет храниться неотображаемая на странице, но необходимая для создания новых сообщений информация – уникальный идентификатор сообщений, идентификатор темы и пр. Итак, код сообщения без ответов будет выглядеть так:

<TABLE style="margin-top:0px;margin-bottom:0px;display:block;width:100%">
  <TR style="width:100%">
    <TD style="width:70%;padding-left:Changepx;">
      <IMG src="clipitem.gif" align=absmiddle onmouseover="this.style.cursor='pointer'"  >    
      <SPAN onmouseover="overPost(this);" class="TreeRoot" onmouseout="outPost(this);" 
        onclick = "showPost('Post|', 'Auth|', 'Date|','ID|', 
        this, 'TYPE|', 'TID|', 'Level|');" style="left:0;">Text</SPAN>              
    </TD>
    <TD id='Auth|' style="width:30%;text-align:left;" class="TreeRoot">Author</TD>
    <TD id='Date|' style="width:120px;text-align:left;" class="TreeRoot" nowrap>Date</TD>
  </TR>
</TABLE>

Символ “|” будет заменяться в процессе генерации на индексы. Метод showPost будет выводить текст сообщения в кадр IFrame – его реализация будет рассмотрена ниже. Методы overPost и outPost динамически изменяют класс стиля, когда над сообщением проходит курсор мышки. Для создания отступов между сообщениями разного уровня используется элемент стиля padding. Слово Change, указанное в данном элементе, будет динамически заменяться на нужный отступ в процессе генерации страницы. Можно было бы, конечно, задать элементам относительное позиционирование (position:relative) и установить поля в некоторую постоянную величину – например, margin-left:10px. Однако такой способ в нашем случае неприемлем, так как необходимо, чтобы сдвигалось лишь содержимое поля с темой сообщения, а остальные оставались неподвижными.

HTML-код сообщения, которое содержит ответы, отличается лишь двумя моментами. Прежде всего, в тег IMG добавлен обработчик события onclick, куда помещен указатель на функцию TreeExpand, раскрывающую список дочерних сообщений. Кроме того, каждый шаблон сообщения, содержащего ответы, содержит незакрытый тег DIV (этот тег закрывается программно), выступающий в качестве контейнера для дочерних сообщений:

<DIV id="Child|" style="width:100%; display:none;”>

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

Также на странице форума будет размещаться заголовок сообщений и колонтитул самого форума – их HTML-код не представляет никакого интереса. Навигация по форуму (переход между страницами) будет осуществляться с помощью специальной панельки, для рендеринга которой мы также будем использовать элемент HTML table:

<TABLE class="PagesBody" style="font-size:10pt;font-weight:bold;position:relative;width:100%">
  <TR>
    <TD style="font-weight:normal;font-size:8pt;">
      Страница <strong>pagenumber</strong> из pagecount
    </TD>      
    <TD style="padding-left:0px" align=”right” nowrap>
      <INPUT style=”disptype” id="Back" class="ButtonItem" 
        type="submit" value="&lt;&lt; Предыдущая" onclick="goPrevious();">
      <INPUT style=”displaytype” сlass="ButtonItem" type="submit" 
        value="Следущая &gt;&gt;" onclick="goNext();">
    </TD>
  </TR>
</TABLE>

Слова pagenumber и pagecount будут динамически заменяться на номер текущей страницы и общее количество страниц, соответственно, на этапе рендеринга страницы. Также вы наверняка заметили, что в атрибут style ячеек таблицы вставлены слова disptype и displaytype. Они будут заменяться на указания display:none или display:inline, в зависимости от ряда причин. Например, если форум состоит только из одной страницы, кнопки вообще не будут выводиться, и т.п. Кнопки, с помощью которых, собственно, и осуществляется навигация по форуму, имеют тип submit. После их нажатия данные будут автоматически отсылаться на сервер. Можно также использовать javascript-указание document.[formName].submit(), где formName – это название вашей формы (не забывайте всегда указывать объект document – в противном случае этот код будут правильно интерпретировать не все браузеры). Кодовые символы будут отображаться на странице HTML как треугольные скобки. С помощью функций goPrevious и goNext, собственно, и будет осуществляться навигация между страницами.

Помимо панели для навигации на странице будет также размещаться и панелька с кнопками, позволяющими отправить новое сообщение в форум.

<TABLE class="RateBody"
       style="font-size:10pt;font-weight:bold;MARGIN-RIGHT: 0px; 
              padding:5 0 4 3; POSITION: relative;width:100%">
  <TR>
    <TD style=”width:30%”>
      <INPUT type="button"
             id="Answer"
             value="Ответить"
             class="ButtonItem" 
             onclick="reply();"
             onmouseover="this.className='ButtonItemHover'" 
             onmouseout="this.className='ButtonItem'"
             style="display:none">
    </TD>
    <TD style="width:70%">
      <INPUT type="button"
             value="Новая ветка"
             onclick="newPost();"
             class="ButtonItem"
             onmouseover="this.className='ButtonItemHover'"
             onmouseout="this.className='ButtonItem'">
    </TD>
  </TR>
</TABLE>

Эти кнопки уже не обязательно должны иметь тип submit, так при их нажатии данные на сервер отсылаться не будут, но просто откроется страница для создания нового сообщения. Код для открытия данных страниц будет размещен в javascript-функциях newPost и reply, соответственно.

Использование IFrame

Сам текст сообщения будет выводиться в окно IFrame, размещенное между панелью для навигации по форуму и панелью для создания нового сообщения. Данный подход удобен тем, что он позволяет не размещать в результирующей HTML-странице весь текст выводимых на нее сообщений – как это делается, к примеру, в форумах codeproject.com, где в процессе рендеринга дерева сообщений само содержимое последних также выводится в специальные скрытые поля, которые автоматически раскрываются, когда пользователь щелкает на теме сообщения. Такая методика несколько упрощает общую структуру элементов управления HTML, которые используются для представления форума. Например, отпадает необходимость в специальном окне, в которое будет выводиться текст сообщений. Однако размер результирующих HTML-страниц значительно увеличивается, что может негативно сказаться на производительности форума. По этой причине в настоящей реализации мы и будем использовать окно IFrame.

Фактически в IFrame будет загружаться специальная ASPX-страница. В качестве параметра этой странице будет передаваться уникальный идентификатор сообщения, содержимое которого мы хотим просмотреть, а в серверном коде, размещенном на данной странице, будет осуществляться выборка тела сообщения из базы данных. Этот код мы рассмотрим чуть позднее, когда обратимся к созданию серверных сценариев.

Написание клиентских сценариев

При прохождении мышки по заголовку сообщения, заголовок будет выделяться с помощью специальной рамки засветки. При щелчке по данному сообщению оно будет отмечаться как выбранное, что позволит пользователю легко определить, какое из сообщений он в настоящий момент просматривает. Для осуществления этих эффектов мы используем такие Javascript-функции:

function overPost(item)
{
  if (item.className != "SelectedPost")
    item.className = "TreeHover";
}

function outPost(item)
{
  if (item.className != "SelectedPost")
    item.className = "TreeRoot";
}

Как мы видим, класс стиля будет меняться только в том случае, если он является классом выбранного пользователем сообщения – в таком случае внешний вид заголовка сообщения никак не будет реагировать на прохождение над ним курсора мышки. Чтобы отмечать сообщение как выбранное, будет использоваться такая функция:

var selPost;

function showPost(topic)
{  
  if (selPost != null)
    selPost.className = "TreeRoot";
  
  topic.className = "SelectedPost";
  selPost = topic;
}

Переменная selPost будет хранить ссылку на последнее выбранное пользователем сообщение. Topic – это ссылка на сообщение, по которому пользователь щелкнул мышкой. Благодаря использованию переменной selPost можно гарантировать, что в каждый момент времени только одно сообщение будет помечено как выбранное
ПРИМЕЧАНИЕ

Код данной функции был несколько сокращен – в ней также содержится очевидная логика для отображения данных сообщения в окне IFrame; полный код этой функции можно найти в файле forum.js.

Помимо этого, при щелчке по иконке сообщения (принимающей вид «плюса» или «минуса» в зависимости от ситуации) будет раскрываться ветка данной темы. Для этого будет использоваться такая клиентская функция:

function TreeExpand (item, img) 
{
  if (item.style.display == "block") 
  {
    item.style.visibility = "hidden";
    item.style.display = "none";    
    img.src = "cliptplus.gif";    
  }
  else
  {    
    item.style.display = "block";    
    item.style.visibility = "visible";
    img.src = "cliptminus.gif";
  }    
}

С помощью данной функции будут не только раскрываться темы сообщений, но и производиться динамическая замена иконок, обозначающих, раскрыта данная тема или свернута.

Для навигации между страницами используются функции goNext и goPrevious. В этих функциях производится инкремент номера текущей страницы. Полученное значение записывается в скрытое поле CountBox. В функциях reply и newPost содержится код, формирующий все необходимые для создания сообщения параметры, которые будут передаваться в функцию performPost. Эти параметры состоят из идентификационных данных выбранного пользователем сообщения. В итоге на их основе сформируется запрос для открытия страницы post.aspx, в которую будут в качестве параметров передаваться:

  • уникальный ID выбранного сообщения,
  • ID корневой темы сообщения,
  • уровень вложенности выбранного сообщения,
  • идентификатор темы, частью которой является сообщение,
  • текст темы сообщения, ответом на которое является сообщение.

(Если сообщение открывает новую тему, то вместо некоторых из этих параметров будет передаваться значение “-1”). Функция performPost будет открывать в отдельном окне страницу для создания сообщения (с помощью javascript-функции window.open), передавая в нее эти параметры в виде запроса как часть адресной строки. Вряд ли тут требуются какие-то дополнительные пояснения.

Для динамического изменения количества тем на странице будет использоваться функция refresh, вызов которой происходит при нажатии кнопки типа submit, в результате чего данные отправляются на сервер. Вот код этой функции:

function refresh()
{
  document.getElementById("PerPageBox").value = 
    document.getElementById("PerPage").value;
}

Элемент PerPageBox – это скрытое поле, обрабатываемое на сервере. В этом поле хранится количество тем, отображаемых на странице. PerPage – это элемент HTML типа Select, позволяющий пользователю выбрать количество тем на странице. Данные, передаваемые в скрытое поле PerPageBox, будут интерпретироваться уже серверным сценарием.

Проектирование базы данных

Для начала следует сказать, что предлагаемое здесь решение позволяет размещать на Web-ресурсе сколько угодно форумов. При этом для добавления нового форума понадобится лишь внести соответствующую запись в одну из таблиц БД и поместить ссылку на этот форум в главном меню сайта. Разумеется, подобная функциональность накладывает свой отпечаток на структуру БД и, хотя в демонстрационном проекте приведен пример лишь одного форума, сама реализация построена так, что при желании можно добавить сколько угодно новых форумов.

Тем не менее, в данном случае проектирование базы – далеко не самый сложный этап в разработке. База данных – назовем ее forumdb – будет состоять всего из двух таблиц, связанных по ключевому полю forum_id. Так они должны выглядеть:


Рисунок 2. Схема базы данных.

В таблице forum будет храниться список всех форумов. Форумы идентифицируются по ключевому полю forum_id, которое также выступает в качестве foreign key в основной таблице БД forum_post. С помощью данного поля производится выборка сообщений из конкретного форума. В поле name будет храниться название форума. Куда больший интерес представляет таблица forum_post. Структура данной таблицы специально оптимизирована под хранение данных с иерархической структурой, какими и являются сообщения нашего форума. Ниже я перечислю все поля данной таблицы с краткими пояснениями:

  • forum_post_id – автоинкрементальное поле – идентификатор записи.
  • author – имя автора сообщения.
  • topic – тема сообщения.
  • body – текст сообщения.
  • date – дата создания сообщения, типа DateTime.
  • answer – в данном поле будет храниться ссылка на уникальный идентификатор (id) сообщения, ответом на которое является данное сообщение. Если же сообщение открывает новую тему, то в это поле будет по умолчанию записано значение -1.
  • topic_id – используется для того, чтобы отмечать все сообщения, принадлежащие одной теме.
  • level – отмечает уровень сообщения в иерархии других сообщений. Например, корневое сообщение темы будет иметь уровень 1, ответ на него – уровень 2, и так далее.
  • answers – поле типа Bit (аналог Boolean в MS SQL Server), показывает, есть ли ответы на сообщение. Оно будет использоваться для упрощения алгоритма построения дерева сообщений, так, сообщения, на которые нет ответов, и сообщения, на которые уже кто-то успел ответить, будут рендериться в различный HTML-код.
  • forum_id – как мы уже говорили, идентифицирует сообщение с тем или иным форумом.

Предназначение этих полей станет более понятным, когда мы обратимся непосредственно к рассмотрению алгоритма генерации HTML-кода форума. Как вы уже заметили, в рассматриваемой здесь реализации в качестве SQL-сервера использовался MS SQL Server, однако в структуре базы данных и коде SQL-запросов нет ничего такого, что нельзя с минимумом сложностей перевести на SyBase или Oracle.

Создание SQL-запросов

Прежде всего следует определиться с задачами. Итак, сообщения форума будут выводиться постранично, и на каждой странице будет размещаться не более десяти тем. Так как текст самих сообщений не будет включаться в результирующий код страницы, то, соответственно, он не должен включаться и в выборку данных таблицы forum_posts. К тому же, как мы уже определились, данная таблица может содержать сообщения для произвольного количества форумов, соответственно, выборка сообщений будет совершаться по полю forum_id. Наконец, на основе данных, полученных в результате запроса, будет заполняться dataset, и основной цикл генерации форума будет происходить при разъединенном подключении к базе данных. Поэтому основной запрос должен совершать выборку сразу всех сообщений для текущей страницы – то есть, например, десяти корневых сообщений с первым уровнем вложенности и всех сообщений, которые являются ответами на них. Итак, для этих целей мы можем использовать такой запрос.

SELECT id, author, topic, date, answer, topic_id, level, answers 
  FROM forum
  WHERE topic_id IN
    (SELECT TOP X topic_id FROM forum 
       WHERE forum_id = @forum AND level = 1 AND topic_id NOT IN
        (SELECT TOP Y topic_id FROM forum
          WHERE forum_id = @forum AND level = 1 ORDER BY id DESC)
     ORDER BY id DESC)
ORDER BY level ASC, id DESC

Переменная @forum будет определять, из какого форума производится в настоящий момент выборка сообщений. X будет динамически заменяться на количество тем на странице (вычисление которого на стороне клиента было показано выше), Y – на число, вычисляемое по формуле [номер текущей страницы] * [количество тем на странице]. Таким образом, если текущей является первая страница (т.е. фактически номер страницы равен нулю), то вместо X будет подставлен 0 – и так далее. Сортировка в обратном порядке по ключевому полю необходима для того, чтобы сообщения выстраивались в том порядке, в котором они были записаны в таблицу форума. Сортировка в обратном порядке по полю level необходима для работы генератора форума, который будет рассмотрен ниже.

Он не использует T-SQL, не блокирует таблицу на момент выборки данных, не создает временных таблиц. Необходимость двух вложенных SELECT’ов объясняется в данном случае тем, что нужно возвращать некоторое заданное количество тем с произвольным количеством сообщений – т.е. данный запрос может в принципе вернуть любое количество строк таблицы. Именно в этом и заключается его коренное отличие от классической реализации постраничной выборки без использования специфичного T-SQL.

У этого два запроса есть два немаловажных недостатка. Первый недостаток заключается в том, что последний вложенный SELECT с переходом на каждую новую страницу будет производить выборку все большего числа сообщений – так, при показе первой страницы количество сообщений будет равно нулю, при показе второй страницы – уже десяти, при показе третьей – тридцати и пр. В принципе, в данном случае потеря производительности будет не столь критична, потому что вышеупомянутый SELECT производит выборку только одного поля и только корневых сообщений одного форума. Второй недостаток – это отсутствие кэширования планов запроса при использовании выборки с помощью ключевого слова TOP. Дело в том, что хотя в вышеозначенном примере номер текущей страницы и количество тем на странице мы обозначили как X и Y, в действительности эти величины нельзя параметризировать (как это делается, например, для идентификатора форума), и запрос придется динамически генерировать в коде. Достоинство этого запроса заключается в том, что он будет работать на большинстве SQL-серверов.

При желании можно, конечно, оптимизировать данный запрос, однако тема постраничного вывода данных из таблицы – а особенно применительно к различным SQL-серверам – заслуживает отдельной большой статьи, поэтому здесь мы не будем касаться ее подробно. Приведем лишь пример того, как можно переписать этот запрос, используя специфичный T-SQL и временную таблицу:

DECLARE @id int, @min_id int, @start_record int

SELECT @start_record = (@page - 1) * @page_size + 1

SET ROWCOUNT @start_record
SELECT @id = topic_id 
FROM forum_post
ORDER BY topic_id DESC

SET ROWCOUNT @page_size    
SELECT DISTINCT topic_id INTO temp_table FROM forum_post    
WHERE topic_id <= @id
ORDER BY topic_id DESC

SELECT @min_id = MIN(topic_id)
FROM temp_table  

DROP TABLE temp_table

SET ROWCOUNT 0
SELECT * FROM forum_post WHERE
topic_id >= @min_id AND topic_id <= @id
ORDER BY level ASC, forum_post_id DESC

Помимо этого, в основном классе генератора форума используется еще два простых запроса. Один из них будет возвращать общее количество тем в указанном форуме. Другой будет совершать выборку из таблицы forum и возвращать название текущего форума по его идентификатору. Эти два последних запроса будут выполняться в одном пакете с первым, но только один раз, при первом открытии форума. Впоследствии данные, полученные с помощью этих запросов, запишутся в скрытые поля на странице, и, когда пользователь начнет путешествовать по страницам нашего форума, эти запросы повторно исполняться уже не будут. В остальных классах нашего проекта мы будем использовать еще несколько запросов. Однако они более чем просты, поэтому не требуют подробного рассмотрения.

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

Класс ForumGenerator

Общие сведения о классе

Реализация класса ForumGenerator – основной этап разработки форума. Все параметры, необходимые для работы этого класса, будут передаваться в его конструктор при создании экземпляра. Данный класс будет содержать всего один открытый метод – string Generate() – не принимающий никаких параметров и возвращающий полный DHTML-код форума в виде строки. В теле класса объявляются следующие поля:

string slider, sliderDiv, current, forumName;
int forum, pageSize;
ResourceManager res;

В полях slider и sliderDiv будут храниться блоки DHTML-кода, полученные из ресурсов сборки через менеджер ресурсов (res). Предназначение остальных полей станет ясно из описания конструктора класса, который выглядит так:

public ForumGenerator(string forumName, string current, int forum, int pageSize)
{
  res = new ResourceManager("RSDNMag.Forum.Resources.Forum", 
this.GetType().Assembly);
  this.pageSize = pageSize;
  this.forum = forum;
  this.forumName = forumName;
  
  if (current != String.Empty)
    this.current = current;
  else
    this.current = "0";
}

В качестве параметра forumName будет передаваться содержимое скрытого поля StateBox, которое при первом создании экземпляра класса будет пустым, а соответственно, в качестве данного параметра будет передаваться пустая строка (String.Empty). Аналогично и с параметрами pageSize и current – они представляют собой содержимое полей PerPageBox и CountBox, которые будут заполняться через клиентский скрипт, что уже рассматривалось нами ранее. В качестве параметра forum будет передаваться идентификационный номер форума по таблице forum.

Помимо публичного метода Generate класс будет также содержать несколько закрытых вспомогательных методов и два класса, которые понадобятся при построении дерева сообщений форума.

Объектная модель сообщений

Необходимость построения дерева сообщений в данном случае может показаться не столь очевидной, однако в действительности она может сильно помочь нам в процессе генерации форума. Все дело в том, что с одной стороны, мы должны уже формировать DHTML-код всех сообщений, чтобы генерация не разбивалась на два этапа – создание дерева сообщений и собственно генерацию DHTML на основе этого дерева. Но с другой стороны, у нас по-прежнему должна остаться возможность доступа к некоторым данным, ассоциированным с каждым сообщением, а так как после итогового формирования DHTML-кода для сообщения это последнее будет представлять собой обычную строку, то выполнение такого рода задачи может стать несколько затруднительным. Поэтому для того чтобы упростить себе жизнь, мы создадим специальный класс, представляющий сообщение, на которое нет ответов. Данный класс будет включен в тело основного класса ForumGenerator, так как не будет использоваться нигде за пределами генератора форума:

class Post
{
  string source;
  public Post(string source, int id)
  {
    this.source = source;
    _iD = id;
  }

  private int _iD;
  public int ID
  {
    get { return _iD; }
  }

  public override string ToString()
  {
    return source;
  }
}

Как вы видите, в классе перегружен метод string ToString() – так, чтобы с его помощью можно было получить DHTML-содержимое данного сообщения. Удобство такого подхода станет более ясным далее, когда мы рассмотрим реализацию второго класса, используемого при построении дерева сообщений. Вышеприведенная реализация класса Post, как несложно догадаться, может содержать лишь данные для одиночного сообщения, на которое нет ответов. Если же сообщение является началом целой темы или отдельного обсуждения внутри темы, то оно должно содержать также и ссылки на дочерние сообщения. Для такого рода сообщений нам придется создать отдельный класс – наследник Post:

class TopicPost : Post
{
  public TopicPost(string source, int id) : 
    base(source, id) 
  {
    _childNodes = new ArrayList();
  }

  private ArrayList _childNodes;
  public ArrayList ChildNodes
  {
    get { return _childNodes; }
  }

  public override string ToString()
  {
    StringBuilder result = 
      new StringBuilder();
    
    foreach (object o in _childNodes)
      result.Append(((Post)o).ToString());
    
    // Открывающий тег DIV находится в шаблоне
    return base.ToString() + result + "</DIV>";
  }
}

Главное новшество в классе TopicPost по сравнению с родительским классом – это свойство ChildNodes, инкапсулирующее поле, в котором будут храниться ссылки на все дочерние сообщения. Дочерние сообщения могут быть как одиночными, так и содержащими ответы, а следовательно, будут представлены в нашем дереве различными объектами. Использование в данном случае типизированной коллекции нецелесообразно, поэтому данное свойство было объявлено как ArrayList. Еще одно важное новшество – это более сложная по сравнению с предыдущим классом реализация метода ToString. Дело в том, что если аналогичный метод класса одиночного сообщения должен возвращать DHTML-код только одного сообщения, то в данном случае нам нужно получить DHTML-код всех дочерних сообщений. К тому же, как уже говорилось при описании шаблона, используемого при рендеринге сообщения с ответами, все дочерние сообщения должны быть обрамлены тегом DIV. Проще всего сделать это, используя предлагаемую здесь модель. Теперь если вы, к примеру, вызовете метод ToString у корневого сообщения длинной и разветвленной темы, то получите правильно скомпонованный DHTML-код всех сообщений данной темы (думаю, вряд ли у кого возникнут сомнения, что это действительно так). Вызов метода ToString мы будем производить только один раз, на самом последнем этапе генерации форума.

Однако это еще не все. Текущая модель позволяет создать иерархический дерево, наиболее, казалось бы, удобное для наших целей, однако обладающее одним серьёзным недостатком – чтобы перебрать все элементы дерева, придется совершать перебор элементов всех коллекций в многократно вложенных циклах, что не только понизит производительность генератора, но и сделает длину ветки ограниченной. Однако учитывая, что в данном случае мы не будем использовать некую изначально заданную иерархическую структуру, но самостоятельно формировать собственную, эта проблема является вполне решаемой. Все, что нужно сделать, – это написать метод, представляющий все вложенные классы коллекции в виде линейного списка, элементы которого можно перебрать в одном цикле. Реализация данного метода включена в класс TopicPost и выглядит так:

public ArrayList GetEnumeration()
{
  ArrayList enumeration = new ArrayList();
  enumeration.AddRange(_childNodes);

  foreach (object o in _childNodes)
  {
    if (o.GetType() == typeof(TopicPost))
      enumeration.AddRange(((TopicPost)o).GetEnumeration());
  }

  return enumeration;
}

GetEnumeration является рекурсивной функцией, возвращающей список всех дочерних веток. Таким образом, мы получаем простой механизм для перебора всех членов коллекции.

Основной алгоритм генерации форума

Теперь мы уже можем перейти к написанию главного метода класса ForumGenerator – метода Generate. В нем будут использоваться несколько вспомогательных методов:

  • метод initialize, совершающий выборку шаблонов DHTML из файла ресурсов сборки в поля slider и sliderDiv;
  • метод buildQuery, генерирующий SQL-запросы, примеры которых приводились ранее;
  • методы rootBuild и nodeBuild, которые формируют DHTML-код корневых и одиночных сообщений на основе шаблонов, которые содержатся в полях slider и sliderDiv;
  • и, наконец, метод buildResult, формирующий полный код форума – включая заголовки, элементы управления, окно IFrame и пр.

Реализация этих методов вполне очевидна, и многие ее аспекты уже затрагивалась выше. Фактически в них содержится вспомогательная логика генератора форума, не представляющая особого интереса. Здесь же мы рассмотрим главный алгоритм генератора. Ниже я приведу весь код метода Generate – так, как он реализован в прилагающемся к статье демонстрационном проекте:

public string Generate()
{
  initialize();
  
  SqlConnection connection = new
    SqlConnection(ConfigurationSettings.AppSettings["ConnectionString"]);

  string query = buildQuery();

  string source = String.Empty;
  ArrayList topics = new ArrayList(10);
  Hashtable topicNumbers = new Hashtable(10);      

  SqlDataAdapter adapter = new SqlDataAdapter(query, connection);
  adapter.SelectCommand.Parameters.Add("@forum", forum);
  DataSet dataSet = new DataSet("sender");
  adapter.Fill(dataSet);
  DataTable table = dataSet.Tables[0];
  int i = 0;

  foreach (DataRow r in table.Rows)
  {
    if ((Int32)r["level"] == 1)
    {
      if ((Boolean)r["answers"])
        source = rootBuild(i, r);
      else
        source = nodeBuild(i, r);

      Post rootData;

      if ((Boolean)r["answers"])
        rootData = new TopicPost(source, (Int32)r["forum_post_id"]);
      else
        rootData = new Post(source, (Int32)r["forum_post_id"]);

      topics.Add(rootData);
      topicNumbers.Add(r["topic_id"], i);
    }
    else
    {
      Post msgData;
    
      if (!(Boolean)r["answers"])
        msgData = new Post
          (nodeBuild(i, r), (Int32)r["forum_post_id"]);
      else
        msgData = new TopicPost
          (rootBuild(i, r), (Int32)r["forum_post_id"]);

      int value = (Int32)topicNumbers[r["topic_id"]];  
      
      TopicPost topicPost = (TopicPost)topics[value];
      
      if ((Int32)r["level"] == 2)
        topicPost.ChildNodes.Add(msgData);

      ArrayList enumeration = topicPost.GetEnumeration();

      foreach (object o in enumeration)
      {
        if ((Int32)r["answer"] == ((Post)o).ID)
        {
          ((TopicPost)o).ChildNodes.Add(msgData);          
          break;
        }
      }        
    }
    i++;
  }  

  return buildResult(topics, dataSet);
}

Здесь не обойтись без нескольких комментариев. На основе приведенных ранее запросов заполняется датасет. Если форум открывается в первый раз, то датасет состоит из трех таблиц: первая – это, собственно, сами сообщения форума, а вторая и третья содержат общее количество корневых сообщений форума и название форума, взятое из таблицы forum. Если форум просто пролистывается пользователем, то датасет состоит всего из одной таблицы с сообщениями. Основная часть кода метода занимается построением дерева сообщений на основе данных, которые содержатся в первой таблице датасета. При построении дерева главную роль играют два поля – topics типа ArrayList и topicNumbers типа Hashtable. Поле topics является коллекцией с длиной, равной количеству тем, отображаемых на странице одновременно. В данной коллекции будут храниться объекты типов Post и TopicPost. Смысл коллекции topics заключается в том, что, благодаря ей, сохраняется порядок следования сообщений.

Поскольку (и об этом уже говорилось) список сообщений в таблице датасета отсортирован по возрастанию поля level, то в числе самых первых сообщений окажутся корневые сообщения, т.е. те, ответами на которые являются все остальные сообщения данной страницы форума, а все остальные будут расположены в порядке убывания их индекса, чем мы и воспользуемся при генерации. Поэтому на первоначальном этапе построения дерева вся коллекция topics будут целиком (или почти целиком, если, к примеру, во всем форуме менее десяти тем) заполнена корневыми сообщениями тем. Если на корневое сообщение нет ответов, и для его представления в коллекции использовался экземпляр типа Post, то весь его рендеринг будет завершен еще на этом первом этапе и, можно сказать, что в дальнейшей генерации оно участвовать не будет. Если же на сообщение имеются ответы (что определяется содержимым поля answers типа Bit), то для его представления будет использоваться экземпляр типа TopicPost, который, как мы помним, сам по себе может содержать коллекцию дочерних элементов. И именно в данную коллекцию будут добавляться сообщения второго и более глубоких уровней вложенности.

Тут, конечно же, не все так просто. Когда начинается второй этап генерации (его код содержится в блоке else), то прежде всего нужно определить, частью какой из тем является рассматриваемое в настоящий момент сообщение (или, говоря другими словами, каков индекс данной темы в коллекции topics). Использовать для этих целей поле answer, содержащее ссылку на forum_post_id того сообщения, ответом на которое является рассматриваемое, нельзя, так как если первый этап генерации совершается только для сообщений первого уровня, то второй этап – для сообщений всех остальных уровней, и нам пришлось бы использовать многократно вложенные циклы для этого. Тут нам приходит на помощь заранее определенное поле topic_id, которое есть у каждого сообщения, и которое позволяет ассоциировать его с той или иной темой. Чтобы оптимизировать поиск нужной темы в коллекции topics, используется хэш-таблица topicNumbers. Еще на первом этапе генерации в эту хэш-таблицу заносятся индексы всех тем, добавляемых в коллекцию topics, причем в качестве ключа выступает, конечно же, topic_id этой темы. Поэтому все, что нам нужно на втором этапе – это просто прочитать нужное значение из хэш-таблицы. Это делается с помощью строчки кода: int value = (Int32)topicNumbers[r[«topic_id»]]. В результате в поле value будет находиться индекс темы в коллекции topics – т.е. то, что нам и нужно было получить.

Но, опять-таки, трудности на этом не кончаются. Теперь мы точно знаем, к какой из тем принадлежит рассматриваемое сообщение, и что искать его нужно, скажем, в той коллекции, ссылка на которую содержится, например, в topics[0]. Область поиска, конечно, сужается, однако все равно остается не такой узкой, как хотелось бы. К сожалению, знание уровня вложенности рассматриваемого сообщения нам уже никак не поможет. Ведь на одном и том же уровне может быть множество разных сообщений, каждое из которых может начинать отдельную подтему. Но тут на помощь придет метод GetEnumeration, реализация которого рассматривалась выше. Благодаря ему можно представить все иерархические коллекции сообщений данной темы в виде одной плоской коллекции, итерацию по которой совершать уже совсем несложно. Так заканчивается последний этап генерации дерева сообщений. Теперь остается лишь вызвать метод ToString у всех корневых тематических сообщений – и мы получим полностью сформированный DHTML-код страницы форума.

Просмотр и отправка сообщения

Страница для просмотра сообщений

В принципе, весь код этой страницы чрезвычайно прост. При ее разработке даже не потребуется визуальный редактор Web-форм. Нужно всего лишь создать контейнер (DIV), обрабатываемый на сервере, или любой другой элемент, который вы сочтете наиболее удобным, и написать метод, который будет вызываться при загрузке страницы:

private void Page_Load(object sender, System.EventArgs e)
{
  if (Request.QueryString["id"] != null)
  {
    string query = @"SELECT body FROM forum_post WHERE forum_post_id = @id";
    
    SqlConnection connection = new SqlConnection
      (ConfigurationSettings.AppSettings["ConnectionString"]);
  
    using (SqlCommand command = new SqlCommand(query, connection))
    {
      command.Parameters.Add("@id", Int32.Parse(Request.QueryString["id"]));
      connection.Open();
    
      SqlDataReader reader = command.ExecuteReader();
  
      reader.Read();
      ForumText.InnerHtml = reader.GetString(0);
      reader.Close();
    }

    connection.Close();
  }        
}

ForumText – это элемент HTML, в который будет выводиться текст сообщения. Весь код, содержащийся в данном методе, будет выполняться только тогда, когда в страницу через строку запроса будет передан параметр forum_id. В противном случае страница будет оставаться пустой (как это происходит когда, например, просто загружается само дерево сообщений форума). Все остальное, в принципе, и так очевидно – с помощью простейшего запроса будет извлекаться содержимое поля body из таблицы forum_post – из той строки, которая соответствует заданному forum_id.

Страница для отправки сообщений

Эта страница будет состоять из нескольких элементов управления, которые удобнее всего создать в визуальном дизайнере. Внешний вид этих элементов управления можно определить непосредственно в редакторе свойств, но лучше все-таки создать соответствующие CSS-классы.

ПРИМЕЧАНИЕ

М. МакДональд в своем фундаментальном труде «ASP.NET: complete reference» утверждал, что возможность работать с серверными элементами управления как с обычными control-ами, скажем, в Windows Forms является серьезным прорывом в Web-разработке даже по сравнению с CSS. Например, теперь можно указать размеры любого элемента управления в редакторе свойств, что позволит сэкономить время, которое уходит на создание таблицы стилей или инлайновое указание стилевых элементов. Чтобы убедиться, насколько серьезным прорывом является это достоинство ASP.NET, советую определить размеры какого-нибудь элемента управления именно таким образом и посмотреть получившуюся страницу через браузеры Mozilla, Opera или Netscape. По неким странным причинам все данные о размерах элементах управления вообще не будут включаться в результирующий DHTML-код.

Итак, на форме должны быть размещены три текстовых поля – для ввода имени, темы сообщения и текста сообщения. Кроме этого, следует добавить кнопки OK и Cancel. Также будет производиться проверка содержимого всех трех текстовых полей, расположенных на форме, с помощью компонентов типа RequiredFieldValidator. Для вывода суммарных сообщений валидаторов будет использоваться компонент ValidationSummary. В итоге форма будет выглядеть примерно так:


Рисунок 3. Форма для отправки сообщений.

При загрузке формы будет вызываться метод Page_OnLoad. Код данного метода будет считывать запросы из адресной строки, с помощью которой была вызвана данная форма, а также высчитывать общее количестве тем в указанном форуме (в том случае, если создаваемое сообщение не является ответом на какое-либо другое сообщение и, соответственно, открывает новую тему). Также в том случае, когда сообщение является ответом, будет динамически формироваться его заголовок на основе заголовка сообщения, на которое производится ответ, с добавленным к нему префиксом «Re:». Так будет выглядеть код данного метода:

private void Page_Load(object sender, System.EventArgs e)
{
  if (Request.QueryString["topic"] != String.Empty && !this.IsPostBack)
    Topic.Text = "Re: " + Request.QueryString["topic"];
    
  level = Int32.Parse(Request.QueryString["level"]);
    
  if (Request.QueryString["topicID"] == "-1") 
  {
    SqlConnection connection = new SqlConnection
      (ConfigurationSettings.AppSettings["ConnectionString"]);
    string query = "SELECT count(*) FROM forum_post WHERE answer = -1";
          
    using (SqlCommand command = new SqlCommand(query, connection))
    {
      command.Connection.Open();        
      topicID = (int)command.ExecuteScalar();
    }

    connection.Close();        
  }
  else
    topicID = Int32.Parse(Request.QueryString["topicID"]);
}

При нажатии на кнопку OK будет вызываться метод OK_Click и, соответственно, данная кнопка должна представлять собой серверный элемент управления. При нажатии на кнопку Отмена будет просто закрываться окно, т.е. выполняться javascript-код window.close() и, соответственно, данная кнопка должна представлять собой обычный элемент HTML и обрабатываться только на клиенте. Вот код, который будет исполняться при нажатии на кнопку OK:

private void OK_Click(object sender, System.EventArgs e)
{
  string query = @"
    UPDATE forum_post SET answers = 1
    WHERE forum_post_id = @answer;
    INSERT INTO forum_post
    (author, topic, body, date, answer, forum_id, topic_id, level)
    VALUES
    (@author, @topic, @body, @date, @answer, @forum, @topicID, @level)";

  SqlConnection connection = new SqlConnection(ConfigurationSettings.AppSettings["ConnectionString"]);

  using (SqlCommand command = new SqlCommand(query, connection))
  {
    command.Connection.Open();
    command.Parameters.Add("@author", parse(AuthorName.Text));
    command.Parameters.Add("@topic", parse(Topic.Text));
    command.Parameters.Add("@body", parse(Msg.Text));
    command.Parameters.Add("@date", DateTime.Now);
    command.Parameters.Add("@level", level + 1);
    command.Parameters.Add("@answer", Request.QueryString["answer"]);
    command.Parameters.Add("@forum", Request.QueryString["ID"]);
    command.Parameters.Add("@topicID", topicID);
    command.ExecuteNonQuery();
  }
  
  connection.Close();      
  Response.Redirect("includesend.htm");
}

Как видно, в том случае, если создаваемое сообщение является ответом, код будет вносить изменения в то сообщение, на которое отвечает пользователь, чтобы при последующем считывании данного сообщения из датасета можно было сразу определить, что на него есть ответы. В методе parse, возвращающем значение типа string, происходит проверка текста сообщения. В частности, символы «<» и «>» заменяются кодами этих символов («