Кешируем отдачу картинок в .NET MVC Core

от автора

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1048484/