惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
G
Google Developers Blog
H
Help Net Security
月光博客
月光博客
阮一峰的网络日志
阮一峰的网络日志
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
P
Proofpoint News Feed
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
爱范儿
爱范儿
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 三生石上(FineUI控件)
大猫的无限游戏
大猫的无限游戏
人人都是产品经理
人人都是产品经理
GbyAI
GbyAI
D
Docker
Hugging Face - Blog
Hugging Face - Blog
I
InfoQ
博客园 - 司徒正美
Last Week in AI
Last Week in AI
Microsoft Security Blog
Microsoft Security Blog
美团技术团队
Stack Overflow Blog
Stack Overflow Blog
M
MIT News - Artificial intelligence

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Кешируем отдачу картинок в .NET MVC Core
Алексей Садомов · 2026-06-17 · via Все публикации подряд на Хабре

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

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