Всем привет. В этой статье хочу поделиться опытом добавления клиентского кеширования картинок в ASP.NET MVC Core приложении. В мире SaaS экономия машинных ресурсов - актуальная задача, которая тем актуальнее, чем больше клиентов обслуживается на "единицу железа" (если можно так выразиться). Традиционно, генерация и отдача картинок на бэкенде - достаточно CPU и memory-емкие операции, и добавление клиентского кеша с помощью HTTP заголовков Cache-Control помогает снизить нагрузку на железо.
Допустим, у нас есть контроллер ImageController с действием View, которое умеет отдавать запрошенное изображение из бд, на лету изменяя его размеры, чтобы они не превышали переданных maxWidth и maxHeight:
public class ImageController : Controller
{
[HttpGet]
public ActionResult ViewResized(int id, int maxWidth, int maxHeight)
{
...
return new ImageResult { FileName = fileName, Content = memoryStream.ToArray() };
}
}(конкретную реализацию приводить не буду, т.к. статья в первую очередь о кэшировании, а не о работе с изображениями)
Мы хотим добавить клиентский кэш, добавив в ответ сервера заголовок Cache-Control. Это можно сделать несколькими способами:
Декларативно, добавив аттрибут на действие
// Добавляет заголовок: Cache-Control: public, max-age=60 [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)] public ActionResult ViewResized(int id, int maxWidth, int maxHeight)Изменив код самого действия
public ActionResult ViewResized(int id, int maxWidth, int maxHeight) { Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue { Public = true, MaxAge = 60 }; ... }Добавив промежуточный слой (middleware) для кэширования
Я не люблю смешивать функциональные и нефункциональные аспекты, поэтому буду использовать третий вариант с промежуточным слоем. Он позволит сохранить код самого действия "чистым", а также динамически изменять срок действия кэша, читая его например из бд или файла конфигурации.
Код промежуточного слоя выглядит следующим образом:
internal class ImageCacheMiddleware: MiddlewareWithService
{
private readonly RequestDelegate next;
public ImageCacheMiddleware(RequestDelegate next)
{
this.next = next;
}
public async Task Invoke(HttpContext context)
{
var settingsProvider = context.RequestServices.GetService<ISettingsProvider>();
int imageCacheIntervalInSeconds = settingsProvider.Get("ImageCacheIntervalInSeconds");
if (imageCacheIntervalInSeconds > 0)
{
context.Response.OnStarting(() =>
{
// add the header only if it hasn't been set by a controller already
if (!context.Response.Headers.ContainsKey("Cache-Control"))
{
context.Response.Headers.Append("Cache-Control", $"public, max-age={imageCacheIntervalInSeconds}");
}
return Task.CompletedTask;
});
}
await next.Invoke(context);
}
}Сначала мы получаем экземпляр ISettingsProvider (наш интерфейс, абстрагирующий работу с хранилищем настроек. Конкретная реализация зависит от специфики приложения, поэтому его реализацию я здесь не буду приводить. Как уже было сказано выше, он может читать настроки из бд, файла, переменных окружения и т.д.) и получаем значение параметра ImageCacheIntervalInSeconds. Если он больше 0, то в обработчик начала отдачи ответа (Response.OnStarting) добавляем заголовок Cache-Control со значением
public, max-age={imageCacheIntervalInSeconds}В этом примере мы используем директиву public, с которой ответ в дополнении к кэшированию на стороне браузера будет кешироваться также в промежуточных прокси и cdn. Если в вашем сценарии это не подходит, рассмотрите использование других директив (no-cache, no-store, private).
Далее нам понадобится метод расширения для добавления промежуточного слоя при старте приложения:
internal static class ImageCacheMiddlewareExtension
{
public static IApplicationBuilder UseImageCacheMiddleware(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ImageCacheMiddleware>();
}
}и собственно его добавление в Program.cs:
var builder = WebApplication.CreateBuilder(args);
...
var app = builder.Build();
...
app.UseWhen(
context => context.Request.Path.ToString().ToLower().Contains("/image/view"),
appBranch => {
appBranch.UseImageCacheMiddleware();
});Отлично, слой кэширования добавлен и работает. Можно проверить это на вкладке Network браузера - первый ответ придет с устнавленным Cache-Control, последующие запросы и ответы будут отображаться со статусом cached.
Осталось покрыть его юнит-тестами. Для этого используем NUnit и популярную открытую библиотеку для создания заглушек тестирования Moq.
Для тестирования у нас два проблемных момента:
Замокать получение времени действия кэша из бд
Инициировать обработчик начала отдачи ответа с сервера
Для решения первой проблемы, нужно замокать цепочку вызовов, из которых последний вызов к тому же является методом-расширением:
HttpContext.RequestServices.GetService<T>()Чтобы облегчить себе жизнь, немного изменим код промежуточного слоя - выделим получение экземпляра ISettingsProvider в отдельный виртуальный метод:
protected virtual T getService<T>(HttpContext ctx)
{
return ctx.RequestServices.GetService<T>();
}затем при тестировании создадим класс-наследник ImageCacheMiddlewareForTesting с переопределенной реализацией этого метода:
internal class ImageCacheMiddlewareForTesting: ImageCacheMiddleware
{
private ISettingsProvider settingsProvider;
public ImageCacheMiddlewareForTesting(RequestDelegate next, ISettingsProvider settingsProvider) : base(next)
{
this.settingsProvider = settingsProvider;
}
protected override T getService<T>(HttpContext context)
{
if (typeof(T) == typeof(ISettingsProvider))
return (T)this.settingsProvider;
throw new NotSupportedException();
}
}В конструктор этого класса будем передавать объект-заглушку (mock) с нужным нам поведением.
Для решения второй проблемы, используем средства библиотеки Moq - при добавлении делегата, запишем его в локальную переменную capturedCallback, и затем сэмулируем начало отдачи ответа прямым вызовом этого делегата. Выглядит это следующим образом:
[TestFixture]
public class TestImageCacheMiddleware
{
[Test]
public void test_WHEN_cache_lifetime_specified_THEN_it_is_added_to_headers()
{
// настройка - время кэша 1 сек
var settingsProvider = new Mock<ISettingsProvider>();
settingsProvider.Setup(x => x.Get(It.IsAny<string>())).Returns(1);
var headers = new HeaderDictionary();
var response = new Mock<HttpResponse>();
response.Setup(x => x.Headers).Returns(headers);
// записываем обработчик в локальную переменную capturedCallback
Func<Task> capturedCallback = null;
response.Setup(r => r.OnStarting(It.IsAny<Func<Task>>()))
.Callback<Func<Task>>(callback => capturedCallback = callback);
var ctx = new Mock<HttpContext>();
ctx.Setup(x => x.Response).Returns(response.Object);
var requestDelegate = new Mock<RequestDelegate>();
var middleware = new ImageCacheMiddlewareForTesting(requestDelegate.Object, settingsProvider.Object);
middleware.Invoke(ctx.Object).GetAwaiter().GetResult();
// симуляция начала отправки ответа сервером
if (capturedCallback != null)
{
capturedCallback().GetAwaiter().GetResult();
}
// проверяем, что заголовок кэширования был добавлен в заголовки ответа
ClassicAssert.AreEqual(1, headers.Count);
ClassicAssert.AreEqual("public, max-age=1", headers["Cache-Control"]);
}
[Test]
public void test_WHEN_cache_lifetime_not_specified_THEN_it_is_not_added_to_headers()
{
// настройка - время кэша не указано
var settingsProvider = new Mock<ISettingsProvider>();
settingsProvider.Setup(x => x.GetValue(It.IsAny<string>(), 0)).Returns(0);
var headers = new HeaderDictionary();
var response = new Mock<HttpResponse>();
response.Setup(x => x.Headers).Returns(headers);
// записываем обработчик в локальную переменную capturedCallback
Func<Task> capturedCallback = null;
response.Setup(r => r.OnStarting(It.IsAny<Func<Task>>()))
.Callback<Func<Task>>(callback => capturedCallback = callback);
var ctx = new Mock<HttpContext>();
ctx.Setup(x => x.Response).Returns(response.Object);
var requestDelegate = new Mock<RequestDelegate>();
var middleware = new ImageCacheMiddlewareForTesting(requestDelegate.Object, settingsProvider.Object);
middleware.Invoke(ctx.Object).GetAwaiter().GetResult();
// симуляция начала отправки ответа сервером
if (capturedCallback != null)
{
capturedCallback().GetAwaiter().GetResult();
}
// проверяем, что заголовок кэширования не был добавлен в заголовки ответа
ClassicAssert.AreEqual(0, headers.Count);
}
}Таким образом, мы добавили кэширование к действию отдачи картинок, снизили нагрузку на железо и покрыли код юнит-тестами, для обеспечения надлежащего качества и предотвращения регресса в будущем.



























