Pull to refresh

Кеширование в ASP.NET MVC

Reading time 6 min
Views 48K
В прошлом посте я рассказывал о различных стратегиях кеширования. Там была голая теория, которая и так всем известна, а кому неизвестна, тому без примеров ничего не понятно.

В этом посте я хочу показать пример кеширования в приложении 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 сделать такое кеширование очень просто, как так всего 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 кеширование к запросу количества элементов в корзине. Для этого нужно:
  1. Отдавать Last-Modified в заголовках ответа
  2. Обрабатывать If-Modified-Since в заголовках запроса (Conditional GET)
  3. Отдавать код 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-кеширования. Тогда можно будет выдерживать большие нагрузки на довольно скромном железе.
Tags:
Hubs:
+13
Comments 26
Comments Comments 26

Articles