Содержание
HTTP-кэширование (веб-кэширование), как и кэширование данных, является способом повышения производительности приложений. При этом управление HTTP-кэшированием ресурсов осуществляется с помощью заголовков запросов. Обычно выделяют два вида http-кэша – приватный (private cache) и кэш совместного использования (shared cache). Также, в различных источниках можно встретить такие названия как клиентский кэш, шлюзовый, прокси-кэш и так далее. Но, в любом случае, все эти виды кэшей так или иначе можно свести к первым двум видам: приватный (клиентский) кэш – это кэш, который доступен конкретному пользователю и хранится на его компьютере, а кэш совместного пользования, в свою очередь, может располагаться где-либо в Сети и быть доступным многим пользователям.
Что касается непосредственно ASP.NET Core, то здесь можно выделить два механизма HTTP-кэширования – это кэширование выходных данных и кэширование ответов. Несмотря на то, что эти механизмы очень похожи между собой, всё же это два разных механизма кэширования. И в этой и следующей части мы попробуем разобраться в том, чем эти механизмы кэширования различаются.
Условия кэширования
При использовании HTTP-кэширования в ASP.NET Core можно выделить следующие условия по умолчанию, которые должны выполняться для того, чтобы данные были помещены в кэш:
- результатом запроса должен быть ответ сервера с кодом состояния 200 (OK).
- методом запроса должен быть
GET
илиHEAD
. - промежуточное программное обеспечение для кэширования должно располагаться перед промежуточным программным обеспечением, требующим кэширования.
- ответы, содержащие заголовок
Authorization
не кэшируются. - Ответы, устанавливающие файлы
cookie
, не кэшируются.
При использовании кэширования ответов (response caching) также определено следующее ограничение: директивы заголовка Cache-Control
должны быть допустимыми, а ответ должен быть помечен public
и не помечаться как private
. Но об этих директивах мы поговорим чуть ниже.
Модели управления HTTP-кэшем
Прежде, чем мы перейдем к непосредственной реализации механизмов HTTP-кэширования в нашем приложении, разберемся в общих чертах с тем, как такой вид кэширования реализуется.
Модель истечения срока
Модель истечения срока действия (Expiration model) – это достаточно простая модель, которую можно представить следующим образом. Когда пользователь пытается первый раз получить доступ к ресурсу
Наше приложение пытается найти ресурс в кэше, но так как такой ресурс ещё не сохранялся, то он запрашивается из хранилища (базы данных), ответ сервера сохраняется в кэше и отправляется пользователю. В результате пользователь получает ответ, который содержит заголовок сервера Cache-Control
с заданными директивами.
Заголовок Cache-Control
может содержать следующие директивы, управляющие кэшированием ответов
Директива | Описание |
public |
Ответ может храниться в кэше |
private |
Ответ не должен храниться в кэше совместного использования. Частный кэш может сохранять и повторно использовать ответ. |
max-age |
Задаёт максимальное время (в секундах), в течение которого ресурс будет считаться актуальным. Примеры: max-age=60 (60 секунд), max-age=2592000 (1 месяц) |
no-cache |
Указывает на необходимость отправить запрос на сервер для валидации ресурса перед использованием закешированных данных. |
no-store |
Кеш не должен хранить никакую информацию о запросе и ответе |
Например, в нашем примере это были директивы public
и max-age
, со значением 60. То есть кэш будет храниться в памяти сервера в течение 60 секунд. Если в течение жизни кэшированного ресурса этот же или другой пользователь попытаются выполнить такой же запрос к серверу, то сервер будет действовать следующим образом
На рисунке выше показан второй запрос к ресурсу, который пользователь выполнил через 15 секунд после первого запроса. В результате сервер проверяет наличие запрошенного ресурса в кэше, находит его и возвращает пользователю кэшированную версию ресурса, сопровождая ответ заголовком Age
в котором указывается время в секундах, в течение которого запись находится в кэше.
Как только срок жизни кэшированного ресурса истекает, он удаляется из хранилища и процесс начинается сначала – пользователь запрашивает ресурс, сервер достает его из базы данных, кэширует ответ и так далее.
Модель валидации
Также при работе с ресурсами может использоваться модель валидации (validation model). Эта модель немного сложнее предыдущей, но с помощью неё можно сохранить пропускную способность сервера за счёт меньшего объема передаваемых данных по сети. Когда пользователь выполняет первый запрос на получение ресурса, то сервер работает по следующей схеме
Здесь на рисунке для упрощения не показано обращение к базе данных, но смысл остается тот же, что и всегда – если ответ не находится в кэше, то сервер получает данные из хранилища и отправляет ответ серверу. Главное здесь – заголовки. Сервер отправляет пользователю заголовки ETag
и Last-Modified
. При этом заголовки могут посылаться как вместе (как на рисунке), так и по отдельности.
Заголовок ETag
является идентификатором определенной версии ресурса. Обычно, это каким-либо способом сгенерированный хэш ресурса.
Заголовок Last-Modified
определяет дату и время последнего изменения ресурса и также может использоваться для эффективного использования кэша и пропускной способности сервера.
Клиент каким-либо образом запоминает значения этих заголовков и повторный запрос ресурса будет выглядеть следующим образом
Здесь уже клиент включает в запрос новые заголовки – If-None-Match
или If-Modified-Since
.
Заголовки If-None-Match
и If-Modified-Since
являются условными. То есть сервер вернет ресурс только в том случае, если значение, переданное в этом заголовке, не соответствует значению, полученному для ресурса ранее.
Например, если клиент отправляет в заголовке If-None-Match
полученное ранее значение заголовка ETag
, то запрос можно интерпретировать так: «Дай мне ресурс Х, если его ETag отличается от моего». Если же ETag
ресурса и значение в заголовке If-None-Match
совпадает, то сервер возвращает пустой ответ с кодом статуса 304 Not Modified.
Смысл заголовка If-Modified-Since
аналогичен – сервер вернет ресурс только если время его последней модификации отличается от переданного в заголовке.
Теперь, немного разобравшись с моделями управления кэшем, можно приступить к конкретной реализации HTTP-кэширования в нашем приложении.
Кэширование ответов (response caching)
Кэширование ответов позволяет сократить количество запросов к серверу и правила его использования представлены в RFC 9111 «HTTP Caching». Как было сказано выше, управление HTTP-кэшированием осуществляется с использованием HTTP-заголовков и основной заголовок, используемый при HTTP-кэшировании – это Cache-Control
.
Для того, чтобы воспользоваться директивами заголовка Cache-Control
в ASP.NET Core Web API на основе контроллеров используется специальный атрибут [ResponseCache]
, который имеет следующее описание
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public class ResponseCacheAttribute: Attribute, IFilterFactory, IOrderedFilter { … }
То есть этот атрибут может применяться однократно как к отдельным методам, так и к классу (например, контроллера).
У ResponseCacheAttribute определены следующие важные для нас свойства
Название | Тип | Описание |
CacheProfileName |
string? |
Имя профиля кэша |
Duration |
int |
Определяет значение директивы max-age заголовка Cache-Control сервера |
Location |
ResponseCacheLocation |
Определяет расположение кэша. Может принимать три значения:
|
NoStore |
bool |
Устанавливает для заголовка Cache-Control директиву no-store |
VaryByHeader |
string? |
Определяет значение для заголовка Vary ответа |
VaryByQueryKeys |
string[]? |
Принимает список имен параметров запроса, значения которых будут добавлены к ключу объекта в кеше |
Чтобы продемонстрировать использование этого атрибута, создадим новое приложение ASP.NET Core Web API и изменим действие Get()
контроллера WeatherForecastController
следующим образом:
[HttpGet] [ResponseCache(Duration = 30)] public IEnumerable<WeatherForecast> Get() { return Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray(); }
Здесь мы применили к действию атрибут [ResponseCache]
. Теперь запустим приложение и выполним GET-запрос на получение прогноза погоды и после выполнения запроса перейдем на вкладку «Headers»
Как можно видеть по рисунку, сервер вместе в ответе клиенту отправляет также заголовок с установленными директивами. Однако, на данный момент это просто заголовок, который никак не меняет поведение нашего приложения. Сколько бы раз мы не выполняли запрос – данные о прогнозе будут формироваться каждый раз новые, а не возвращаться из кэша сервера. Чтобы кэширование заработало необходимо подключить необходимый сервис и настроить компонент middleware для кэширования ответов.
Настройка кэширования ответов
Для добавления сервиса кэширования ответов используется метод расширения IServiceCollection
AddResponseCaching()
, который имеет две версии
public static IServiceCollection AddResponseCaching (this IServiceCollection services); public static IServiceCollection AddResponseCaching (this IServiceCollection services, Action<ResponseCachingOptions> configureOptions);
Вторая версия метода принимает в качестве параметра делегат Action<ResponseCachingOptions>
для настройки кэширования. Класс ResponseCachingOptions
содержит следующие свойства
Название | Тип | Описание |
MaximumBodySize |
long |
Максимальный размер кэшируемого тела ответа в байтах. По умолчанию установлено значение 64 Мб. |
SizeLimit |
long |
Максимальный размер кэша, используемого компонентом middleware. По умолчанию установлено значение 100 Мб. При превышении этого размера ответы не будут кэшироваться до тех пор, пока не будут удалены старые записи. |
UseCaseSensitivePaths |
bool |
Указывает необходимо ли обрабатывать пути запроса с учётом регистра. По умолчанию пути обрабатываются без учёта регистра. |
Перейдем в файл Program.cs и добавим сервис кэширования ответов, используя делегат для настройки кэширования
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddResponseCaching(config => { config.SizeLimit = 1024; config.MaximumBodySize = 1024; config.UseCaseSensitivePaths = false; }); var app = builder.Build(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
Теперь нам необходимо добавить в конвейер обработки запросов компонент middleware, который и будет работать с кэшированием ответов. Для этого используется метод расширения IApplicationBuilder UseResponseCaching()
. Так как порядок расположения компонентов middleware в конвейере играет важную роль, то вызов этого метода расширения должен находиться ниже вызова UseCors()
, если в приложении используется CORS. Добавим компонент middleware для кэширования ответов в наше приложение
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddResponseCaching(config => { config.SizeLimit = 1024; config.MaximumBodySize = 1024; config.UseCaseSensitivePaths = false; }); var app = builder.Build(); app.UseHttpsRedirection(); app.UseResponseCaching(); //добавляем компонент для кэширования ответов app.UseAuthorization(); app.MapControllers(); app.Run();
Теперь наше приложение сможет кэшировать ответы. Запустим приложение и выполним запрос, представленный выше дважды. Заголовки первого ответа сервера
Заголовки второго ответа сервера
Обратите внимание на то, что сервер вернул нам ещё один заголовок – Age
. В этом заголовке передается «возраст» записи в кэше, которая была передана в ответе. То есть наличие этого заголовка говорит нам, что ресурс API был получен из кэша сервера, а значение этого заголовка сообщает нам возраст элемента.
Кэширование ответов заработало и теперь мы можем рассмотреть следующий момент – управление ключами кэша.
Управление ключами
При кэшировании ответов данные сохраняются в виде пар «ключ-значение». При этом по умолчанию в качестве первичного ключа, согласно RFC 9111 выступают:
- метод запроса
- запрашиваемый URI, включая параметры строки запроса
Например, при настройке ключей по умолчанию для следующих запросов:
- http://localhost/tasks/1?key=value
- http://localhost/tasks/1?key=value2
- http://localhost/tasks/1?key=value3
будут использоваться различные записи в кэше. При желании мы можем изменить ключ кэша. Первая возможность, которая также представлена в RFC 9111 – это использование заголовка Vary
.
В заголовке Vary
перечисляются имена заголовков, значения которых будут использоваться в качестве ключей кэша. Например, сервер может отправить следующий ответ клиенту
200 Ok Cache-Control: public, max-age=60 Vary: “User-Agent”
Этот ответ будет означать следующее – сервер вернет ответ из кэша только, если совпадет и метод запроса, и запрашиваемый URI, и значение заголовка User-Agent
. Для управления заголовком Vary
используется свойство VaryByHeader
атрибута кэширования ответов. Например, чтобы сервер отправлял ответ, как показано в листинге выше, мы можем определить следующий атрибут кэширования
[HttpGet] [ResponseCache(Duration = 30, VaryByHeader = "User-Agent")] public IEnumerable<WeatherForecast> Get()
Второй способ управления ключом кэша – это использование свойства атрибута кэширования VaryByQueryKeys
. Следует отметить, что для этого свойства в HTTP не предусмотрено отдельного заголовка. При использовании этого свойства используется компонент middleware для работы с ключами кэша.
Свойство VaryByQueryKeys
позволяет указать значения каких ключей должны участвовать в формировании ключа кэша. В качестве примера, определим заголовок кэширования ответов следующим образом
[HttpGet] [ResponseCache(Duration = 120, VaryByQueryKeys = ["key"])] public IEnumerable<WeatherForecast> Get()
Теперь попробуем выполнять следующий запрос, который добавим в http-файл
@WebApplication9_HostAddress = http://localhost:5080 @keyValue = value @cityValue = value GET {{WebApplication9_HostAddress}}/weatherforecast?key={{keyValue}}&city={{cityValue}} Accept: application/json
URI первого запроса:
https://localhost:7210/weatherforecast?key=value&city=value. Ответ сервера
На этом этапе полученный ответ записывается в кэш. Теперь изменим URL запроса следующим образом:
https://localhost:7210/weatherforecast?key=value&city=Tomsk
При настройках ключа кэширования по умолчанию, представленный URL должен привести к созданию новой записи в кэше так значение в параметре city
не совпадает со значением из предыдущего запроса. Однако, так как мы указали в атрибуте, что ключ кэша должен формироваться только по значению параметра key
, то в результате мы получим кэшированную запись
Таким образом, используя свойства VaryByHeader
и VaryByQueryKeys
мы можем управлять ключами кэша, исключая из значений ключа определенные параметры строки запроса или используя для формирования ключа кэша значения определенных заголовков.
Профили кэша
В атрибуте [ResponseCache]
можно указывать значение свойства CacheProfileName
, в котором определяется имя профиля кэша. Профили кэша настраиваются при добавлении сервиса для поддержки контроллеров следующим образом
builder.Services.AddControllers(config => { config.CacheProfiles.Add("SuperCache", new CacheProfile() { Duration = 60, Location = ResponseCacheLocation.Any, NoStore = false }); });
Здесь мы добавили в коллекцию CacheProfiles
новый профиль кэша с именем «SuperCache». Свойства класса CacheProfile
соответствуют свойствам атрибута [ResponseCache]
. Теперь мы можем использовать в атрибуте название этого профиля
[ResponseCache(CacheProfileName = "SuperCache")]
Таким образом мы можем добавить в приложение несколько профилей кэшей и использовать их для кэширования различных ответов сервера, не прибегая к настройкам каждого используемого в контроллерах API атрибута [ResponseCache]
.
Валидация кэша
Как было сказано выше, при работе с кэшем мы можем использовать модель валидации кэша, в которой используются заголовки сервера ETag
и Last-Modified
и заголовки запроса If-None-Match
и If-Modified-Since
. Ни у атрибута [ResponseCache]
, ни при настройке профилей нет каких-либо специальных свойств или методов, формирующих значения заголовков сервера.
Способ формирования значения ETag
разработчик может выбирать самостоятельно – это может быть хэш MD5, SHA256 и так далее. Чтобы продемонстрировать работу со значением заголовка ETag
доработаем код контроллера следующим образом
using System.Security.Cryptography; namespace WebApplication9.Controllers { [ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase { private static readonly string[] Summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; private readonly ILogger<WeatherForecastController> _logger; public WeatherForecastController(ILogger<WeatherForecastController> logger) { _logger = logger; } private string GetHash(object value) { string json = JsonSerializer.Serialize(value); using SHA256 hash = SHA256.Create(); return $"\"{Convert.ToHexString(hash.ComputeHash(Encoding.ASCII.GetBytes(json)))}\""; } [HttpGet] [ResponseCache(Duration = 120)] public IEnumerable<WeatherForecast> Get() { var data = Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray(); HttpContext.Response.Headers.ETag = GetHash(data); return data; } } }
Здесь мы добавили в класс контроллера новый метод GetHas()
:
private string GetHash(object value) { string json = JsonSerializer.Serialize(value); using SHA256 hash = SHA256.Create(); return $"\"{Convert.ToHexString(hash.ComputeHash(Encoding.ASCII.GetBytes(json)))}\""; }
В этом методе мы сериализуем объект value
в JSON и для полученной строки генерируем хэш SHA256. Результатом выполнения метода является строка хэша заключенная в кавычки. Внутри действия Get()
мы используем этот метод для указания в ответе значения заголовка ETag
:
HttpContext.Response.Headers.ETag = GetHash(task);
Теперь добавим в http-файл проекта новый запрос
@etag = tag GET {{WebApplication9_HostAddress}}/weatherforecast Accept: application/json If-None-Match: "{{etag}}"
Соответственно, при выполнении этого запроса, мы будем передавать в переменную etag
значение, полученное в заголовке ETag
сервера.
Запустим приложение и выполним запрос прогноза, чтобы сформировать кэш. В результате заголовки сервера будут выглядеть следующим образом
Теперь необходимо скопировать значение заголовка ETag
без кавычек и вставить его в переменную etag
нашего нового запроса после чего выполнить этот запрос. В результате мы увидим следующий ответ сервера
Так как значения заголовков ETag
и If-None-Match
совпали, сервер вернул пустой ответ с кодом состояния 304 Not Modified. Таким образом мы можем сохранить пропускную способность сервера и повысить производительность приложения, исключив отправку пользователю данных, которые не изменялись с момента последнего обращения к ним.
Итого
HTTP-кэширование (веб-кэширование), как и кэширование данных, является способом повышения производительности приложений. При этом управление HTTP-кэшированием ресурсов осуществляется с помощью заголовков запросов. Одним из способов использования HTTP-кэширования в ASP.NET Core является использования кэширования ответов, которое осуществляется с использованием атрибута ResponseCache
и сервисов кэширования ответов