
Большая часть запросов к продукту — чтение: списки, карточки, справочники, настройки, профили. Эти ответы часто повторяются. Если научить браузеры, CDN и клиенты API не тянуть одинаковые данные по несколько раз, а сверяться с сервером «изменилось/нет», можно:
Это делается стандартными средствами HTTP. Без переписывания архитектуры и без риска потерять данные.
Клиент запрашивает ресурс. Сервер отвечает данными и метаданными о «свежести»: Cache-Control, ETag (отпечаток содержимого) или Last-Modified (время изменения). При следующем запросе клиент не скачивает заново, а шлет «условный запрос»: If-None-Match (с предыдущим ETag) или If-Modified-Since (с предыдущим временем). Если ресурс не изменился — сервер отвечает 304 Not Modified без тела. Если изменился — отдает новый контент и новые метаданные.
Главный плюс: сервер ничего «не вспоминает» про клиента — это полностью статeless-механизм. А значит — горизонтально масштабируется и дружит с CDN.
Cache-Control — управление свежестью. Частые значения:
ETag — «отпечаток» версии ресурса. Это строка в кавычках, например ""ad12…"". Бывает «сильный» (точное совпадение байт) и «слабый» (начинается с W/, разрешает мелкие отличия, например форматирование). Для API чаще используем сильный.
Last-Modified — время последней модификации. Простая альтернатива ETag, но менее точная (секундная точность, проблемы с часовыми поясами и параллельными изменениями).
Vary — список заголовков запроса, от которых зависит ответ. Например, Vary: Accept-Encoding, Authorization, Accept-Language. Если забыть Vary, кеш может смешать разные варианты ответа.
Задача: отдать список товаров, включить ETag, Cache-Control и поддержать If-None-Match.
// package.json: { "type": "module" }
import express from 'express';
import crypto from 'crypto';
const app = express();
// Пример данных (вместо БД). В реальности берите max(updated_at), версию снапшота и т.п.
let products = [
{ id: 1, name: 'Кофе', price: 490, updatedAt: '2025-01-10T10:00:00Z' },
{ id: 2, name: 'Чай', price: 290, updatedAt: '2025-01-10T10:05:00Z' },
];
// Стабильная сериализация: сортируем ключи и массив по id, чтобы хеш не скакал от порядка полей
function stableStringify(value) {
const seen = new WeakSet();
const normalize = (val) => {
if (val === null || typeof val !== 'object') return val;
if (seen.has(val)) throw new TypeError('Циклическая ссылка в данных');
seen.add(val);
if (Array.isArray(val)) return val.map(normalize);
const obj = {};
for (const key of Object.keys(val).sort()) {
obj[key] = normalize(val[key]);
}
return obj;
};
return JSON.stringify(normalize(value));
}
function computeETag(payload) {
const hash = crypto.createHash('sha256').update(payload).digest('hex');
return `"${hash}"`; // сильный ETag обязан быть в кавычках
}
app.get('/api/products', (req, res) => {
// В реальности — фильтры, пагинация и т.д. Тут фиксированный набор
const data = products.slice().sort((a, b) => a.id - b.id);
const body = stableStringify(data);
const etag = computeETag(body);
// Проверяем условный запрос
const ifNoneMatch = req.header('If-None-Match');
if (ifNoneMatch && ifNoneMatch.split(',').map(s => s.trim()).includes(etag)) {
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30');
res.setHeader('Vary', 'Accept, Accept-Encoding');
return res.status(304).end();
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=30');
res.setHeader('Vary', 'Accept, Accept-Encoding');
res.status(200).send(body);
});
// Авторизованный профиль — кеш только у пользователя (private)
app.get('/api/profile', (req, res) => {
// Для простоты считаем, что идентификатор пользователя приходит в заголовке
const userId = req.header('X-User-Id') || 'anonymous';
const profile = { userId, name: 'Иван', tariff: 'pro', updatedAt: '2025-01-10T12:00:00Z' };
const body = stableStringify(profile);
const etag = computeETag(body);
const ifNoneMatch = req.header('If-None-Match');
if (ifNoneMatch && ifNoneMatch.split(',').map(s => s.trim()).includes(etag)) {
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'private, max-age=60, must-revalidate');
res.setHeader('Vary', 'Accept, Accept-Encoding, Cookie, Authorization');
return res.status(304).end();
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'private, max-age=60, must-revalidate');
res.setHeader('Vary', 'Accept, Accept-Encoding, Cookie, Authorization');
res.status(200).send(body);
});
app.listen(3000, () => {
console.log('API listening on http://localhost:3000');
});
Проверка в консоли:
# Первый запрос — 200 OK с телом
curl -i http://localhost:3000/api/products
# Второй — с If-None-Match (вставьте ETag из ответа)
curl -i http://localhost:3000/api/products -H 'If-None-Match: "<ваш-хеш>"'
# Ответ: 304 Not Modified, без тела
Ключевые моменты:
Для списков удобна ETag-стратегия «по версии набора», а не по каждому байту. Например, берём максимальный updated_at из таблицы или версию снапшота. Ниже — минимальный пример с версией.
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"sort"
)
type Product struct {
ID int
Name string
Price int
}
// Имитация БД
var products = []Product{{1, "Кофе", 490}, {2, "Чай", 290}}
var datasetVersion = 1 // увеличиваем при изменении данных
func computeETagFromVersion(version int, ids []int) string {
// Включаем в отпечаток и версию, и состав набора
sort.Ints(ids)
payload := fmt.Sprintf("v=%d|ids=%v", version, ids)
sum := sha256.Sum256([]byte(payload))
return "\"" + hex.EncodeToString(sum[:]) + "\""
}
func productsHandler(w http.ResponseWriter, r *http.Request) {
ids := make([]int, len(products))
for i, p := range products {
ids[i] = p.ID
}
etag := computeETagFromVersion(datasetVersion, ids)
ifNoneMatch := r.Header.Get("If-None-Match")
if ifNoneMatch == etag {
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=30")
w.Header().Set("Vary", "Accept, Accept-Encoding")
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=30")
w.Header().Set("Vary", "Accept, Accept-Encoding")
// Ручная JSON-выдача (для краткости). В реальности используйте json.Marshal.
out := "[" + fmt.Sprintf("{\"id\":%d,\"name\":\"%s\",\"price\":%d}", products[0].ID, products[0].Name, products[0].Price) +
"," + fmt.Sprintf("{\"id\":%d,\"name\":\"%s\",\"price\":%d}", products[1].ID, products[1].Name, products[1].Price) + "]"
_, _ = w.Write([]byte(out))
}
func main() {
http.HandleFunc("/api/products", productsHandler)
http.ListenAndServe(":3001", nil)
}
В продакшене вместо datasetVersion повысьте точность: берите max(updated_at), COUNT(*) и checksum из БД (например, через агрегатную хеш‑функцию по ключам). Это даёт стабильный и быстрый ETag, без сериализации всего JSON.
Эти директивы работают в современных браузерах и многими CDN. На стороне сервера ничего особенного делать не нужно — просто укажите их в Cache-Control.
Проводите A/B на части трафика (например, включите ETag на 10% запросов) и сравните метрики, чтобы убедиться в выигрыше.
Итог: условные запросы — простой рычаг ускорения и экономии. Они прозрачны, не ломают бизнес‑логику и отлично масштабируются. Включите их хотя бы на нескольких «тяжелых» эндпоинтах — и вы почти наверняка увидите двузначное снижение стоимости и времени ответа.