Кешування

Response Cache: HTTP-кешування через Cache-Control

Детальний розгляд Response Caching у ASP.NET Core Minimal API: Cache-Control директиви (public, private, max-age, s-maxage, no-cache, immutable), Vary заголовок, умовні запити ETag/If-None-Match, Last-Modified/If-Modified-Since, ResponseCachingMiddleware, налаштування CDN, Cache Busting стратегії.

Response Cache: HTTP-кешування через Cache-Control

Response Cache — це кешування на рівні HTTP-протоколу. Замість збереження даних у RAM сервера чи Redis — ми додаємо до HTTP-відповіді спеціальні заголовки, і браузер або проміжний CDN (Cloudflare, CloudFront, Nginx) кешують відповідь на своєму боці.

Це означає: при cache hit запит взагалі може не дійти до вашого сервера — браузер відповідає сам. Або навіть не покидає CDN edge-server у найближчому до користувача дата-центрі. Нульове навантаження на ваш сервер.

Client                    CDN                    Server
  │──── GET /products ──────►│                      │
  │                          │── GET /products ──────►│
  │                          │◄── 200 OK (fresh) ─────│
  │◄── 200 OK (cached) ──────│   Cache-Control: max-age=3600
  │                          │
  │──── GET /products ──────►│  (1 годину потому)
  │◄── 200 OK (from cache) ──│  ← Сервер не отримав жодного запиту!
Response Cache — це стандарт HTTP/1.1 (RFC 7234), не специфіка ASP.NET Core. Розуміння цих заголовків важливе незалежно від технологічного стеку.

Cache-Control: директиви та їх значення

Cache-Control — головний HTTP-заголовок керування кешуванням. Він може містити кілька директив через кому.

Директиви видимості

Cache-Control: public

Відповідь може кешуватися будь-яким кешем: і браузером клієнта, і проміжними CDN, і корпоративними proxy. Використовуйте для публічних даних, не прив'язаних до конкретного користувача.

Cache-Control: private

Кешується тільки браузером конкретного клієнта. CDN і proxy не мають права кешувати. Використовуйте для персональних відповідей (профіль користувача, особисті налаштування). ASP.NET Core автоматично додає private якщо є заголовок Authorization або Set-Cookie.

Cache-Control: no-store

Заборона кешування взагалі де-небудь і будь-коли. Найсуворіша директива. Для дуже чутливих даних (банківські транзакції, медичні дані), що не можна зберігати навіть тимчасово.

Cache-Control: no-cache

Небезпечно імення: не означає "не кешувати". Означає: "можна кешувати, але перед використанням кешу — завжди перевіряти актуальність на сервері" (умовний запит з ETag або Last-Modified). Якщо сервер підтверджує актуальність (304 Not Modified) — браузер використовує кеш без завантаження тіла.

Директиви часу

Cache-Control: max-age=3600

Максимальний вік відповіді у секундах. Після 3600 секунд (1 година) запис вважається "stale" (застарілим). Застосовується і до браузера, і до CDN.

Cache-Control: s-maxage=86400

s-maxage ("shared maxage") — аналог max-age, але тільки для "shared caches" (CDN, proxy). Якщо обидва присутні — CDN використовує s-maxage, браузер — max-age.

Cache-Control: max-age=300, s-maxage=86400

Браузер кешує 5 хвилин, CDN — 24 години. Класична конфігурація для API: CDN обслуговує більшість запитів, браузер перевіряє відносно часто.

Cache-Control: max-age=31536000, immutable

immutable (стійкий): вміст ніколи не зміниться. Браузер не виконує умовний запит навіть після закінчення TTL. Використовується для статичних assets з хешем у URL (main.a3f9bc.js). Далеко не всі CDN підтримують.

Директиви валідації

Cache-Control: must-revalidate

Після закінчення max-age браузер зобов'язаний перевірити актуальність на сервері — не може використовувати "stale" відповідь навіть якщо сервер недоступний.

Cache-Control: stale-while-revalidate=60

Дозволяє повертати застарілу відповідь ще 60 секунд поки у фоні виконується оновлення. Покращує сприйняту швидкість: користувач отримує відповідь миттєво, кеш оновлюється паралельно.

Cache-Control: stale-if-error=86400

Якщо сервер повертає 5xx — дозволяє використовувати застарілу відповідь ще 24 години. Підвищує resilience при збоях.


ETag і умовні запити

ETag (Entity Tag) — унікальний ідентифікатор версії ресурсу. Це може бути хеш вмісту, timestamp, версійний номер — будь-що, що змінюється при зміні даних.

Сценарій умовного запиту

Перший запит:
GET /products HTTP/1.1

HTTP/1.1 200 OK
ETag: "d9a2e1b3"
Cache-Control: no-cache
Content-Length: 1024
[body: JSON з продуктами]

Другий запит (браузер перевіряє актуальність):
GET /products HTTP/1.1
If-None-Match: "d9a2e1b3"   ← браузер надсилає ETag

Відповідь якщо дані не змінились:
HTTP/1.1 304 Not Modified   ← Тіло відповіді ВІДСУТНЄ (0 байтів!)
ETag: "d9a2e1b3"
                             ← Браузер використовує кеш

Відповідь якщо дані змінились:
HTTP/1.1 200 OK
ETag: "f7c1a4d8"            ← Новий ETag
[body: оновлений JSON]

304 Not Modified — ключова відповідь: сервер підтверджує актуальність без передачі тіла. Для великих відповідей це значна економія трафіку.

Last-Modified і If-Modified-Since

Альтернатива ETag — timestamp останньої зміни:

GET /products HTTP/1.1

HTTP/1.1 200 OK
Last-Modified: Tue, 18 Mar 2024 10:00:00 GMT

GET /products HTTP/1.1
If-Modified-Since: Tue, 18 Mar 2024 10:00:00 GMT

HTTP/1.1 304 Not Modified   ← Не змінилось після 10:00

ETag точніший (може відрізнятись при однакових timestamps), Last-Modified простіший у реалізації. На практиці часто обидва разом.


ResponseCachingMiddleware в ASP.NET Core

ASP.NET Core надає вбудований middleware для Server-side кешування відповідей з підтримкою Cache-Control:

Program.cs
builder.Services.AddResponseCaching(options =>
{
    // Максимальний розмір тіла відповіді що кешується (64 МБ за замовчуванням)
    options.MaximumBodySize = 64 * 1024 * 1024; // 64 MB

    // Як трактувати URL при пошуку в кеші
    options.UseCaseSensitivePaths = false; // /Products == /products

    // Загальний розмір кешу (100 МБ за замовчуванням)
    options.SizeLimit = 100 * 1024 * 1024;
});

var app = builder.Build();

// ВАЖЛИВО: UseResponseCaching ПЕРЕД маппінгом ендпоінтів
app.UseResponseCaching();

ResponseCache атрибут і WithMetadata

Для Minimal API — параметри кешування через WithMetadata або власні методи:

Features/Products/ProductEndpoints.cs
// Варіант 1: Через WithMetadata (низькорівнево)
app.MapGet("/products", async (ProductService svc) =>
    Results.Ok(await svc.GetAllAsync()))
    .WithMetadata(new ResponseCacheAttribute
    {
        Duration = 60,           // Кешувати 60 секунд
        Location = ResponseCacheLocation.Any, // public (браузер + CDN)
        VaryByQueryKeys = ["page", "pageSize"] // Різний кеш для різних параметрів
    });

// Варіант 2: Додати заголовки вручну (більш гнучко)
app.MapGet("/products/featured", async (HttpContext ctx, ProductService svc) =>
{
    // Встановлюємо Cache-Control заголовок вручну
    ctx.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
    {
        Public = true,
        MaxAge = TimeSpan.FromHours(1),
        SharedMaxAge = TimeSpan.FromHours(24) // s-maxage для CDN
    };

    var products = await svc.GetFeaturedAsync();
    return Results.Ok(products);
});

Extension method для зручного налаштування

Extensions/ResponseCacheExtensions.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;

public static class ResponseCacheExtensions
{
    // Публічний кеш — для CDN і браузера
    public static RouteHandlerBuilder CachePublic(
        this RouteHandlerBuilder builder,
        int browserSeconds = 300,
        int cdnSeconds = 3600)
    {
        return builder.WithMetadata(new ResponseCacheAttribute
        {
            Duration = cdnSeconds,
            Location = ResponseCacheLocation.Any,
            VaryByHeader = "Accept-Encoding"
        });
    }

    // Приватний кеш — тільки браузер
    public static RouteHandlerBuilder CachePrivate(
        this RouteHandlerBuilder builder,
        int seconds = 60)
    {
        return builder.WithMetadata(new ResponseCacheAttribute
        {
            Duration = seconds,
            Location = ResponseCacheLocation.Client
        });
    }

    // Без кешу — для мутуючих ендпоінтів
    public static RouteHandlerBuilder NoCache(
        this RouteHandlerBuilder builder)
    {
        return builder.WithMetadata(new ResponseCacheAttribute
        {
            NoStore = true
        });
    }
}

Використання:

Program.cs
app.MapGet("/products", GetAll).CachePublic(browserSeconds: 60, cdnSeconds: 3600);
app.MapGet("/profile", GetProfile).CachePrivate(seconds: 60);
app.MapPost("/products", Create).NoCache();
app.MapPut("/products/{id}", Update).NoCache();

Ручне керування Cache-Control заголовками

Для повного контролю — встановлюємо заголовки вручну через HttpContext.Response:

Features/Products/ProductEndpoints.cs
app.MapGet("/products/{id:int}", async (int id, HttpContext ctx, ProductService svc) =>
{
    var product = await svc.GetByIdAsync(id);
    if (product is null) return Results.NotFound();

    // ETag на основі останньої зміни (або хешу)
    var etag = $"\"{product.UpdatedAt.Ticks}\"";
    var lastModified = product.UpdatedAt;

    // Перевіряємо умовні заголовки ПЕРЕД формуванням відповіді
    if (ctx.Request.Headers.TryGetValue("If-None-Match", out var ifNoneMatch)
        && ifNoneMatch.ToString() == etag)
    {
        // Клієнт має актуальну версію — повертаємо 304 без тіла
        return Results.StatusCode(StatusCodes.Status304NotModified);
    }

    if (ctx.Request.Headers.TryGetValue("If-Modified-Since", out var ifModifiedSince)
        && DateTimeOffset.TryParse(ifModifiedSince, out var ifModifiedDate)
        && lastModified <= ifModifiedDate)
    {
        return Results.StatusCode(StatusCodes.Status304NotModified);
    }

    // Встановлюємо Cache-Control заголовки
    ctx.Response.Headers[HeaderNames.CacheControl] =
        "public, max-age=300, s-maxage=3600, must-revalidate";
    ctx.Response.Headers[HeaderNames.ETag] = etag;
    ctx.Response.Headers[HeaderNames.LastModified] =
        lastModified.ToString("R"); // RFC 1123 format

    return Results.Ok(product);
});

Middleware для автоматичних ETag

Щоб не дублювати логіку ETag у кожному ендпоінті — можна написати middleware:

Middleware/ETagMiddleware.cs
public class ETagMiddleware
{
    private readonly RequestDelegate _next;

    public ETagMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx)
    {
        // Тільки для GET/HEAD запитів
        if (ctx.Request.Method is not (HttpMethods.Get or HttpMethods.Head))
        {
            await _next(ctx);
            return;
        }

        // Буферуємо відповідь для обчислення ETag
        var originalBody = ctx.Response.Body;
        using var buffer = new MemoryStream();
        ctx.Response.Body = buffer;

        await _next(ctx);

        // Обчислюємо ETag з тіла відповіді
        buffer.Seek(0, SeekOrigin.Begin);
        var content = await new StreamReader(buffer).ReadToEndAsync();
        buffer.Seek(0, SeekOrigin.Begin);

        if (ctx.Response.StatusCode == 200)
        {
            // MD5 або SHA1 хеш вмісту
            var hash = Convert.ToHexString(
                System.Security.Cryptography.MD5.HashData(
                    System.Text.Encoding.UTF8.GetBytes(content)));

            var etag = $"\"{hash}\"";
            ctx.Response.Headers[HeaderNames.ETag] = etag;

            // Перевіряємо If-None-Match
            if (ctx.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var ifNoneMatch)
                && ifNoneMatch.ToString() == etag)
            {
                ctx.Response.StatusCode = 304;
                ctx.Response.Headers.ContentLength = 0;
                ctx.Response.Body = originalBody;
                return; // Не пишемо тіло
            }
        }

        // Копіюємо тіло у оригінальний stream
        await buffer.CopyToAsync(originalBody);
        ctx.Response.Body = originalBody;
    }
}

Vary: різні кеші для різних клієнтів

Vary заголовок вказує: «кешувати окремо для кожного унікального значення цих заголовків запиту».

Cache-Control: public, max-age=3600
Vary: Accept-Language

Це означає: CDN зберігає окремі версії відповіді для Accept-Language: uk-UA і Accept-Language: en-US. Кожна мовна версія — свій запис кешу.

Приклади Vary заголовків
// Vary по мові — для локалізованих API
ctx.Response.Headers[HeaderNames.Vary] = "Accept-Language";

// Vary по стисненню — обов'язково якщо є gzip/br
ctx.Response.Headers[HeaderNames.Vary] = "Accept-Encoding";

// Vary по кількох заголовках
ctx.Response.Headers[HeaderNames.Vary] = "Accept-Language, Accept-Encoding";

// Vary: * — унікально для КОЖНОГО запиту (фактично вимикає CDN-кешування)
ctx.Response.Headers[HeaderNames.Vary] = "*"; // Не робіть так без причини!
Vary: Authorization означає: для кожного унікального Bearer токена — окремий запис кешу. При тисячах користувачів — тисячі записів, що ніколи не будуть перевикористані. CDN fill up — виснаження кешу CDN. Якщо треба кешувати авторизовані запити — використовуйте Output Cache з VaryByHeader("Authorization").

Cache Busting: примусове оновлення

Cache Busting — техніка гарантованого оновлення кешу клієнтів при зміні вмісту:

1. Версія у URL (для assets)

<script src="/js/app.js?v=2024031801"></script>
<link rel="stylesheet" href="/css/styles.css?v=a3f9bc12">

При кожному деплої — змінюється версія у URL → браузер робить нові запити.

Program.cs — автоматичний hash в URL
app.UseStaticFiles(new StaticFileOptions
{
    OnPrepareResponse = ctx =>
    {
        // Для файлів з хешем у назві (main.a3f9bc.js) — aggressively кешуємо
        if (ctx.File.Name.Contains('.', StringSplitOptions.RemoveEmptyEntries))
        {
            ctx.Context.Response.Headers[HeaderNames.CacheControl] =
                "public, max-age=31536000, immutable";
        }
    }
});

2. Інвалідація через CDN API

Cloudflare, CloudFront та інші CDN мають API для примусового видалення записів кешу:

Infrastructure/CloudflareCachePurge.cs
public class CloudflareCachePurge
{
    private readonly HttpClient _http;
    private readonly string _zoneId;
    private readonly string _apiToken;

    public CloudflareCachePurge(HttpClient http, IConfiguration config)
    {
        _http = http;
        _zoneId = config["Cloudflare:ZoneId"]!;
        _apiToken = config["Cloudflare:ApiToken"]!;
    }

    // Видаляємо конкретні URL з CDN кешу після оновлення даних
    public async Task PurgeUrlsAsync(IEnumerable<string> urls)
    {
        var response = await _http.PostAsJsonAsync(
            $"https://api.cloudflare.com/client/v4/zones/{_zoneId}/purge_cache",
            new { files = urls.ToArray() },
            headers: request => request.Headers.Add("Authorization", $"Bearer {_apiToken}"));

        response.EnsureSuccessStatusCode();
    }

    // Очистити весь кеш зони (обережно!)
    public async Task PurgeEverythingAsync()
    {
        await _http.PostAsJsonAsync(
            $"https://api.cloudflare.com/client/v4/zones/{_zoneId}/purge_cache",
            new { purge_everything = true });
    }
}

Використання при оновленні продукту:

Services/ProductService.cs
public async Task UpdateAsync(int id, UpdateProductRequest req)
{
    // 1. Оновлюємо БД
    var product = await _db.Products.FindAsync(id);
    product!.Name = req.Name;
    await _db.SaveChangesAsync();

    // 2. Інвалідуємо CDN кеш для змінених URL
    await _cfCache.PurgeUrlsAsync([
        $"https://api.myshop.com/products",
        $"https://api.myshop.com/products/{id}"
    ]);
}

Обмеження Response Cache

Request Caching Middleware в ASP.NET Core НЕ кешує відповіді якщо:

УмоваПричина
Запит має AuthorizationВідповідь може містити приватні дані
Відповідь має Set-CookieКожен клієнт має унікальну сесію
Метод не GET або HEADPOST, PUT, DELETE — завжди мутуючі
Статус не 200 OKПомилки не кешуються
Cache-Control: no-storeЯвна заборона
Немає Cache-ControlНевизначена поведінка = не кешуємо

Для кешування авторизованих запитів — використовуйте Output Cache (наступна стаття).


Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Додайте до GET /products заголовки Cache-Control: public, max-age=300, s-maxage=3600 та Vary: Accept-Language, Accept-Encoding. Через DevTools браузера або Httpie перевірте заголовки відповіді. Зробіть два запити підряд і переконайтесь що другий повертає 200 from disk cache у DevTools.

Завдання 1.2. Реалізуйте повноцінний ETag для GET /products/{id}: обчислюйте ETag як MD5-хеш JSON-серіалізації товару. При PUT /products/{id} — ETag змінюється автоматично. Перевірте через .http файл: після отримання ETag надішліть If-None-Match: "{etag}" і отримайте 304.

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте ETagMiddleware з наданого прикладу. Зареєструйте через app.UseMiddleware<ETagMiddleware>() тільки для /products/* шляхів (через app.Map("/products", subApp => subApp.UseMiddleware<ETagMiddleware>())). Переконайтесь що POST/PUT/DELETE не проходять через ETag middleware.

Завдання 2.2. Реалізуйте stale-while-revalidate: endpoint GET /products/catalog повертає застарілі дані в межах 60 секунд після закінчення TTL, поки у фоні (через Task.Run або Hangfire) оновлюється кеш. Заголовок: Cache-Control: public, max-age=300, stale-while-revalidate=60.

Рівень 3 — Архітектура

Завдання 3.1. Реалізуйте CloudflareCachePurge (або аналогічний для іншого CDN). При кожному PUT/DELETE /products/{id} — надсилайте запит до CDN API для видалення кешованих URL. Використайте IHttpClientFactory з retry-policy через Polly. Додайте Feature Flag: якщо CdnPurge:Enabled = false — CDN purge пропускається.

Copyright © 2026