Всем привет. В этой статье хочу поделиться опытом добавления клиентского кеширования картинок в 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. Это можно сделать несколькими способами:

  1. Декларативно, добавив аттрибут на действие

    // Добавляет заголовок: Cache-Control: public, max-age=60
    [ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
    public ActionResult ViewResized(int id, int maxWidth, int maxHeight)
  2. Изменив код самого действия

    public ActionResult ViewResized(int id, int maxWidth, int maxHeight)
    {
        Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
        {
            Public = true,
            MaxAge = 60
        };
        ...
    }
  3. Добавив промежуточный слой (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.

Для тестирования у нас два проблемных момента:

  1. Замокать получение времени действия кэша из бд

  2. Инициировать обработчик начала отдачи ответа с сервера

Для решения первой проблемы, нужно замокать цепочку вызовов, из которых последний вызов к тому же является методом-расширением:

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.Get(It.IsAny<string>())).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);
    }
}

Таким образом, мы добавили кэширование к действию отдачи картинок, снизили нагрузку на железо и покрыли код юнит-тестами, для обеспечения надлежащего качества и предотвращения регресса в будущем.

Комментарии (4)


  1. skovpen
    17.06.2026 08:11

    Snake Case в тестах прям глаза режет. Это же шарпы https://www.dofactory.com/csharp-coding-standards