В прошлом посте я рассказывал о различных стратегиях кеширования. Там была голая теория, которая и так всем известна, а кому неизвестна, тому без примеров ничего не понятно.
В этом посте я хочу показать пример кеширования в приложении ASP.NET MVC и какие архитектурные изменения придется внести, чтобы поддерживать кеширование.
Для примера я взял приложение MVC Music Store, которое используется в разделе обучение на сайте asp.net. Приложение представляет из себя интернет-магазин, с корзиной, каталогом товаров и небольшой админкой.
Исследуем проблему
Сразу создал нагрузочный тест на одну минуту, который открывает главную страницу. Получилось 60 страниц в секунду (все тесты запускал в дебаге). Это очень мало, полез разбираться в чем проблема.
Код контроллера главной страницы:
public ActionResult Index()
{
// Get most popular albums
var albums = GetTopSellingAlbums(5);
return View(albums);
}
private List<Album> GetTopSellingAlbums(int count)
{
// Group the order details by album and return
// the albums with the highest count
return storeDB.Albums
.OrderByDescending(a => a.OrderDetails.Count())
.Take(count)
.ToList();
}
На главной странице выводится агрегирующий запрос, которому придется считать большую часть базы, чтобы вывести результат, при этом изменения на главной происходят нечасто.
При этом в каждой странице выводится персонализированная информация — количество элементов в корзине.
Код _layout.cshtml (Razor):
<div id="header">
<h1><a href="/">ASP.NET MVC MUSIC STORE</a></h1>
<ul id="navlist">
<li class="first"><a href="@Url.Content("~")" id="current">Home</a></li>
<li><a href="@Url.Content("~/Store/")">Store</a></li>
<li>@{Html.RenderAction("CartSummary", "ShoppingCart");}</li>
<li><a href="@Url.Content("~/StoreManager/")">Admin</a></li>
</ul>
</div>
Такой «паттерн» часто встречается в веб-приложениях. На главной странице, которая открывается чаще всего, выводится в одном месте статистическая информация, которая требует больших затрат на вычисление и меняется нечасто, а в другом месте — персонализированная информация, которая часто меняется. Из-за этого главная страница работает медленно, и средствами HTTP её кешировать нельзя.
Делаем приложение пригодным для кеширования
Чтобы такой ситуации, как описано выше, не происходило надо разделить запросы и собирать части страницы на клиенте. В ASP.NET MVC это сделать довольно просто.
Код _layout.cshtml (Razor):
<div id="header">
<h1><a href="/">ASP.NET MVC MUSIC STORE</a></h1>
<ul id="navlist">
<li class="first"><a href="@Url.Content("~")" id="current">Home</a></li>
<li><a href="@Url.Content("~/Store/")">Store</a></li>
<li><span id="shopping-cart"></span></li>
<li><a href="@Url.Content("~/StoreManager/")">Admin</a></li>
</ul>
</div>
<!-- skipped -->
<script>
$('#shopping-cart').load('@Url.Action("CartSummary", "ShoppingCart")');
</script>
В коде контроллера:
//[ChildActionOnly] //Убрал
[HttpGet] //Добавил
public ActionResult CartSummary()
{
var cart = ShoppingCart.GetCart(this.HttpContext);
ViewData["CartCount"] = cart.GetCount();
this.Response.Cache.SetCacheability(System.Web.HttpCacheability.NoCache); // Добавил
return PartialView("CartSummary");
}
Установка режима кеширования NoCache необходима, так как браузеры могут по умолчанию кешировать Ajax запросы.
Само по себе такое преобразование делает приложение только медленнее. По результатам теста — 52 страницы в секунду, с учетом ajax запроса для получения состояния корзины.
Разгоняем приложение
Теперь можно прикрутить lazy кеширование. Саму главную страницу можно кешировать везде и довольно долго (статистика терпит погрешности).
Для этого можно просто навесить атрибут OutputCache на метод контроллера:
[OutputCache(Location=System.Web.UI.OutputCacheLocation.Any, Duration=60)]
public ActionResult Index()
{
// skipped
}
Чтобы оно успешно работало при сжатии динамического контента необходимо в web.config добавить параметр:
<system.webServer>
<urlCompression dynamicCompressionBeforeCache="false"/>
</system.webServer>
Это необходимо чтобы сервер не отдавал заголовок Vary:*, который фактически отключает кеширование.
Нагрузочное тестирование показало результат 197 страниц в секунду. Фактически страница homeindex всегда отдавалась из кеша пользователя или сервера, то есть настолько быстро, насколько возможно и тест померил быстродействие ajax запроса, получающего количество элементов в корзине.
Чтобы ускорить работу корзины надо сделать немного больше работы. Для начала результат cart.GetCount() можно сохранить в кеше asp.net, и сбрасывать кеш при изменении количества элементов в корзине. Получится в некотором роде write-through кеш.
В MVC Music Store сделать таrое кеширование очень просто, как так всего 3 экшена изменяют состояние корзины. Но в сложном случае, скорее всего, потребуется реализации publishsubscribe механизма в приложении, чтобы централизованно управлять сбросом кеша.
Метод получения количества элементов:
[HttpGet]
public ActionResult CartSummary()
{
var cart = ShoppingCart.GetCart(this.HttpContext);
var cacheKey = "shooting-cart-" + cart.ShoppingCartId;
this.HttpContext.Cache[cacheKey] = this.HttpContext.Cache[cacheKey] ?? cart.GetCount();
ViewData["CartCount"] = this.HttpContext.Cache[cacheKey];
return PartialView("CartSummary");
}
В методы, изменяющие корзину, надо добавить две строчки:
var cacheKey = "shooting-cart-" + cart.ShoppingCartId;
this.HttpContext.Cache.Remove(cacheKey);
В итоге нагрузочный тест показывает 263 запроса в секунду. В 4 раза больше, чем первоначальный вариант.
Используем HTTP кеширование
Последний аккорд — прикручивание HTTP кеширование к запросу количества элементов в корзине. Для этого нужно:
- Отдавать Last-Modified в заголовках ответа
- Обрабатывать If-Modified-Since в заголовках запроса (Conditional GET)
- Отдавать код 304 если значение не изменилось
Начнем с конца.
Код ActionResult для ответа Not Modified:
public class NotModifiedResult: ActionResult
{
public override void ExecuteResult(ControllerContext context)
{
var response = context.HttpContext.Response;
response.StatusCode = 304;
response.StatusDescription = "Not Modified";
response.SuppressContent = true;
}
}
Добавляем обработку Conditional GET и установку Last-Modified:
[HttpGet]
public ActionResult CartSummary()
{
//Кеширование только на клиенте, обновление при каждом запросе
this.Response.Cache.SetCacheability(System.Web.HttpCacheability.Private);
this.Response.Cache.SetMaxAge(TimeSpan.Zero);
var cart = ShoppingCart.GetCart(this.HttpContext);
var cacheKey = "shooting-cart-" + cart.ShoppingCartId;
var cachedPair = (Tuple<DateTime, int>)this.HttpContext.Cache[cacheKey];
if (cachedPair != null) //Если данные есть в кеше на сервере
{
//Устанавливаем Last-Modified
this.Response.Cache.SetLastModified(cachedPair.Item1);
var lastModified = DateTime.MinValue;
//Обрабатываем Conditional Get
if (DateTime.TryParse(this.Request.Headers["If-Modified-Since"], out lastModified)
&& lastModified >= cachedPair.Item1)
{
return new NotModifiedResult();
}
ViewData["CartCount"] = cachedPair.Item2;
}
else //Если данных нет в кеше на сервере
{
//Текущее время, округленное до секунды
var now = DateTime.Now;
now = new DateTime(now.Year, now.Month, now.Day,
now.Hour, now.Minute, now.Second);
//Устанавливаем Last-Modified
this.Response.Cache.SetLastModified(now);
var count = cart.GetCount();
this.HttpContext.Cache[cacheKey] = Tuple.Create(now, count);
ViewData["CartCount"] = count;
}
return PartialView("CartSummary");
}
Конечно такой код в production писать нельзя, надо разбить на несколько функций и классов для удобства сопровождения и повторного использования.
Итоговый результат на минутном забеге — 321 страница в секунду, в 5,3 раза выше, чем в первоначальном варианте.
Залючение
В реальном проекте надо с самого начала проектировать веб-приложение с учетом кеширования, особенно HTTP-кеширования. Тогда можно будет выдерживать большие нагрузки на довольно скромном железе.
Автор: gandjustas