Всем привет. В этой статье хочу поделиться опытом добавления клиентского кеширования картинок в 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); }}
Таким образом, мы добавили кэширование к действию отдачи картинок, снизили нагрузку на железо и покрыли код юнит-тестами, для обеспечения надлежащего качества и предотвращения регресса в будущем.
ссылка на оригинал статьи https://habr.com/ru/articles/1048484/