вторник, 19 апреля 2011 г.

WebFarm/WebGarden сценарии – управление состоянием, сессией, кешем

Введение

Как известно, стандартная веб страница не хранит состояния. Точнее – каждый раз при отправке запроса назад на сервер на клиент возвращается новая сгенерированная страница. В традиционном веб-программировании каждая последующая оптравка страницы на сервер влечет за собой постоянное сохранение данных формы и восстановление этих значений на клиенте. Для решения этой проблемы в ASP.NET предоставляются множесвтво приемов: ControlState, ViewState, Coockie, Session и т.д. Посмотрим на следующую диаграмму:

States

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

Что такое AppDomain

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

Роль AppDomain в ASP.NET

Конечно же – эта роль ключевая. Когда сервер IIS получает первый запрос к приложению, создается отдельный Application Domain. Его ключевая задача – обеспечить полную изоляцию от других приложений, работающих на данном сервере, а значит: работать только в рамках выделенных ресурсов, не иметь возможность переходить рамки политики безопасности, абсолютно независимое от других AppDomain загрузк-выгрузки из памяти выделенных ресуросов.

ALC1

В рамках созданного AppDomain для приложения доступны перечисленные способы хранения состояния: InProc Session, Cache, App variables, Static variables. Когда AppDomain завершает свою работу либо перегружается – все эти данные очищаются.

AppDomaincontains

Что такое WebFarm (веб-ферма)

Данная концепция используется в сценариях, когда приложение имеет довольно внушительный поток пользователей и для поддержания качества и приемлемой скорости обработки и передачи данных необходимо использовать множество серверов. Но просто увеличение количества серверов не решает проблему – необходимо также реализовывать механизмы проверки нагруженности определенного сервера для последующей перенаправки запроса на простаивающий сервер. Посему веб-фермы работает в паре с NLB (Network Load Balancer). При каждом запросе сначала определяется наименее загруженный сервер и данный запрос направляется на этот сервер.

webfarm

В данном случае возникает проблема хранения сессийного состояния в AppDomain, так как NLB не гарантирует, что каждый последующий запрос попадет на этот же сервер. В этом случае необходимо использовать OutProc session mode. Более детально дальше.

В некоторых случаях, если невозможно реализовать OutProc session mode NLB можно настроить так, чтобы все запросы с одного IP-адресса всегда приходили на один и тот же сервер. Тем самым мы гарантировано может использовать InProc session mode. Но тут есть и свои неудобства – NLB работает не на полную силу и даже при перегрузке сервера какими-то другими процессами мы все равно вынуждены перенаправлять все запросы с указаного IP на него.

Что такое Web Garden

После того, как новый запрос приходит на сервер, как уже говорилось, для приложения создается AppDomain, в рамках которого работает приложение и в рамках которого доступны все переменные приложения. Каждый AppDomain создается в рамках нового или рабочего ASP.NET процесса w3wp.exe. Согласно настройкам приложения, каждое приложение прикреплено к определенному пулу приложений и создается также в рамках данного пула. Application Pool также налаживает свои ограничения на все процессы внутри него на использование ресурсов, на безопасность и т.д. Так вот, если Application Pool включает в себя несколько рабочих процессов ASP.NET, то такой пул приложеий можно рассматривать как WebGarden. Зачастую преимущества его видны на многопроцессорных серверах, где именно созданием дополнительных процессов загружаем простаивающие процессоры сервера. На рисунку ниже именно первый и второй пул представляют из себя WebGarden.

appDomainsnew

Управление сессиями

Существует два режима хранения сессийный переменныйх:

  • InProc
  • OutProc

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

OutProc – в данном режиме данные не хранятся в AppDomain, а значит требуют дополнительных приемов по сериализации\десериализации для доступа к ним. Существуют следующие способы хранения сессийных данных вне рабочего процесса:

  • StateServer – для хранения данных настраивается дополнительно StateServer. На Windows Server 2003, например, он устанавливается автоматически после установки операционной системы. Необходимо лишь включить (запустить) сервис в настройках.
  • SqlServer – в данном случае выбирается отдельный SqlServer для хранения сессийный переменных. Для правильной конфигурации необходимо выполнить ряд скриптов, которые можно найти на сайте Microsoft. В результате можно восстанавливаеть состояние сессии для пользователя, пришедшего через несколько дней после последнего посещенения ресурса.
  • Собственное решение – имеется возможность создания собственного провайдера управления сессийными перменными, если перечисленные способы не могут быть использованы в вашем варианте, например, если необходимо хранить данные в файлах на сервере.

Управление кешированием при использовании WebFarm\WebGarden

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

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

    filesystemnew

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

    dbdependency

  • Мы имеем кешированные данные на каждом сервере фермы, но не может знать периодичность их обновления и не можем гарантировать, что данные валидные на этот момент.
    Это уже боле сложная ситуация и имеет несколько вариантов решения:

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

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

      webserviceapproach
    • Использовать отдельный сервер для кеширования данных и реализовать логику контроля над валидностью данных на этом сервере. Все другие компьютеры фермы через быстрое TCP/IP-соедиение обращаются на этот сервер и получают необходимую порцию данных. Тем самым единожды реализовываются механизмы кеширования и контроля над валидностью кеширования. Сам же кеш-сервер может читать данные из любого другого источника. Остальные веб-сервера имеют доступ только к данному серверу.

      remoting
    • Использование серверов или механизмов сторонних фирм. Например использование CDN от Microsoft или Google для получения статичных данных.

четверг, 27 января 2011 г.

EasyHTTP – инструмент для удобного взаимодействия с веб-сервером

Работая над множеством интернет-проектов, я довольно часто сталкиваюсь с проблемой, когда приходится обмениваться данными с сервером не посредстовом вызова веб-сервисов, а непосредственно создавая и отправляя HtppRequest с прикрепленными параметрами и обрабатывая HttpResponse, приводя возвращенные данные к нужному объекту класса.

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

Можно продемонстрировать коротенький пример данного подхода: допустим мы имеем обычный RSS-клиент, но оперирующий данными, хранящимися на сервере. Наш клиент может получать список новых статей, добавлять новый RSS-канал, отмечать статьи как прочитанные.

Для удобного оперирования данными создадим соответвующие классы объектов приложения:

public class RssChanel
{
public int Id { get; set; }
public string Title { get; set; }
public string RssUrl { get; set; }
public string Category { get; set; }
}

public class SitePage
{
public int Id { get; set; }
public int RssChanelId { get; set; }
public RssChanel RssChanelSource { get; set; }
public string Title { get; set; }
public string Preview { get; set; }
public string Url { get; set; }
public DateTime CreateDate { get; set; }
public bool IsRead { get; set; }
}



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


/// <summary>
/// Summary description for RssService
/// </summary>
[WebService(Namespace = "http://tempuri.org/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
public class RssService : System.Web.Services.WebService
{

[WebMethod]
public bool RssChanelProvider_Insert(RssChanel chanel)
{
return DataRepository.RssChanelProvider.Insert(chanel);
}

[WebMethod]
public bool RssChanelProvider_Update(RssChanel chanel)
{
return DataRepository.RssChanelProvider.Update(chanel);
}

[WebMethod]
public bool RssChanelProvider_Delete(int chanelId)
{
return DataRepository.RssChanelProvider.Delete(chanelId);
}

[WebMethod]
public SitePage[] SitePageProvider_GetAll(int pageLength)
{
return DataRepository.SitePageProvider.GetAll(pageLength).ToArray();
}

[WebMethod]
public bool SitePageProvider_Update(SitePage sitePage)
{
return DataRepository.SitePageProvider.Update();
}
}


Итак, теперь с помощью соответсвующих запросов мы можем достучаться до методов нашего веб-сервиса


image


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


image


image


Все. Далее всю сложную работу по созданию прокси-класов, wsdl-описания сервиса и многое другое берет на себя VisualStudio. Также в конфигурационный файл добавляется сексия управления каналом связи с сервером. Тонна рутинной работы была сделана за нас студией. Нам же остается лишь изменить тестовый URL-адрес сервера на реальный.


В итоге мы имеем удобную объектную модель для обмена данными с сервером.


image image


Следующий код будет абсолютно валидным и компилируемым:


static void Main(string[] args)
{
RssServiceSoapClient serviceClient = new RssServiceSoapClient();

SitePage[] sitePages = serviceClient.SitePageProvider_GetAll(20);

foreach (SitePage sitePage in sitePages)
{
sitePage.IsRead = true;
serviceClient.SitePageProvider_Update(sitePage);
}
}






Отлично. Если же наше веб-приложение постоянно развивается и объектная модель со временем может менятся – для клиента необходимо просто перегенерировать ServiceReference и новые свойства будут доступны в перегенерированных proxy-классах.


image


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


Хорошо, но что делать, если необходимо создать клиентское приложение, получающее и обрабатывающее данные не с нашего сервера, т.е. где мы не можем гарантировать, что обмен данными происходит через веб-сервисы.


К тому же мы не можетvгарантировать, что все ресурсы, из которых мы чарпаем данные, отдают нам их именно в том формате, в котором нам надо. Хотелось бы иметь какое-то универсальное средство, упрощающее монотонную работу с HTTP – Get,Post,Delete,Put, сериализация из JSON в объектную модель приложения, чтение данных из REST-full сервисов.


Анализируя библиотеки сторонных разработчиков, я нашел для себя наиболее удобную – EsayHTTP. Можно на первом коротеньком примере показать, в чем именно упрощается работа при использовании данной библиотеки:


var http = new HttpClient
{
Request = {Accept = HttpContentTypes.ApplicationJson}
};

var response = http.Get("http://domain.com/customer/25");

var customer = response.StaticBody<Customer>();



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


В этом же примере мы задаем Accept Header возвращаемой страницы и конвертируем ответ в strogly-typed объект Customer.


Если же наша бизнес-модель не имеет точного типа объекта для десериализации ответа сервера, мы может проделать следующие манипуляции:


var http = new HttpClient
{
Request = {Accept = HttpContentTypes.ApplicationJson}
};

var response = http.Get("http://domain.com/customer/25");

dynamic customer = response.DynamicBody();

Console.WriteLine(customer.Name);
Console.WriteLine(customer.Email);



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


Если же нам просто надо скачать страницу или изображение и сохранить его в файл на локальном компьюетере:


var http = new HttpClient();
http.GetAsFile("http://ydobno.net/logo.png", @"C:\Logos\ydobno\logo.png");


Крайне просто также сделать POST-запрос на сервер:


var http = new HttpClient();

var customer = new Customer()
{
Name = "Joe Smith",
Email = "Joe@Gmail.com"
};

http.Post("http://domain.com/customer", customer, HttpContentTypes.ApplicationJson);



К тому же мы можем задавать тип кодировки передаваемых данных, в нашем случае данные на сервере принимаются в формате JSON.


Отлично. Благодаря данной библиотеке мы сразу получили мощный инструмент для взаимодействия с удаленным сервером, получили возможность обрабатывать и посылать запросы, не погружаясь в глубины HTTP, получили возможность вытягивать из ответного потока объекты, не погружаясь в глубины десеарилизации. Конечно же, данная библиотека не является панацеей от всех проблем и скорее всего придется делать свои классы для работы с сервером на уровне HTTP, но во многих стандартных вопросах смело можно положится именно на нее.

среда, 19 января 2011 г.

Выполнение методов веб сервиса в браузере

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

Примером может быть:

image

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

image

Чтобы добавить возможность вызывать методы и передавать параметры методам сразу в браузере необходимо всего лишь добавить следующие строки в конфигурационный файл веб-приложения в секцию <system.web>:

<webServices>     
<protocols>
<add name="HttpGet"/>
<add name="HttpPost"/>
</protocols>
</webServices>


В результате при переходе на конкретный метод веб-сервиса можно видеть форму ввода параметров и кнопку Invoke для выполнения метода с введенными параметрами с выводом результата выполнения на экран.


image


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

четверг, 6 января 2011 г.

Контроль исключений ASP.NET с помощью модуля Elmah

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

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

image

Используя валидаторы ASP.NET на этапе постинга формы мы проверяем введенное значения на условия: не пустое, целочисленное, меньше 5. Если введенные пользователем значения не попадают под наши правила валидации, мы выдаем предупреждения:

Введите количество человек
Только целочисленные значение не более 5

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

image

Было вызвано необработанное исключение в ответ на операцию деления на ноль. Крайне некрасиво смотрится в рабочем приложении и крайне сложно определяется на этапе тестирования, особенно если данная форма показывается пользователю после множества этапов ввода данных.

Если же исправление ошибки довольное длительная процедура, а необходимо какое-никакое решения прямо сейчас – можно на первое время перенапрявлять пользователя на custom error страницу, если произошло необработанное исключение. Тем самым со стороны пользователя остается мнение, что система держит все под контролем.

Чтобы добавить такую функциональность необходимо просто создать новую страницу (например error.aspx) и добавить следующую запись в конфигурационный файл web.config.

<customErrors mode="On" defaultRedirect="error.aspx">
</customErrors>


Теперь при постинге формы с нулевым значением количества участников мы получим следующий ответ сервера (для примера)


image


Решение вроде бы подходящее, но малоинформативное для администратора системы. Можно полагаться конечно на сознательных пользователей нашей системы, которые точно опишут совершенные ими шаги, укажут введенные в поля значения и отправят письмо по указанному адресу. Хотелось бы получасть уведомления о каждой критической ошибке приложения и независимо от того – произошло это при постинге формы, либо же в фоновом процессе рассылки писем пользователям, либо при ajax-запросе для обновления пользовательского интерфейса.


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


Для этого с помощью NuGet (расширение Visual Studio) добавим библиотеку Elmah (если расширение NuGet не установлено – рекомендую срочным образом исправить это :) так как очень много именно рутинной работы помогает решить столь незаметное для всего рабочего процесса приложение).


image


image




Итак, нажимая кнопку Install происходят следующие действия (замечу, что без NuGet эти действия необходимо было бы проделать вручную):



  • Закачивается последняя стабильная сборка Elmah библиотеки;

  • Библиотека добавляется в References вашего приложения (если тип проекта – WebSite – сборка добавляется в Bin директорию);

  • В конфигурационном файле регистрируется новая SectionGroup – elmah и регистрируются новые модули и хендлеры. В частности, регистрируется хендлер elmah.axd, который генерирует структурированный отчет об ошибках приложения;

Все. Несколько кликов и механизм доступа администратором ко всем ошибкам приложения реализован. Введем в нашу форму 0 и еще раз запостим нашу форму. Теперь перейдем на страницу elmah.axd в корне нашего приложения. Это зарегестрированный ранее хендлер, управляемый библиотекой Elmah.dll


image


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


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


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


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


Для решения данной проблемы мы можем использовать возможности ранее подключенной библиотеки Elmah. Для этого в наш конфигурационный файл добавим в секцию <configuration> и в секцию <httpModules> следующие найтройки:


 


<elmah>
<errorMail from="test@test.com"
to="test@test.com"
subject="Application Exception"
async="false"
smtpPort="25"
smtpServer="***"
userName="***"
password="***">
</errorMail>
</elmah>
...
<httpModules>
<add name="ErrorLog" type="Elmah.ErrorLogModule, Elmah"/>
<add name="ErrorMail" type="Elmah.ErrorMailModule, Elmah" />
</httpModules>


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


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


public class VeryImportantException : Exception
{
public VeryImportantException(Exception ex) : base(ex.Message, ex)
{
}
}


В global.asax добавим следующие методы:


public void ErrorLog_Filtering(object sender, ExceptionFilterEventArgs e)
{
Exception exception = e.Exception as VeryImportantException;
if (exception != null)
{
ErrorLog.GetDefault(HttpContext.Current).Log(new Error(exception.InnerException));
e.Dismiss();
}
}

public void ErrorMail_Filtering(object sender, ExceptionFilterEventArgs e)
{
if (!(e.Exception is VeryImportantException))
e.Dismiss();
}


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


Итак, благодаря возможностям библиотеки Elmah, мы реализовали все необходимые нашему приложению требования относительно логирования ошибок, вплоть до рассылки на почту особо опасных приложению исключений. Теперь, используя следующий синтаксис, мы можем контролировать блоки кода на наличие необработанных исключений, на которые в первую очередь нужно обратить внимание администратора:


int price = int.Parse(lblPrice.Text);
int count = int.Parse(txtPersonCount.Text);

try
{
double calcPrice = price / count;
}
catch (Exception ex)
{
ErrorSignal.FromContext(HttpContext.Current).Raise(new VeryImportantException(ex));
}


Теперь мы можем переловить опасное исключение и сообщить администратору на почту.


image

четверг, 30 декабря 2010 г.

JavaScript combining с помощью ScriptManager контрола

Почти любое современное web-приложение использует различные javascript-библиотеки для манипулирования данными на клиентской стороне. Будь то пользовательский файл, призванный помочь в управлении DOM-моделью на клиенте, будь то сторонняя библиотека (jQuery, Dojo, PrototypeJS и др.), будь то встроенные механизмы VisualStudio при генерации исходного html кода. Каждая библиотека – это отдельный файл на сервере, для загрузки которого браузер использует отдельный запрос. Если же на Вашей странице есть ссылки на несколько .js файлов, то загрузка по отдельности каждого файла крайне неэффективно скажется в целом на производительности web-приложения, особенно если построение DOM-модели вынуждено прервано до момента окончательной подгрузки файлов. Для пользователя данная страница будет выглядеть как частично загруженная и в подвисшем состоянии. Уж совсем некрасиво.

Допустим в нашем ASP.NET приложении мы имеем 3 различных javascript файла: jscript1.js, jscript2.js, jscript3.js, в которых нам необходимы 3 функции:

function Execute1() {
alert("Execute 1");
}

function Execute2() {
alert("Execute 2");
}

function Execute3() {
alert("Execute 3");
}


В ASP.NET для загрузки сторонних скриптов можно использовать привычный нам ScriptManager контрол.


<asp:ScriptManager runat="server">
<Scripts>
<asp:ScriptReference Path="~/Scripts/JScript1.js" />
<asp:ScriptReference Path="~/Scripts/JScript2.js" />
<asp:ScriptReference Path="~/Scripts/JScript3.js" />
</Scripts>
</asp:ScriptManager>


Хорошо. Загрузим данную страницу и проанализируем ответ сервера на запрос к нашей странице с помощью Developer Tools в IE.


image


Как видно, при запросе к серверу для получения практически ничего не делающей .aspx страницы в ответ мы получаем 7! файлов: сама .aspx страница (отмечена зеленым), 3 сгенерированные студией при компиляции проекта библиотеки и 3 пользовательских .js файла.


Если же проверить степень оптимизации нашей страницы для поисковиков с помощью утилиты YSlow (разширение для FireFox), то увидим что даже пустая страница  без контента не на 100% оптимизирована. Основная проблема – много HTTP-запросов для построения контента.


image




Мощность ScriptManager контрола в том, что множество сложных задач он позволяет делать в несколько строчек кода. Если раньше для объединения множества .js файлов необходимо было писать свои custom решения либо использовать сторонние библиотеки, такие как MbCompression, использовать сторонние модули, такие как ScriptCompressionModule и т.д., то сейчас достаточно внести необходимые для combine-инга скрипты в блок <CompositeScript> контрола:


<asp:ScriptManager runat="server">
<CompositeScript>
<Scripts>
<asp:ScriptReference Path="~/Scripts/JScript1.js" />
<asp:ScriptReference Path="~/Scripts/JScript2.js" />
<asp:ScriptReference Path="~/Scripts/JScript3.js" />
</Scripts>
</CompositeScript>
</asp:ScriptManager>


Проверим ответы сервера и качество оптимизации


image


image


Как видно количество закачанных файлов уменьшилось до 5 (3 файла были объеденены в один) и общая оценка оптимизации сменилась с B на A. А общий коэфициент оптимизации вырос с 91 до 95. Тем самым, с помощью буквально нескольких строчек мы решили довольно сложную задачу, возникающую уже в стадии SEO-оптимизации проекта – объединение множества скриптов в один.

пятница, 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


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