пятница, 27 августа 2010 г.

Создание GridView-контрола в WebForms и MVC с SEO-friendly пейжингом

SEO-friendly пейжинг в ASP.NET webForms

В предыдущем посте я описывал разные способы создания аналога контрола <asp:GridView /> средствами ASP.NET MVC. В итоге полученные результаты хоть и были более трудоемкими в создании кода, но обладали некоторыми свойствами, недоступными привычному WebForms контролу, такими как, например: загрузка данных через AJAX-запрос, более удобное назначение стилей элементов таблицы, контроль над генерируемым кодом.

Конечно, рассмотрев несколько удачных примеров замены серверного контрола в среде WebForms на аналогичные (и даже более богатые возможностями) контролы в среде MVC, мы с ухмылкой посматриваем на старый проверенный элемент разметки страницы <asp:GridView runat=”server”… />, подбирая в уме замену более подходящим вариантом. Но не стоит спешить выделять блок страницы и жать кнопку <del> – смогут ли решить следующую задачу найденные нами jQuery-контролы, с которой старый добрый GridView опять справляется на 5 баллов.

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

Вроде бы ничего нового. Но… Что значит - Добавить возможность перехода по пейжингу поисковым роботам?

Посещая ресурс, поисковый робот загружает только тот HTML-код, который был возвращен на запрос к сайту. Если мы используем jQuery-плагин, который загружает содержимое таблицы после загрузки основной страницы дополнительным AJAX-запросом через JavaScript-функцию, то поисковый робот получит HTML-код с пустой таблицей.

image

Если же мы используем плагин, который осуществляет переход по страницам таблицы посредством вызова JavaScript-функции – поисковый робот опять-таки далее загрузки основной таблицы не пойдет.

Почему? Ответ простой – поисковые роботы не выполняют .js-код при загрузке содержимого страницы.

Но тут же и на время стопорится работа и при использовании привычного WebForms контрола <asp:GridView  />, так как для перехода по страницам пейжинга он использует postback-механизм (нажатие кнопки для осуществления перехода на следующую страницу), что нарушает еще одно правило поведения робота на странице: поисковый робот на нежимает на кнопки.

image

Попробуйте загрузить любую страницу интернета с помощью Lynx-браузера и увидите ее так, как видит ее поисковый бот. Вот пример страницы Торговой площадки Ydobno, отображенной с помощью lynx-браузера.

image

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

image

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

image

Хорошо, проблема ясна. Теперь можно искать ее решение. Для начала конечно же эксперементируем с нашим серверным контролом. Как упоминалось в самом начале об этом контроле – что нам снег, что нам зной, что нам пейжинг сотворить. Но, конечно, не без дополнительных манипуляций.

В паре с серверным контролом используем еще один контрол - <asp:DataPager runat=”server” />, который как раз и позволяет нам генерировать пейжинг в одном из следующих форматов: числовое отображение количества страниц (1-10, 11-20 и т.д.), переход вперед-назад, custom template пейжинг. При этом мы можем объединять варианты, получая числовые ссылки и ссылки вперед-назад по краям.

Итак, добавляем на страницу следующий контрол:

<asp:DataPager ID="DataPager1" runat="server" QueryStringField="page" PagedControlID="gvEmployee">
<Fields>
<asp:NextPreviousPagerField FirstPageText="|&lt;&lt;" ShowFirstPageButton="False" ShowNextPageButton="False" ShowPreviousPageButton="True" PreviousPageText="&lt;&lt;" />
<asp:NumericPagerField ButtonCount="10" CurrentPageLabelCssClass="dataPager-current" />
<asp:NextPreviousPagerField LastPageText="&gt;&gt;|" ShowLastPageButton="False" ShowPreviousPageButton="False" ShowNextPageButton="True" NextPageText="&gt;&gt;" />
</Fields>
</asp:DataPager>



Как мы видим, данный контрол имеет свойство PagedControlID, которому присваиваем ID нашего грида для построения пейжинга. Одна лишь проблема, если мы запустим данный код на выполнение, то получим следующую страницу:


image


Серверный контрол <asp:GridView …/> не имплементирует интерфейс IPageableItemContainer. Для решения данной проблемы мы создаем новый класс DataPagerGridView, унаследованный от GridView, имплементирующий необходимый интерфейс:


/// <summary>
/// DataPagerGridView is a custom control that implements GrieView and IPageableItemContainer
/// </summary>
public class DataPagerGridView : GridView, IPageableItemContainer
{
public DataPagerGridView()
: base()
{
PagerSettings.Visible = false;
}

/// <summary>
/// TotalRowCountAvailable event key
/// </summary>
private static readonly object EventTotalRowCountAvailable = new object();

/// <summary>
/// Call base control's CreateChildControls method and determine the number of rows in the source
/// then fire off the event with the derived data and then we return the original result.
/// </summary>
/// <param name="dataSource"></param>
/// <param name="dataBinding"></param>
/// <returns></returns>
protected override int CreateChildControls(IEnumerable dataSource, bool dataBinding)
{
int rows = base.CreateChildControls(dataSource, dataBinding);

// if the paging feature is enabled, determine the total number of rows in the datasource
if (this.AllowPaging)
{
// if we are databinding, use the number of rows that were created, otherwise cast the datasource to an Collection and use that as the count
int totalRowCount = dataBinding ? rows : ((ICollection)dataSource).Count;

// raise the row count available event
IPageableItemContainer pageableItemContainer = this as IPageableItemContainer;
this.OnTotalRowCountAvailable(new PageEventArgs(pageableItemContainer.StartRowIndex, pageableItemContainer.MaximumRows, totalRowCount));

// make sure the top and bottom pager rows are not visible
if (this.TopPagerRow != null)
this.TopPagerRow.Visible = false;

if (this.BottomPagerRow != null)
this.BottomPagerRow.Visible = false;
}
return rows;
}

/// <summary>
/// Set the control with appropriate parameters and bind to right chunk of data.
/// </summary>
/// <param name="startRowIndex"></param>
/// <param name="maximumRows"></param>
/// <param name="databind"></param>
void IPageableItemContainer.SetPageProperties(int startRowIndex, int maximumRows, bool databind)
{
int newPageIndex = (startRowIndex / maximumRows);
this.PageSize = maximumRows;

if (this.PageIndex != newPageIndex)
{
bool isCanceled = false;
if (databind)
{
// create the event arguments and raise the event
GridViewPageEventArgs args = new GridViewPageEventArgs(newPageIndex);
this.OnPageIndexChanging(args);

isCanceled = args.Cancel;
newPageIndex = args.NewPageIndex;
}

// if the event wasn't cancelled change the paging values
if (!isCanceled)
{
this.PageIndex = newPageIndex;

if (databind)
this.OnPageIndexChanged(EventArgs.Empty);
}
if (databind)
this.RequiresDataBinding = true;
}
}

/// <summary>
/// IPageableItemContainer's StartRowIndex = PageSize * PageIndex properties
/// </summary>
int IPageableItemContainer.StartRowIndex
{
get { return this.PageSize * this.PageIndex; }
}

/// <summary>
/// IPageableItemContainer's MaximumRows = PageSize property
/// </summary>
int IPageableItemContainer.MaximumRows
{
get { return this.PageSize; }
}

/// <summary>
///
/// </summary>
event EventHandler<PageEventArgs> IPageableItemContainer.TotalRowCountAvailable
{
add { base.Events.AddHandler(DataPagerGridView.EventTotalRowCountAvailable, value); }
remove { base.Events.RemoveHandler(DataPagerGridView.EventTotalRowCountAvailable, value); }
}

/// <summary>
///
/// </summary>
/// <param name="e"></param>
protected virtual void OnTotalRowCountAvailable(PageEventArgs e)
{
EventHandler<PageEventArgs> handler = (EventHandler<PageEventArgs>)base.Events[DataPagerGridView.EventTotalRowCountAvailable];
if (handler != null)
{
handler(this, e);
}
}
}


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


<%@ Register TagPrefix="pager" Namespace="WebApplication2" Assembly="WebApplication2" %>
...
<pager:DataPagerGridView ID="gvEmployee" runat="server" DataSourceID="dsEmployee" AllowPaging="true" />
<asp:DataPager ID="DataPager1" runat="server" QueryStringField="page" PagedControlID="gvEmployee">
<Fields>
<asp:NextPreviousPagerField FirstPageText="|&lt;&lt;" ShowFirstPageButton="False" ShowNextPageButton="False" ShowPreviousPageButton="True" PreviousPageText="&lt;&lt;" />
<asp:NumericPagerField ButtonCount="10" CurrentPageLabelCssClass="dataPager-current" />
<asp:NextPreviousPagerField LastPageText="&gt;&gt;|" ShowLastPageButton="False" ShowPreviousPageButton="False" ShowNextPageButton="True" NextPageText="&gt;&gt;" />
</Fields>
</asp:DataPager>


Судя по декларации DataPager-контрола имеем ссылку на предыдущую страницу в начале и на следующую страницу в конце пейжинга, а также имеет ссылки на 10 текущих страниц (поскольку у нас мало данных, то показано всего 2 страницы).


Вуаля! Получаем следующий результат:


image


Отлично! Полученный результат вполне удовлетворяет нашим пожеланием относительно возможности максимально глубокого сканирования поисковым ботом содержимого сайта. И, опять таки, решение построение на базе мощного серверного контрола <asp:GridView /> при помощи <asp:DataPager />. Теперь, загружая данную страницу, поисковый бот получит и список ссылок на другие страницы пейжинга, что позволит ему последовательно посетить и просканировать все элементы данной таблицы.


 


SEO-friendly пейжинг в ASP.NET MVC


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


Поскольку конечным результатом нам необходимо отобразить запрашиваемый срез данных и показать пейжинг для перехода к другим страницам контрола, то для более удобной манипуляции данными на сервере было бы хорошо использовать объект, позволюящий нам хранить лишь нужный нам в данный момент набор данных, но при этом сохраняя информацию об общем количестве объектов, как далеко мы находимся от начала набора и т.д. В процессе приготовления к данному материалу я наткнулся на реализацию данного класса на codeplex - PagedList<T> (либо же можно найти похожую реализацию на этом блоге). Для работы с данными в веб-ориентированных проектах данный класс будет крайне пригодным, так как позволяет превратить любой объект, наследуемый от IEnumerable, в объект PagedList, благодаря которому можно получить много полезной информации о наборе данных: первая ли это страница из всего набора, последняя ли это страница, общее количество данных, количество данных в отображаемой наборе и т.д.


Для отображения данных на страницу создадим отдельный HtmlHelper-метод – GridView.


public static class GridViewExtensions
{
public static void GridView<T>(
this HtmlHelper html,
PagedList<T> data,
Action<PagedList<T>> headerTemplate,
Action<T, string> itemTemplate,
string cssClass,
string cssAlternatingClass,
Action<PagedList<T>> footerTemplate,
Action<PagedList<T>> paging)
{
headerTemplate(data);

for (int i = 0; i < data.Count; i++)
{
var item = data[i];
itemTemplate(item, (i % 2 == 0 ? cssClass : cssAlternatingClass));
}

footerTemplate(data);
paging(data);
}
}


Чтобы жестко не прописывать логику генерации кода контрола, а сделать его универсальным для отображения разных табличных данных – прибегаем к использованию Action-методов (чтобы не загружать наш контрол делегатными функциями), логику работы которых мы будем индивидуально прописывать в шаблоне View. Конечно это заставит нас писать больше кода для каждого View, в котором мы будем использовать данный контрол, поэтому для полного комфорта было бы хорошо ввести два контрола – один с широкими возможностями для изменения внешнего вида, второй – построение единого шаблона отображения, но и требующего написания меньше кода. Остановимся пока на первом варианте.


Создаем новый strongly-typed view и вводим следующий тип (он не будет присутствовать в выпадающем списке) -PagedList<EmployeeModel>


image



Добавляем следующий код на страницу:


<% Html.GridView<MvcApplication1.Models.EmployeeModel>(
this.ViewData.Model,
data =>
{ %>
<table class="grid" cellpadding="0" cellspacing="0">
<% },
(item, css) =>
{ %>
<tr class="<%=css%>">
<td><%=item.EmployeeID%></td>
<td><%=item.FirstName%></td>
<td><%=item.LastName%></td>
</tr>
<% },
"item",
"item-alternating",
data =>
{ %>
</table>
<% },
data =>
{ %>
<%= Html.Pager(Model, "/Employee/GetList")%>
<%}); %>



Необходимо разобрать более детально этот код для лучшего его понимания.

Для генерации табличного контрола мы создали статический html-helper метод GridView, в который в нужной последовательности необходимо передать данные, согласно декларации метода. Хотелось бы сильнее обратить внимание на следующую запись:


data =>
{ %>
<table class="grid" cellpadding="0" cellspacing="0">
<% },





согласно декларации метода в классе третьим параметором мы должны были передать Action-метод

...
Action<PagedList<T>> headerTemplate
...



Как Action-метод может быть заменен такой конструкцией? Сначала необходимо разобраться что из себя представляет класс Action и как серверный код превращается в html-код.


Начнем со второго вопроса. Не задумывались ла Вы, почему компилятор при компиляции страницы указывает лишь об ошибках внутри серверных блоков (<% %>, <%= %>), но пропускает любое упоминание об ошибке в записи Html-тега например? Все очень просто: для компилятора все что внутри серверных блоков – это инструкции для выполнения, все что за пределами – это строки текста, которые просто надо вывести на страницу. Представим что мы имеем следующую декларацию на странице:


<ul>
<% for(int i=1; i<=3; i++) { %>
<li><%= i %></li>
<% } %>
</ul>

При компиляции страницы в сборку данный код будет выглядеть приблизительно так:


Response.Write("<ul>");
for(int i=1; i<=3; i++)
{
Response.Write("<li>");
Response.Write(i);
Response.Write("</li>");
}
Response.Write("</ul>");


Именно поэтому компилятор не показывает сообщение об ошибке записи тега, как как для него – это обычный блок текста, который необходимо вывести в output-поток согласно своей позиции.


Что же такое Action-методы? Объекты данного класса используются для замены делегатов-функций определенного синтаксиса – принимают один параметр и на выходе не возвращают ничего (void). К тому же Action-методы позволяют задавать декларации с использованием анонимных функций и лямбда-выражений. Следующий код демонстрирует простой пример использования Action-метода для замены делегатов.


public static void Main()
{
Action<string> messageTarget;
messageTarget = s => ShowWindowsMessage(s);

messageTarget("Hello, World!");
}

private static void ShowWindowsMessage(string message)
{
MessageBox.Show(message);
}


В нашем же примере мы используем тоже анонимные функции и лямбда-выражение для декларации Action-метода.

В итоге, наш код


data =>
{ %>
<table class="grid" cellpadding="0" cellspacing="0">
<% },


можно превратить в следующую аналогичную запись:





delegate(PagedList<T> data) { Response.Write("<table class='grid' cellpadding='0' cellspacing='0'>"); },


Хорошо,  детально разобрали декларацию нового HtmlHelper-метода GridView. Но, как видно, для отображения пейжинга на странице используем еще один HtmlHelper-метод – Pager.


Я не буду вдаваться в детали его реализации – более детально можно почитать на этом ресурсе. Одно лишь замечу, что в качестве источника данных для построения пейжинга данный контрол использует объект реализованного выше класса PagedList.


После компиляции мы получил следующий результат:


image


Если просмотреть html-код, то видим, что весь контент присутствует и ссылки на месте. Это значит, что при загрузке данной страницы поисковым роботом, он получит всю информацию для индексации.


Отлично, поставленная задача опять выполнена, хотя и потребовалось много дополнительной ручной работы – создать дополнительные контролы и дополнительные классы для оперирования данными. Но при этом опять с лучшим результатом, чем для серверного контрола <asp:GridView runat=”server” />, а именно получили ссылку без параметров строки (article.aspx?Id=2, как в примере с WebForms), а полноценную ссылку. В этом случае поисковики больше доверяют ссылке без параметров строки.


Для упрощения работы с созданием табличных контролов можно посоветовать использовать контролы сторонних разработчиков. Для примера компания Telerik предлагает удобный Grid-контрол для MVC.


image


Примеры его использования можно посмотреть здесь.