В этом посте я хочу показать пример кеширования в приложении 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 страниц в секунду. Фактически страница home\index всегда отдавалась из кеша пользователя или сервера, то есть настолько быстро, насколько возможно и тест померил быстродействие ajax запроса, получающего количество элементов в корзине.
Чтобы ускорить работу корзины надо сделать немного больше работы. Для начала результат cart.GetCount() можно сохранить в кеше asp.net, и сбрасывать кеш при изменении количества элементов в корзине. Получится в некотором роде write-through кеш.
В MVC Music Store сделать таrое кеширование очень просто, как так всего 3 экшена изменяют состояние корзины. Но в сложном случае, скорее всего, потребуется реализации publish\subscribe механизма в приложении, чтобы централизованно управлять сбросом кеша.
Метод получения количества элементов:
[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-кеширования. Тогда можно будет выдерживать большие нагрузки на довольно скромном железе.
ссылка на оригинал статьи http://habrahabr.ru/post/168869/
Добавить комментарий