Введение
На Мании не один раз поднималась тема создания одностраничных порталов, при
этом обычно обсуждался IBuySpy Portal. Причина написания этой статьи
одновременно является и причиной самой возможности её написания, и она очень
проста: у меня до сих пор не хватает времени более или менее основательно
разобраться со структурой и принципами функционирования данного Shared Source
проекта, и, соответственно, его использовать, поэтому пришлось реализовывать
собственный engine. Предполагая, что не один я нахожусь в ситуации острой
нехватки времени (и/или слабого знания английского, которое у меня
наличествовало на момент разработки собственного одностраничного портала), я
решил, что мой опыт решения названной выше задачи, оформленный в виде статьи,
может быть полезен некоторым начинающим ASP.NET программерам. Так что начнём,
пожалуй :).
1. Что было в начале
То, что и бывает в начале – концепт, созданный дизайнером :). Приведён ниже.

И, созданный совместно с дизайнером макет html страницы:

Представленный макет немного отличается от оригинального – названия
additionalLeftPanel, mainLeftPanel и mainRightPanel вставлены мною в
одноимённые ячейки таблиц для наглядности местонахождения этих ячеек. Эти
панели, как вы наверное догадываетесь, являются контейнерами для динамического
контента проектируемого сайта. Пожалуй, в данном разделе писать больше нечего,
кроме небольшого примечания: статья написана по мотивам сайта, на момент
написания статьи доступного по адресу http://www.new.as.ru,
а к моменту публикации статьи, возможно, данный сайт уже будет доступен по
адресу http://www.as.ru
2. Чего хочется
Итак, попытаемся формализовать задачу. Из названия статьи :(=) очевидно, что
хочется нам создать портал с одностраничной структурой, соответственно:
-
Динамический контент портала должен формироваться из некоторого конечного
количества пользовательских элементов управления - User Controls (далее
называемых модулями). Почему атомарной единицей портала удобнее
всего выбирать пользовательский элемент управления, сказано много добрых и
правильных слов :) в статье Dimona aka Manowar
"Введение в пользовательские элементы управления"
, так что на этом вопросе мы останавливаться не будем. Понятно, что функционал
портала должен позволять достаточно свободно манипулировать месторасположением
этих пользовательских элементов управления, ведь в зависимости от
предназначения различных страниц один и тот же модуль может быть отображён в
разных местах этих страниц, в разной последовательности относительно других
модулей, либо не отображён вовсе, отсюда:
-
Должна иметься возможность расположения модуля в любом из доступных контейнеров
страницы.
В нашем случае их три – additionalLeftPanel – контейнер для небольших по
размеру модулей, mainLeftPanel – основной контейнер, используемый для
отображения целевого контента страницы, и mainRightPanel, контейнер,
используемый в случае, когда целевой контент сайта необходимо представить с
разбиением на две колонки.
-
Должна иметься возможность отображения модулей в предопределённом порядке.
Думаю, в особых комментариях данный этот пункт не нуждается - вряд ли вы (да и
пользователь) хотите, что бы, например, модуль авторизации/регистрации
пользователя каждый раз выводился в произвольном месте страницы, например,
где–нибудь в нижней её части. Вот, наверное, и всё, чего мы можем хотеть на
данном этапе выполнения проекта. Начинаем писать.
3. Реализация
3.1 Разработка структуры БД
Точнее, сегмента БД, который будет хранить информацию о структуре нашего
портала. Так как наш портал будет строиться из кирпичиков–модулей, первая
сущность, которую нам необходимо описать, это модули. Описываем:
CREATE TABLE [Modules]
(
[Id] [int] NOT NULL IDENTITY (1, 1),
[Name] [varchar] (32) NOT NULL CONSTRAINT [DF_Modules_Name] DEFAULT ('New module'),
[Path] [varchar] (1024) NOT NULL ,
[Description] [varchar] (2048) NOT NULL ,
CONSTRAINT [PK_Modules] PRIMARY KEY CLUSTERED ( [Id] ) ON [PRIMARY],
CONSTRAINT [IX_Modules] UNIQUE NONCLUSTERED ( [Name] ) ON [PRIMARY]
) ON [PRIMARY]
GO
| Id |
– идентификатор, он же первичный ключ, |
| Name |
– название модуля, |
| Path |
– относительный путь к файлу модуля от виртуального каталога портала, |
| Description |
– описание модуля, некоторый набор комментариев. |
Помимо самих модулей, нам нужно место, куда их поместить, соответственно
следующая подлежащая описанию сущность – контейнеры:
CREATE TABLE [Containers]
(
[Id] [int] NOT NULL IDENTITY (1, 1),
[Name] [varchar] (32) NOT NULL CONSTRAINT [DF_Containers_Name] DEFAULT ('NewContainer'),
[Description] [varchar] (2048) NOT NULL CONSTRAINT [DF_Containers_Description] DEFAULT ('Описание отсутсвует'),
CONSTRAINT [PK_Containers] PRIMARY KEY CLUSTERED ( [Id] ) ON [PRIMARY],
CONSTRAINT [IX_Containers] UNIQUE NONCLUSTERED ( [Name] ) ON [PRIMARY]
) ON [PRIMARY]
GO
| Id |
– идентификатор, он же первичный ключ, |
| Name |
– название контейнера, |
| Description |
– описание контейнера, некоторый набор комментариев. |
В нашем случае имеет место одна страница и три контейнера, соответственно
содержимое данной таблицы выглядит так:

И контейнеры, и модули должны отображаться на странице, следовательно, нам нужна
сущность, описывающая страницы портала. Может быть, правильнее было бы назвать
её представлениями страницы (так как страница то у нас будет одна), но у меня
она называется Pages.
CREATE TABLE [Pages]
(
[Id] [int] NOT NULL IDENTITY (1, 1),
[Name] [varchar] (32) NOT NULL,
[Header] [varchar] (256) NOT NULL CONSTRAINT [DF_Pages_Header]
DEFAULT ('Новая страница сайта'),
[ImagePath] [varchar] (1024) NOT NULL,
[Description] [varchar] (2048) NOT NULL CONSTRAINT [DF_Pages_Description]
DEFAULT ('Описание отсутствует'),
CONSTRAINT [PK_Pages] PRIMARY KEY CLUSTERED ( [Id] ) ON [PRIMARY],
CONSTRAINT [IX_Pages] UNIQUE NONCLUSTERED ( [Name] ) ON [PRIMARY]
) ON [PRIMARY]
GO
| Id |
– идентификатор, он же первичный ключ, |
| Name |
– название страницы, |
| Header |
– заголовок страницы |
| ImagePath |
– относительный путь к картинке, соответствующей данной странице, от
виртуального каталога портала. |
| Description |
– описание страницы, некоторый набор комментариев. |
Итак, у нас есть таблицы, описывающие страницы, модули и контейнеры. Теперь нам
необходимо некое правило, по которому одни помещаются в другие, и задающее
порядок их размещения (конкретно мы это сформулировали в разделе 2). Для
реализации этого создаём таблицу PagesContents:
CREATE TABLE [PagesContents]
(
[PageId] [int] NOT NULL,
[ModuleId] [int] NOT NULL,
[ContainerId] [int] NOT NULL CONSTRAINT [DF_PagesContents_ModuleLayout] DEFAULT (2),
[ModuleOrder] [int] NOT NULL, CONSTRAINT [PK_PagesContents]
PRIMARY KEY CLUSTERED ( [PageId], [ModuleId] ) ON [PRIMARY],
CONSTRAINT [FK_PagesContents_Containers] FOREIGN KEY ( [ContainerId] ) REFERENCES [Containers] ( [Id] ),
CONSTRAINT [FK_PagesContents_Modules] FOREIGN KEY ( [ModuleId] ) REFERENCES [Modules] ( [Id] ),
CONSTRAINT [FK_PagesContents_Pages] FOREIGN KEY ( [PageId] ) REFERENCES [Pages] ( [Id] )
) ON [PRIMARY]
GO
| PageId |
– внешний ключ на идентификатор страницы, |
| ModuleId |
– внешний ключ на идентификатор модуля, |
| ContainerId |
– внешний ключ на идентификатор контейнера, |
| ModuleOrder |
– порядок отображения модуля. |
Несколько слов о первичном ключе этой таблицы: работая над структурой сегмента
БД, отвечающего за структуру проектируемого портала, я исходил из того, что
один и тот же модуль не может отображаться на одной и той же странице больше
одного раза, поэтому ключ у меня состоит из двух полей, но в принципе возможна
ситуация, когда один и тот же модуль может отображаться больше одного раза даже
в одном контейнере, поэтому формирование Primary Key для данной страницы – по
ситуации и желанию. Посмотрим на диаграмму, демонстрирующую созданные нами
таблицы:

Как оказалось, всё достаточно просто : некоторое количество строк (одна строка
описывает один модуль) таблицы PagesContetnts может полностью описать страницу
портала. Если написать SELECT запрос с условием отбора по заданному PageId, то
как раз получим описание страницы с этим идентификатором: модули, принадлежащие
странице, принадлежность модулей конкретным контейнером, последовательность
расположения модулей в конкретном контейнере. Но мы не будем писать SELECT,
лучше напишем хранимую процедуру:
CREATE PROCEDURE dbo.GetPageSettings
(
@PageId int = NULL,
@PageName varchar(32) = NULL,
@Header varchar(256) OUTPUT,
@ImagePath varchar(1024) OUTPUT )
AS
BEGIN
-- Получаем информацию о заголовке, иконке и внешнем виде виде страницы
SELECT
@PageId = Id,
@Header = Header,
@ImagePath = ImagePath
FROM
Pages
WHERE
Id = @PageId OR
Name = @PageName
-- Получаем информацию о наборе модулей на странице
SELECT
M.Name AS ModuleName,
M.Path AS ModulePath,
C.Name AS ModuleContainer,
PC.ModuleOrder AS ModuleOrder
FROM
PagesContents PC JOIN Modules M
ON PC.PageId = @PageId AND PC.ModuleId = M.Id
JOIN Containers C ON PC.ContainerId = C.Id
ORDER BY
C.Name,
PC.ModuleOrder
RETURN
END
GO
Здесь тоже всё просто – входным параметром процедуры может быть имя либо
идентификатор страницы, а выходными параметрами является заголовок страницы,
соответствующее ей изображение, ей и набор строк, содержащих информацию о
модулях, отображаемых на странице. Мы создали таблицы, необходимые для хранения
информации о структуре одностраничного портала, и написали хранимую процедуру,
возвращающую полное описание странице, заданной входным параметром этой
процедуры. На том с Sql-серверной частью и закончим. Переходим к кодингу.
3.2 Кодинг портала
Я приведу полный код страницы портала, а затем дам некоторые пояснения.
using System;
using System.Configuration;
using System.ComponentModel;
using System.Data;
using System.Data.SqlClient;
using System.Drawing;
using System.Web;
using System.Web.SessionState;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.HtmlControls;
using Aequitas.Data;
namespace Aequitas
{
/// <summary>
/// Summary description for WebForm1.
/// </summary>
public class Default : System.Web.UI.Page
{
#region Protected fields
protected System.Web.UI.HtmlControls.HtmlTableCell additionalLeftPanel;
protected System.Web.UI.HtmlControls.HtmlTableCell mainLeftPanel;
protected System.Web.UI.HtmlControls.HtmlTableCell mainRightPanel;
protected System.Web.UI.HtmlControls.HtmlGenericControl title;
protected System.Web.UI.HtmlControls.HtmlImage toolTip;
protected System.Data.SqlClient.SqlCommand sqlGetPageSettingsCommand;
#endregion Protected fields
#region Private properties
/// <summary>
/// Свойство определяет, является ли запрос этой страницы первым
/// </summary>
private bool IsFirstVisit
{
get
{
return (this.Request.Cookies["NotFirstVisit"] == null);
}
}
#endregion Private properties
#region Constructors
public Default()
{
Page.Init += new System.EventHandler(Page_Init);
}
#endregion Constructors
#region Private methods
private void Page_Init(object sender, EventArgs e)
{
//
// CODEGEN: This call is required by the ASP.NET Web Form Designer.
//
this.InitializeComponent();
this.CustomInitializeComponent();
}
private void Page_Load(object sender, System.EventArgs e)
{
// Put user code to initialize the page here
}
#region Web Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.sqlGetPageSettingsCommand = new System.Data.SqlClient.SqlCommand();
//
// sqlGetPageSettingsCommand
//
this.sqlGetPageSettingsCommand.CommandText = "dbo.[GetPageSettings]";
this.sqlGetPageSettingsCommand.CommandType = System.Data.CommandType.StoredProcedure;
this.sqlGetPageSettingsCommand.Parameters
.Add(new System.Data.SqlClient.SqlParameter("@RETURN_VALUE",
System.Data.SqlDbType.Int, 4,
System.Data.ParameterDirection.ReturnValue,
false,
((System.Byte)(10)),
((System.Byte)(0)),
"",
System.Data.DataRowVersion.Current,
null));
this.sqlGetPageSettingsCommand.Parameters
.Add(new System.Data.SqlClient.SqlParameter("@PageId",
System.Data.SqlDbType.Int,
4,
System.Data.ParameterDirection.Input,
false,
((System.Byte)(10)),
((System.Byte)(0)),
"",
System.Data.DataRowVersion.Current,
null));
this.sqlGetPageSettingsCommand.Parameters
.Add(new System.Data.SqlClient.SqlParameter("@PageName",
System.Data.SqlDbType.VarChar,
32));
this.sqlGetPageSettingsCommand.Parameters
.Add(new System.Data.SqlClient.SqlParameter("@Header",
System.Data.SqlDbType.VarChar,
256,
System.Data.ParameterDirection.Output,
false,
((System.Byte)(0)),
((System.Byte)(0)),
"",
System.Data.DataRowVersion.Current,
null));
this.sqlGetPageSettingsCommand.Parameters
.Add(new System.Data.SqlClient.SqlParameter("@ImagePath",
System.Data.SqlDbType.VarChar,
1024,
System.Data.ParameterDirection.Output,
false,
((System.Byte)(0)),
((System.Byte)(0)),
"",
System.Data.DataRowVersion.Current,
null));
this.Load += new System.EventHandler(this.Page_Load);
}
#endregion
#region Further to Web Form Designer generated code
#endregion
/// <summary>
/// Метод инициализации компонентов, дополняет функционал метода
/// InitializeComponent
/// </summary>
private void CustomInitializeComponent()
{
this.additionalLeftPanel.Visible = false;
this.mainLeftPanel.Visible = false;
this.mainRightPanel.Visible = false;
SqlClient.Processing(new SqlClient.ProcessingLogic(this.BuildPage));
}
/// <summary>
/// Метод реализует получение информации о запрашиваемой странице и
/// загружает все необходимые для данной страницы модули (UC)
/// </summary>
private void BuildPage(SqlConnection sqlConnection)
{
this.sqlGetPageSettingsCommand.Connection = sqlConnection;
// Задаём параметры хранимой процедуры для запрашиваемой страницы
if (this.Request.QueryString["PageId"] != null)
{
this.sqlGetPageSettingsCommand
.Parameters["@PageId"].Value = this.Request.QueryString["PageId"];
}
else
{
if (this.Request.QueryString["PageName"] != null)
{
this.sqlGetPageSettingsCommand
.Parameters["@PageName"].Value = this.Request.QueryString["PageName"];
}
else
{
this.sqlGetPageSettingsCommand
.Parameters["@PageName"].Value = ConfigurationSettings.AppSettings["PageDefaultName"];
}
}
// Заполняем контейнеры страницы соответствующими модулями
SqlDataReader sqlDataReader = this.sqlGetPageSettingsCommand.ExecuteReader();
string currentModuleContainerName = "";
Control moduleContainerControl = new Control();
while(sqlDataReader.Read())
{
if (currentModuleContainerName != sqlDataReader["ModuleContainer"].ToString().Trim())
{
currentModuleContainerName = sqlDataReader["ModuleContainer"].ToString().Trim();
moduleContainerControl = this.FindControl(currentModuleContainerName);
}
// this.FindControl может вернуть null, если Control с таким именем
// отсутствует на странице, поэтому переходим к следующему модулю.
if (moduleContainerControl == null)
{
continue;
}
// Пробуем загрузить модуль в контрол - контейнер
try
{
moduleContainerControl.Controls
.Add(this.LoadControl(sqlDataReader["ModulePath"].ToString()));
// Поскольку контрол нормально загрузили, делаем его видимым
if (moduleContainerControl.Visible != true)
{
moduleContainerControl.Visible = true;
}
}
catch(System.IO.FileNotFoundException ex)
{
// Ничего не делаем :(
}
}
sqlDataReader.Close();
if (this.IsFirstVisit)
{
this.toolTip.Src = "Resources/Menu/PervertMenu/ToolTips/WelcomeHand.gif";
this.Response.Cookies["NotFirstVisit"].Value = Convert.ToString(true);
this.Response.Cookies["NotFirstVisit"].Expires = DateTime.Now.AddYears(5);
}
else
{
// Получаем иконку страницы
this.toolTip.Src = this.sqlGetPageSettingsCommand.Parameters["@ImagePath"]
.Value.ToString();
}
// Получаем заголовок страницы
this.title.InnerText = this.sqlGetPageSettingsCommand.Parameters["@Header"]
.Value.ToString().ToUpper();
}
#endregion
}
}
Зачем нужны additionalLeftPanel, mainLeftPanel и mainRightPanel мы уже знаем,
HtmlGenericControl title – это заголовок страницы, представленный на клиентской
стороне тэгом DIV . C равным, а точнее, большим успехом это может быть Label
или LiteralControl. То, что я использую DIV и его серверное представление
HtmlGenericControl, скорее частный случай. HtmlImage toolTip – это изображение,
соответствующее запрошенной пользователем страницы. Поля Header и ImagePath
таблицы Pages, описанной в разделе 3.1, отображаются серверными элементами
title и toolTip. SqlCommand sqlGetPageSettingsCommand будет использоваться для
получения результатов работы хранимой процедуры GetPageSettings, также
описанной в разделе 3.1. Назначение свойства IsFirstVisit достаточно очевидно,
и если честно, к теме статьи имеет мало отношения. В зависимости от значения
этого свойства в HtmlImage toolTip выводится либо “родное” изображение
страницы, либо, если это первый визит пользователя, изображение,
соответствующее первому визиту пользователя. В конструкторе страницы происходит
подписка метода Page_Init на событие Init. Сам метод последовательно вызывает
сгенерированный дизайнером метод InitializeComponent, работа которого в нашем
случае сводится к заполнению свойств sqlGetPageSettingsCommand и метод
CustomInitializeComponent, делающий невидимыми наши контейнеры
additionalLeftPanel, mainLeftPanel и mainRightPanel – т.к. на момент запроса
страницы неизвестно, какие из них нам понадобятся, и выполняющий статический
метод SqlClient.Processing, в который при помощи делегирования передаётся метод
BuildPage, реализует операции открытия / закрытия соединения с Sql сервер. Код
класса, подобного этому, я приводил
здесь . Метод BuildPage – это как раз реализация задачи построения
страницы из некоторого набора модулей, можно даже сказать, инкапсуляция логики
построения нашего одностраничного портала :). И, как можно легко увидеть,
ничего гениального он в себе не содержит: в зависимости от параметров запроса
страницы формируются параметры для передачи в хранимую процедуру
GetPageSettings, создаётся sqlDataReader, в процессе работы которого
необходимые для корректного отображения запрашиваемой страницы модули
загружаются и добавляются в заданные для них контейнеры в заданном порядке. В
качестве выходных параметров хранимой процедуры мы получаем название страницы и
изображение, ей соответствующее. Вот и всё :).
Заключение
Описанная реализация одностраничного портала наверняка имеет множество мелких
недочётов и ненужностей, и не претендует на уникальность (или, упаси Боже,
гениальность :)) однако, на мой взгляд, имея перед глазами описанную структуру,
можно достаточно быстро и легко писать проекты не очень сложных по постановке
задачи порталов. Почему и для кого была написана эта статья, я говорил в самом
начале, так что не буду повторяться. Удачи!
|