Files
progcode/web/scripts/upgrade-2019-05.mjs
T
huncode 368fa96733
Build and deploy / deploy (push) Successful in 14s
correct HTTP cache validator examples
2026-07-31 10:35:30 +03:00

366 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two sources are required');
}
return {
...meta,
contentHtml: [
bodyParts.join('\n'),
heading('Проверяемые источники'),
sourceList(sources),
].join('\n'),
};
}
const rfcFreshness = {
title: 'RFC 7234, раздел 4.2: freshness',
url: 'https://www.rfc-editor.org/rfc/rfc7234#section-4.2',
note: 'возраст ответа, срок свежести и правило, по которому кэш решает, можно ли использовать сохранённый ответ без повторного запроса',
};
const rfcCacheControl = {
title: 'RFC 7234, раздел 5.2: Cache-Control',
url: 'https://www.rfc-editor.org/rfc/rfc7234#section-5.2',
note: 'семантика max-age, s-maxage, private, no-cache и no-store для HTTP/1.1-кэшей',
};
const rfcValidation = {
title: 'RFC 7234, раздел 4.3: validation',
url: 'https://www.rfc-editor.org/rfc/rfc7234#section-4.3',
note: 'условные запросы, ETag, If-None-Match и ответ 304 как повторная проверка сохранённого представления',
};
const rfcEntityTag = {
title: 'RFC 7232, раздел 2.3: ETag',
url: 'https://www.rfc-editor.org/rfc/rfc7232#section-2.3',
note: 'синтаксис entity-tag и связь валидатора с конкретным представлением ресурса',
};
const rfcVary = {
title: 'RFC 7234, раздел 4.1: Vary',
url: 'https://www.rfc-editor.org/rfc/rfc7234#section-4.1',
note: 'как кэш выбирает подходящую сохранённую вариацию ответа по полям запроса',
};
const mdnCaching = {
title: 'MDN: HTTP caching',
url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching',
note: 'практическое объяснение свежести, повторной проверки, ETag и разницы между no-cache и no-store',
};
const mdnVary = {
title: 'MDN: Vary header',
url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary',
note: 'Vary как перечисление полей запроса, которые повлияли на представление ресурса',
};
const nginxHeaders = {
title: 'nginx: ngx_http_headers_module',
url: 'https://nginx.org/en/docs/http/ngx_http_headers_module.html',
note: 'границы директив add_header и expires в конфигурации nginx',
};
const practiceArticle = createRevision(
{
slug: 'editorial-2019-05-practice-http-caching',
title: 'HTTP Cache-Control: как перестать отдавать устаревшие данные',
categories: ['HTTP', 'Производительность', 'Практика'],
cover: '/assets/editorial/2019/http-cache-response-path-2019.svg',
excerpt: 'Пользователь видит старую цену или старый интерфейс после релиза. Разбираем не один TTL, а контракт ресурса: URL, свежесть, повторную проверку и способ измерить результат.',
readingMinutes: 11,
},
[
paragraph('После релиза пользователь открывает ту же карточку товара и видит вчерашнюю цену. Команда меняет число в <code>Cache-Control</code>, но часть браузеров продолжает получать старый HTML, а новый JavaScript уже ждёт другие данные. Цена ошибки — не только лишний запрос: пользователь принимает решение по неверному состоянию, а разработчик не может сказать, какой слой сохранил ответ.'),
paragraph('В этой заметке разберём один рабочий вопрос: как назначить кэширование для HTML, версионированных файлов и короткоживущего API-ответа так, чтобы договор можно было проверить заголовками. Это учебный пример HTTP/1.1. Он не заменяет правила конкретного CDN, балансировщика или фреймворка: их настройки нужно сверять отдельно, потому что они могут изменить путь ответа до браузера.'),
heading('Сначала фиксируем ресурс, а не число секунд'),
paragraph('У выражения «поставим час кэша» нет смысла без ресурса. HTML по постоянному URL обычно должен быстро перепроверяться: именно он ссылается на новую версию скрипта и стиля. Файл <code>app.4f91.js</code> можно хранить долго, если изменение содержимого создаёт новый URL. Ответ <code>/api/catalog?category=12</code> может быть коротко свежим, но только если его тело не зависит от авторизации, языка или другого неучтённого входа.'),
paragraph('RFC 7234 разделяет свежесть и повторную проверку. Пока сохранённый ответ свежий, кэш может использовать его без обращения к origin. Когда срок истёк, это ещё не означает обязательную загрузку всего тела: валидатор может привести к условному запросу и ответу <code>304 Not Modified</code>. Поэтому первый вопрос к заголовку — не «быстро ли он работает», а «какой старый ответ допустим для этого URL и при каком условии».'),
dataTable(
['Тип ответа', 'Стабильный URL', 'Практический договор', 'Что проверяем'],
[
['HTML документа', 'Да', '<code>no-cache</code> плюс валидатор, если документ не персонализирован', 'После изменения сервер получает условный запрос или отдаёт новый HTML'],
['Файл с хешем в имени', 'Нет: URL меняется с содержимым', '<code>public, max-age=31536000</code>', 'Новый релиз ссылается на новый URL, старый URL может жить отдельно'],
['Общий краткий API-ответ', 'Да', 'Небольшой <code>max-age</code>; общий кэш только при понятном ключе', 'Два одинаковых запроса дают ожидаемую свежесть и не смешивают варианты'],
['Персональные данные', 'Да', '<code>private</code> или <code>no-store</code> по риску хранения', 'Ответ одного пользователя не может стать общим ответом для другого'],
],
),
paragraph('Таблица не является готовым набором заголовков для любого сайта. У неё другая цель: перед настройкой выписать свойства ответа. Если HTML содержит имя пользователя или корзину, пример для публичного HTML неприменим. Если asset не имеет fingerprint в имени, годовой <code>max-age</code> создаёт ровно ту проблему, которую команда пытается убрать.'),
heading('Три контракта вместо одного общего правила'),
paragraph('Первый контракт — документ. Для общего HTML полезно разрешить хранение, но требовать повторную проверку перед использованием. Директива <code>no-cache</code> не означает «ничего не хранить»: она требует проверять сохранённый ответ перед повторным использованием. Это даёт браузеру шанс получить <code>304</code> вместо повторной передачи всего документа. Если документ персональный, к этому контракту добавляется <code>private</code>; для данных, которые нельзя хранить вообще, нужен более строгий <code>no-store</code>.'),
paragraph('Второй контракт — asset с версией в URL. Здесь cache-busting делается не очисткой кэша, а сменой адреса: содержимое меняется — сборка создаёт новый хеш — HTML начинает ссылаться на новый путь. Длинный срок живёт безопасно только потому, что новый байтовый состав не маскируется старым ключом. Третий контракт — API: значение TTL должно следовать из допустимой давности данных, а не из желания уменьшить нагрузку любой ценой.'),
codeBlock([
'# nginx: общий HTML, который можно хранить, но надо валидировать',
'location = /catalog {',
' add_header Cache-Control "no-cache, public";',
'}',
'',
'# nginx: имя файла меняется вместе с содержимым сборки',
'location /assets/ {',
' add_header Cache-Control "public, max-age=31536000";',
'}',
]),
paragraph('Этот фрагмент показывает форму контракта, а не полный production-конфиг. В реальном nginx нужно проверить наследование <code>add_header</code>, обработку ошибок, существующие заголовки приложения и путь, в котором CDN читает ответ origin. Документация nginx описывает директиву, но не знает, какие именно URL вашего приложения персонализированы или как сборщик формирует имена файлов.'),
figure(
'/assets/editorial/2019/http-cache-response-path-2019.svg',
'Схема пути ответа: браузер получает HTML с требованием повторной проверки, затем загружает версионированный JavaScript по новому URL; для API отдельно указан короткий срок свежести и валидатор.',
'Кэш — не один переключатель. Сначала определяется ключ и допустимая давность ответа, затем выбирается путь: повторная проверка, новый URL или запрет общего хранения.',
),
heading('Проверяем заголовки до изменения конфигурации'),
paragraph('Проверка начинается с одного URL и одного ожидаемого контракта. Не очищаем кэш браузера первым действием: это стирает след, который нужно объяснить. Сохраняем статус, <code>Cache-Control</code>, <code>ETag</code>, <code>Last-Modified</code>, <code>Age</code> при наличии и значения <code>Vary</code>. Затем повторяем запрос с условным заголовком. Если сервер всегда отдаёт полное тело, причина может быть в отсутствии валидатора, в неправильном URL или в том, что промежуточный слой не передаёт условный запрос.'),
codeBlock([
'# Сначала сохранить заголовки обычного ответа.',
'curl -sS -D - -o /dev/null https://example.test/catalog',
'',
'# Затем подставить значение ETag из первого ответа.',
"curl -sS -D - -o /dev/null -H 'If-None-Match: \"catalog-v42\"' https://example.test/catalog",
]),
paragraph('Команда выше не доказывает, что ваш CDN использует те же правила, что и браузер. Она делает границу наблюдаемой: на origin или на тестовом домене можно увидеть, поддерживает ли представление условный запрос. Для CDN нужен второй контролируемый путь с той же конфигурацией кэширования. Сравнивать нужно не только код 200 или 304, но и ключевые заголовки на каждом слое.'),
heading('ETag нужен для повторной проверки, а не как украшение'),
paragraph('Когда срок свежести закончился, браузер может отправить <code>If-None-Match</code> со значением предыдущего <code>ETag</code>. Если представление не изменилось, origin отвечает <code>304</code>, и сохранённое тело остаётся полезным. Если изменилось — отвечает <code>200</code> с новым телом и новым валидатором. Такой путь полезен для HTML с постоянным URL: пользователь получает актуальную ссылку на assets, но сеть не передаёт документ повторно, когда он не менялся.'),
paragraph('Не стоит подменять эту механику словом «инвалидация». RFC не обещает, что все кэши исчезнут одновременно после деплоя. Версионированный URL делает старое содержимое отдельным ресурсом; короткий TTL ограничивает допустимую давность; валидатор проверяет конкретное сохранённое представление. Это три разных инструмента. Смешать их в один «кэш выключен» — значит потерять возможность объяснить поведение.'),
heading('Маршрут изменения без слепой очистки'),
orderedList([
'Выберите один URL и запишите, какую давность данных пользователь может увидеть без ошибки.',
'Определите, меняется ли URL вместе с байтовым содержимым. Если нет, не выдавайте долгий <code>max-age</code> за безопасный вариант.',
'Снимите заголовки origin и публичного адреса; отдельно сохраните <code>Cache-Control</code>, валидаторы, <code>Vary</code> и <code>Age</code>.',
'Сделайте условный запрос с прежним ETag и зафиксируйте, когда ожидается <code>304</code>, а когда новый <code>200</code>.',
'После одного изменения повторите те же запросы. Проверяйте HTML, asset и API раздельно: общий зелёный экран не доказывает их контракт.',
]),
heading('Границы решения'),
paragraph('Этот рецепт не обещает немедленную видимость релиза во всех промежуточных кэшах. Поставщик CDN может иметь собственный TTL, собственный cache key или правило, которое обходит заголовок origin. Браузер может использовать навигационную историю иначе, чем обычный reload. Поэтому результатом работы должна быть не фраза «кэш настроен», а короткая карточка: URL, вариант запроса, заголовки, допустимая давность и команда повторной проверки.'),
paragraph('Для автора 2019 года это естественный следующий шаг после Webpack: сборщик уже умеет менять имя asset, теперь нужно связать это с HTTP-ответом и увидеть границу между браузером, origin и общим кэшем. Если в вашем проекте проблема не в свежести, а в разных языках или пользователях по одному URL, сначала разберите ключ варианта — одной настройкой TTL её не исправить.'),
heading('Что унести в проект'),
bulletList([
'Длинный TTL безопасен только для ресурса, чей URL меняется вместе с содержимым.',
'<code>no-cache</code> разрешает хранение, но требует повторной проверки; это не синоним <code>no-store</code>.',
'Заголовок без снимка ответа не является доказательством: храните запрос, статус и ключевые поля ответа.',
'HTML, asset и API требуют разных контрактов, даже если проходят через один домен.',
]),
],
[rfcFreshness, rfcCacheControl, rfcValidation, rfcEntityTag, mdnCaching, nginxHeaders],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2019-05-mechanism-http-caching',
title: 'HTTP-кэш: почему один Cache-Control не управляет всем маршрутом',
categories: ['HTTP', 'Архитектура', 'Разбор'],
cover: '/assets/editorial/2019/http-cache-key-2019.svg',
excerpt: 'Ответ с правильным TTL всё равно может быть неверным: кэш хранит представление по ключу, а browser, proxy и CDN читают его на разных границах. Разбираем свежесть, варианты и повторную проверку.',
readingMinutes: 12,
},
[
paragraph('Разработчик видит <code>Cache-Control: max-age=60</code> и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут.'),
paragraph('Разберём механизм на одном вопросе: что именно кэш считает «тем же ответом» и почему срок свежести не заменяет ключ и валидатор. Мы не будем назначать поведение конкретному CDN без его конфигурации. Вместо этого соберём модель HTTP: запрос выбирает представление, кэш оценивает его свежесть, а после истечения срока при необходимости валидирует сохранённую версию у origin.'),
heading('Кэш хранит представление, а не просто URL'),
paragraph('URL — начало ключа, но не всегда конец. Если origin отдаёт русский и английский HTML по одному адресу в зависимости от <code>Accept-Language</code>, для кэша это два представления одного ресурса. Заголовок <code>Vary: Accept-Language</code> говорит, что это поле запроса повлияло на содержимое. При выборе сохранённого ответа кэш должен сопоставить значения перечисленных полей с новым запросом.'),
paragraph('Из этого следует практическая проверка: прежде чем увеличивать TTL, перечислите входы, которые меняют тело. Язык, формат, мобильная версия, авторизация, эксперимент и cookie — это не общая «динамика», а конкретные ветви генерации. Если один из входов влияет на HTML, а cache key его не различает, кэш может честно отдать свежий, но чужой вариант. TTL не исправляет такую ошибку; он только ограничивает, как долго она видна.'),
dataTable(
['Вход, меняющий тело', 'Какой договор нужен', 'Опасность неверной настройки', 'Наблюдаемая проверка'],
[
['URL и query', 'Единый канонический порядок параметров и документированный ключ', 'Один смысл разрастается в много ключей или разные смыслы становятся одним ключом', 'Сравнить заголовки и тело для двух URL'],
['<code>Accept-Language</code>', '<code>Vary: Accept-Language</code> при реально разном представлении', 'Русский текст попадает в английский запрос', 'Отправить два запроса с разным языком и сверить Vary'],
['Cookie/авторизация', '<code>private</code> либо явное отделение общего ответа от личного', 'Данные одного пользователя попадают в общий кэш', 'Проверить, не меняется ли тело при двух безопасных тестовых сессиях'],
['Версия asset в имени', 'Новый URL при новом содержимом', 'Длинный TTL держит старый JavaScript по постоянному адресу', 'Сравнить HTML релиза и список URL assets'],
],
),
paragraph('RFC 7234 отдельно описывает <code>Vary</code>, но не превращает его в универсальный флаг для каждого управляемого кэша. У CDN могут быть правила, которые определяют cache key заранее или исключают часть ответов. Поэтому <code>Vary</code> — договор HTTP между origin и совместимым кэшем; проверка реального edge-слоя всё равно входит в выпуск. В статье мы не маскируем этот пробел словом «автоматически».'),
heading('Свежий, устаревший и проверенный — разные состояния'),
paragraph('После сохранения ответа кэш вычисляет его текущий возраст и сравнивает с lifetime. Свежий ответ может быть использован сразу. Устаревший ответ не становится мусором: кэш может отправить условный запрос к origin, передав ETag или дату, и получить подтверждение <code>304 Not Modified</code>. В этом случае тело не скачивается повторно, но новый ответ подтверждает, что сохранённое представление ещё соответствует origin.'),
paragraph('Директивы <code>max-age</code> и <code>s-maxage</code> задают разные границы. Первая влияет на кэши в целом; <code>s-maxage</code> имеет специальное значение для shared cache и может переопределять <code>max-age</code> для него. Это полезно, когда браузер не должен долго хранить ответ, а общий кэш может уменьшить нагрузку origin чуть дольше. Но смысл появляется только после проверки, действительно ли ответ общий и не содержит персональных ветвей.'),
codeBlock([
'HTTP/1.1 200 OK',
'Content-Type: application/json',
'Cache-Control: public, max-age=30, s-maxage=120',
'ETag: "catalog-202-17"',
'Vary: Accept-Language',
'',
'{"items":[{"id":42,"name":"..."}]}',
]),
paragraph('Этот ответ допустим лишь при конкретных условиях: каталог одинаков для всех пользователей с одним языковым вариантом, а 30 секунд допустимой локальной давности названы бизнесом или продуктом. Если цена зависит от пользователя, промокода или сессии, <code>public</code> в примере становится неправильным. Код не заменяет анализ входов; он фиксирует решение после анализа.'),
heading('no-cache, no-store и private отвечают на разные риски'),
paragraph('Три часто смешиваемые директивы нужны для разных ситуаций. <code>no-cache</code> позволяет сохранить ответ, но требует успешной проверки до повторного использования. <code>no-store</code> запрещает сохранять ответ и его части; он нужен, когда сам факт хранения опасен, а не когда хочется «быстро обновлять страницу». <code>private</code> ограничивает повторное использование shared cache, но не делает страницу автоматически безопасной для всех скриптов, логов и истории браузера.'),
paragraph('Выбор должен начинаться с риска. Для обычной публичной статьи полезна повторная проверка: она поддерживает свежесть без полной передачи тела. Для персонального баланса общий кэш недопустим; команда оценивает, достаточно ли <code>private</code>, или ответ вообще нельзя сохранять. Для версионированного bundle риском является не персонализация, а постоянный URL, поэтому решением будет новый ключ ресурса, а не <code>no-store</code>.'),
figure(
'/assets/editorial/2019/http-cache-key-2019.svg',
'Схема выбора HTTP-кэша: URL и Vary формируют ключ варианта, затем кэш проверяет свежесть; при истечении срока условный запрос с ETag приводит к 304 или к новому 200.',
'Диагностика начинается с ключа. Только после этого TTL и валидатор имеют понятный эффект.',
),
heading('Где искать расхождение между слоями'),
paragraph('У одного ответа может быть несколько наблюдаемых точек: приложение, origin-прокси, CDN и браузер. Нельзя склеивать их в один «сервер». В минимальном журнале укажите время, URL, заголовок запроса, статус, <code>Cache-Control</code>, <code>ETag</code>, <code>Vary</code> и <code>Age</code>, если слой его отдал. Затем выполните тот же сценарий напрямую к origin на тестовом адресе и через публичный путь. Разница показывает, где контракт перестал совпадать с ожиданием.'),
codeBlock([
'# Запросить один и тот же ресурс в двух вариантах языка.',
'curl -sS -D /tmp/cache-ru.headers -o /tmp/cache-ru.body -H "Accept-Language: ru" https://example.test/catalog',
'curl -sS -D /tmp/cache-en.headers -o /tmp/cache-en.body -H "Accept-Language: en" https://example.test/catalog',
'',
'# Сначала сравнить Vary, Cache-Control и ETag, затем уже тела.',
]),
paragraph('Команды не являются измерением для этой статьи: их нужно запускать на своём тестовом домене, без личных токенов в истории shell. Их польза в другом — они делают явным вход, который раньше был скрыт в браузере. Если два языка дают разное тело и отсутствует ожидаемый <code>Vary</code>, остановитесь здесь. Если тело одинаково, не добавляйте <code>Vary</code> «на всякий случай»: лишний вариант дробит кэш и усложняет проверку.'),
heading('Последовательность проверки механизма'),
orderedList([
'Для одного URL выпишите все входы, от которых действительно меняется тело ответа.',
'Сравните два безопасных варианта запроса и сохраните заголовки вместе с телом или его хешем.',
'Проверьте, что <code>Vary</code> совпадает с реальными различиями и не содержит случайных полей.',
'Определите допустимую давность отдельно для browser cache и shared cache; только затем назначайте <code>max-age</code> и <code>s-maxage</code>.',
'Добавьте валидатор, если постоянный URL должен быстро подтверждать свежесть после истечения срока.',
'Повторите сценарий через каждый слой, который реально выдаёт ответ пользователю.',
]),
heading('Границы модели'),
paragraph('HTTP-модель не описывает правила очистки конкретного поставщика CDN, режим offline браузера или историю навигации. Она также не говорит, что ETag должен быть криптографическим хешем: важно, чтобы валидатор корректно отличал представления в выбранном договоре. Если у приложения есть персонализация, эксперименты или геозависимые цены, понадобится отдельная карта вариантов и, возможно, отказ от общего кэша для части URL.'),
paragraph('Главная привычка автора на этом этапе — перестать считать заголовок красивой строкой конфигурации. <code>Cache-Control</code> отвечает на вопрос о повторном использовании, <code>Vary</code> — о соответствии варианта запросу, ETag — о повторной проверке. Когда каждый ответ получает короткую карту этих трёх ролей, дебаг перестаёт начинаться с глобальной очистки CDN.'),
heading('Короткий вывод'),
bulletList([
'TTL ограничивает давность сохранённого варианта, но не создаёт правильный ключ.',
'Если тело зависит от заголовка запроса, это зависимость нужно проверить как часть cache key, а не описать общим словом «динамика».',
'<code>no-cache</code>, <code>no-store</code> и <code>private</code> выбираются по разным рискам хранения и повторного использования.',
'Проверка должна сравнивать origin и публичный путь, иначе слой с расхождением останется невидимым.',
]),
],
[rfcFreshness, rfcCacheControl, rfcValidation, rfcVary, mdnCaching, mdnVary],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2019-05-field-http-caching',
title: 'CDN отдаёт старый язык: как проверить cache key, Vary и ETag',
categories: ['HTTP', 'CDN', 'Диагностика'],
cover: '/assets/editorial/2019/http-cache-variant-check-2019.svg',
excerpt: 'Один URL отдаёт разные языки, а пользователь получает вчерашний или чужой вариант. Собираем контролируемый сценарий: два запроса, заголовки, ключ варианта и повторная проверка.',
readingMinutes: 12,
},
[
paragraph('На тестовом домене карточка по адресу <code>/catalog</code> должна отвечать на русском и английском в зависимости от <code>Accept-Language</code>. После включения общего кэша часть английских запросов получает русский HTML, хотя origin формирует правильный язык. Цена ошибки — не косметика: пользователь видит чужой интерфейс, а команда может ошибочно списать проблему на перевод или браузер вместо того, чтобы проверить ключ сохранённого ответа.'),
paragraph('Ниже полевой сценарий для одной ветки: один URL, два языка, доступ к безопасному тестовому origin и к публичному адресу через CDN. Он не предполагает, что любой CDN автоматически уважает каждый <code>Vary</code>. Сначала докажем, что origin различает варианты и объявляет это в HTTP, затем проверим тот же договор на публичном пути. Никаких личных cookies и настоящих пользовательских страниц в команду не подставляем.'),
heading('Определяем, что считается разным представлением'),
paragraph('Один URL может иметь несколько представлений. В нашем примере тело меняется из-за <code>Accept-Language</code>: меняются заголовок, подписи и ссылка на локализованный asset. Это не вопрос вкуса, а вход генерации ответа. RFC описывает <code>Vary</code> как список полей запроса, которые повлияли на выбранное представление. Поэтому origin обязан не только вернуть русский текст, но и объявить кэшу, что язык участвовал в выборе.'),
paragraph('Не добавляйте <code>Vary: Cookie</code> по инерции. Если cookie содержит идентификатор сессии, такой ключ может раздробить общий кэш на множество значений и всё равно не решить персональные данные. Сначала ответьте, почему тело меняется. Для личной страницы правильный маршрут часто начинается с <code>private</code>, а для публичной локализации — с ограниченного и проверяемого <code>Vary: Accept-Language</code>.'),
dataTable(
['Сценарий', 'Ожидаемый заголовок', 'Что считаем ошибкой', 'Следующее действие'],
[
['Публичный русский и английский HTML', '<code>Vary: Accept-Language</code>', 'Тела различаются, а Vary отсутствует или не совпадает', 'Исправить origin и повторить два запроса'],
['Одинаковый ответ независимо от языка', 'Vary не требуется только ради предположения', 'Добавлен лишний Vary без отличий тела', 'Убрать лишний вариант и измерить ключ заново'],
['Страница с пользователем', '<code>private</code> или более строгий запрет хранения', 'Общий кэш способен использовать ответ другой сессии', 'Отделить публичный shell от личных данных'],
['Вариант устарел после срока', 'ETag и условный запрос при выбранном договоре', 'Сервер всегда отдаёт тело, хотя представление не менялось', 'Проверить генерацию валидатора и путь до origin'],
],
),
heading('Собираем контрольный запрос к origin'),
paragraph('Начинаем не с интерфейса, а с заголовков. Два запроса должны отличаться ровно одним входом — языком. Сохраняем заголовки и тело раздельно, чтобы не перепутать вывод curl с данными. В боевом окружении тестовый origin должен быть доступен безопасным способом: отдельный host, allowlist или стенд. Не обходите аутентификацию и не добавляйте служебные адреса в публичные примеры.'),
codeBlock([
'# Русский вариант.',
'curl -sS -D /tmp/catalog-ru.headers -o /tmp/catalog-ru.html -H "Accept-Language: ru" https://origin.example.test/catalog',
'',
'# Английский вариант: меняется только один вход.',
'curl -sS -D /tmp/catalog-en.headers -o /tmp/catalog-en.html -H "Accept-Language: en" https://origin.example.test/catalog',
'',
'grep -Ei "^(cache-control|vary|etag|last-modified):" /tmp/catalog-ru.headers',
'grep -Ei "^(cache-control|vary|etag|last-modified):" /tmp/catalog-en.headers',
]),
paragraph('Ожидаем не конкретный текст ETag, а форму договора. В обоих ответах должно быть одинаковое правило кэширования, если срок свежести одинаков. <code>Vary</code> должен перечислять <code>Accept-Language</code>, если тела различаются по этому полю. ETag может различаться, потому что представления различаются. Если origin уже возвращает неверные заголовки, CDN пока не трогаем: сначала исправляем источник, иначе edge-диагностика будет смешивать две ошибки.'),
figure(
'/assets/editorial/2019/http-cache-variant-check-2019.svg',
'Вертикальная схема проверки двух языковых вариантов: origin формирует русский и английский ответы с Vary, CDN хранит отдельные ключи, затем условный запрос с ETag подтверждает или обновляет вариант.',
'Один URL не равен одному телу. В сценарии решающим входом является язык, поэтому его надо увидеть в Vary и в поведении реального кэша.',
),
heading('Повторяем сценарий через публичный путь'),
paragraph('Когда origin прошёл проверку, повторяем те же два запроса через публичный адрес. Менять одновременно URL, язык, User-Agent и cookie нельзя: тогда результат невозможно объяснить. Сначала сравниваем headers с origin: они могут дополняться, но <code>Cache-Control</code>, <code>Vary</code> и валидатор не должны потерять смысл. Затем сравниваем тела или их безопасные хеши. Если публичный путь отдаёт один вариант на два языка, фиксируем это как расхождение cache key, а не как «кэш иногда глючит».'),
codeBlock([
'for lang in ru en; do',
' curl -sS -D "/tmp/edge-$lang.headers" -o "/tmp/edge-$lang.html" -H "Accept-Language: $lang" https://www.example.test/catalog',
'done',
'',
'sha256sum /tmp/edge-ru.html /tmp/edge-en.html',
'grep -Ei "^(cache-control|vary|etag|age):" /tmp/edge-ru.headers',
'grep -Ei "^(cache-control|vary|etag|age):" /tmp/edge-en.headers',
]),
paragraph('Фрагмент предназначен для shell с безопасным доменом; он не является выполненным измерением этой статьи. Важна последовательность: сначала подтверждаем два варианта, потом смотрим, что edge не склеил их в один. Поле <code>Age</code>, если его отдаёт слой, помогает понять, что ответ уже жил в кэше, но его отсутствие не доказывает отсутствие кэширования. Конкретные диагностические заголовки CDN не универсальны и должны быть описаны его документацией.'),
heading('Проверяем повторную проверку после истечения свежести'),
paragraph('Второй тип сбоя выглядит иначе: варианты различаются правильно, но после изменения перевода один из них долго остаётся старым. Здесь проверяем валидатор. Сохраняем ETag русского варианта, ждём или на тестовом стенде настраиваем короткий срок свежести, затем посылаем <code>If-None-Match</code> для того же языка. Если тело не менялось, допустим <code>304</code>; если менялось — ожидаем новый <code>200</code> и новый ETag. Нельзя проверять русский валидатор английским запросом: это уже другой вариант.'),
codeBlock([
'# Значение взять из ответа русского варианта, не подставлять личные токены.',
"curl -sS -D - -o /dev/null -H \"Accept-Language: ru\" -H 'If-None-Match: \"catalog-ru-v18\"' https://www.example.test/catalog",
]),
paragraph('Если ответ всегда 200, это не повод отключить кэш. Сначала выясняем, меняется ли ETag на каждом запросе из-за времени, случайного идентификатора или неустойчивого порядка данных. Валидатор должен описывать представление, а не шум вокруг него. Если сервер всегда 304 после реального изменения текста, наоборот, валидатор слишком грубый. Оба случая проверяются на маленьком контролируемом изменении, а не на общей очистке всей зоны.'),
heading('Матрица решения по наблюдению'),
paragraph('Результат сценария удобно зафиксировать как четыре короткие строки: вариант запроса, URL, digest тела и заголовки. В локализации это даёт картину, которую можно показать владельцу CDN или backend: вот два запроса, вот origin, вот edge, вот поле, исчезнувшее на переходе. Такой артефакт ценнее скриншота, потому что его можно повторить после правки правила.'),
dataTable(
['Наблюдение после двух запросов', 'Граница, где искать', 'Безопасная следующая проверка'],
[
['Origin возвращает один и тот же язык', 'Шаблон/роутинг origin', 'Проверить, доходит ли Accept-Language до приложения'],
['Origin различает, но не ставит Vary', 'HTTP-ответ приложения или proxy', 'Добавить Vary для реального входа и снова снять заголовки'],
['Origin корректен, edge склеивает варианты', 'Настройка CDN/cache key', 'Сверить правило провайдера с входом языка на тестовом URL'],
['Варианты разделены, но новый текст не приходит после срока', 'ETag, TTL или маршрут условного запроса', 'Проверить один вариант с If-None-Match'],
],
),
heading('Порядок внедрения'),
orderedList([
'Выберите публичный тестовый URL без личных данных и один вход, который точно меняет тело.',
'Снимите два ответа origin, сохранив тело и ключевые заголовки по отдельности.',
'Проверьте соответствие между различием тела и <code>Vary</code>; не расширяйте key произвольными полями.',
'Повторите сценарий через CDN с теми же двумя запросами и зафиксируйте расхождение.',
'Проверьте один язык условным запросом после выбранного срока свежести.',
'После правки оставьте команды и ожидаемые признаки в репозитории или runbook, чтобы следующий релиз не начинал диагностику заново.',
]),
heading('Ограничения и следующий шаг'),
paragraph('Сценарий не проверяет все возможные варианты: мобильный рендер, эксперимент, гео и авторизацию нужно добавлять отдельно только если они действительно меняют тело. Он также не разрешает кешировать персональный HTML. Его задача уже: показать, что правильный перевод на origin не равен правильному cache key на edge.'),
paragraph('После этой проверки можно переходить к настройке TTL и purge-процесса конкретного провайдера, но только с зафиксированным ключом. Если команда не может назвать, какие поля запроса создают разные представления, сначала вернитесь к шаблону и данным. Для автора 2019 года это развитие от ручного curl-диагноза к границе между HTTP-договором и инфраструктурой доставки, без притворной уверенности, что один заголовок управляет всей сетью.'),
heading('Короткий вывод'),
bulletList([
'Два языка по одному URL требуют проверяемого различия вариантов, а не только двух правильных HTML на origin.',
'<code>Vary</code> отражает входы, которые изменили представление; он не заменяет проверку реальной настройки CDN.',
'ETag проверяется внутри одного варианта запроса, иначе 304 и 200 нельзя интерпретировать.',
'Небольшой повторяемый сценарий с headers и телом быстрее локализует сбой, чем очистка всего кэша.',
]),
],
[rfcCacheControl, rfcValidation, rfcEntityTag, rfcVary, mdnCaching, mdnVary],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
}