Пошук уроків, статей та іншого контенту
Додамо стиснення HTTP-відповідей, визначимо сумісність клієнта та оцінимо вплив gzip і Brotli.
Стиснення зменшує кількість байтів, які сервер передає мережею. Це особливо корисно для:
JSON-відповідей;
HTML, CSS і JavaScript;
текстових XML, SVG та CSV;
відповідей із повторюваними структурами.
Стиснення не змінює логічний вміст відповіді. Сервер передає стиснуте представлення, а клієнт розпаковує його перед використанням.
Основний компроміс:
менше мережевого трафіку;
більше навантаження на CPU;
додаткова затримка на стиснення та розпакування.
Для маленьких відповідей стиснення часто не має сенсу: службові заголовки та витрати CPU можуть бути більшими за виграш у розмірі.
Клієнт повідомляє підтримувані алгоритми в заголовку Accept-Encoding:
Accept-Encoding: br, gzipСервер повідомляє фактично використаний алгоритм у Content-Encoding:
Content-Encoding: brНайпоширеніші значення:
br — Brotli;
gzip — gzip;
deflate — формат на основі zlib;
identity — відповідь без стиснення.
Клієнт також може вказувати пріоритети за допомогою параметра q:
Accept-Encoding: br;q=1, gzip;q=0.8, identity;q=0.1Значення q=0 означає, що алгоритм заборонений:
Accept-Encoding: gzip, br;q=0Сервер не повинен використовувати br у відповідь на такий запит.
*Зірочка означає «будь-який алгоритм, який не вказано явно»:
Accept-Encoding: gzip, *;q=0.5Якщо клієнт не передав Accept-Encoding, безпечний вибір — identity, тобто відповідь без стиснення. Сервер не повинен самостійно припускати, що клієнт підтримує певний алгоритм.
Якщо клієнт заборонив і всі підтримувані сервером алгоритми, і identity, сервер може повернути 406 Not Acceptable.
Gzip:
широко підтримується;
має помірне навантаження на CPU;
добре підходить як сумісний запасний варіант;
зазвичай ефективний для текстових даних.
У Node.js gzip доступний через модуль node:zlib:
import { createGzip } from "node:zlib";
const compressor = createGzip();Brotli часто стискає веб-контент краще за gzip, особливо HTML, CSS, JavaScript і JSON. Водночас високі рівні якості можуть вимагати значно більше CPU.
У Node.js Brotli доступний через:
import { createBrotliCompress } from "node:zlib";
const compressor = createBrotliCompress();Для відповідей, які генеруються під час запиту, зазвичай обирають помірний рівень якості. Максимальне стиснення не завжди корисне: заощаджені байти можуть не компенсувати додатковий час обчислення.
VaryЯкщо відповідь залежить від Accept-Encoding, потрібно додати:
Vary: Accept-EncodingЦе повідомляє кешам, що відповіді для клієнтів із різною підтримкою алгоритмів не є взаємозамінними.
Без Vary кеш може зберегти Brotli-відповідь, а потім передати її клієнту, який підтримує лише gzip або взагалі не підтримує стиснення.
Node.js реалізує gzip і Brotli як потокові перетворення. Це дає змогу стискати відповідь частинами, не створюючи окрему велику копію стиснутого результату в пам’яті.
Типовий потік має вигляд:
джерело даних → компресор → HTTP-відповідьДля вже підготовленого буфера можна використати Readable.from, а результат передати через pipeline.
Після стиснення зазвичай не встановлюють Content-Length, якщо довжина стиснутого потоку не відома заздалегідь. Node.js використає потокову передачу відповіді.
Наведений сервер:
визначає сумісний алгоритм;
надає Brotli перевагу перед gzip, якщо їхні q однакові;
не стискає маленькі відповіді;
додає Vary: Accept-Encoding;
використовує потокове стиснення;
повертає 406, якщо клієнт заборонив усі доступні варіанти.
import http from "node:http";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
createBrotliCompress,
createGzip,
constants as zlibConstants,
} from "node:zlib";
const PORT = 3000;
const MINIMUM_SIZE_TO_COMPRESS = 1024;
function parseAcceptEncoding(header) {
const entries = new Map();
for (const part of header.split(",")) {
const [namePart, ...parameters] = part.trim().toLowerCase().split(";");
const name = namePart.trim();
if (!name) {
continue;
}
let quality = 1;
for (const parameter of parameters) {
const [key, value] = parameter.trim().split("=");
if (key === "q") {
const parsedQuality = Number(value);
if (Number.isFinite(parsedQuality)) {
quality = Math.max(0, Math.min(1, parsedQuality));
} else {
quality = 0;
}
}
}
entries.set(name, quality);
}
return entries;
}
function getQuality(encodings, encoding) {
if (encodings.has(encoding)) {
return encodings.get(encoding);
}
if (encodings.has("*")) {
return encodings.get("*");
}
if (encoding === "identity") {
return 1;
}
return 0;
}
function chooseEncoding(header) {
// Якщо заголовка немає, не припускаємо підтримку стиснення.
if (!header) {
return "identity";
}
const encodings = parseAcceptEncoding(header);
// Порядок також визначає вибір у разі однакових значень q.
const supportedEncodings = ["br", "gzip", "identity"];
let bestEncoding = null;
let bestQuality = -1;
for (const encoding of supportedEncodings) {
const quality = getQuality(encodings, encoding);
if (quality > bestQuality) {
bestEncoding = encoding;
bestQuality = quality;
}
}
return bestQuality === 0 ? null : bestEncoding;
}
function createCompressor(encoding) {
if (encoding === "br") {
return createBrotliCompress({
params: {
// Помірна якість зменшує навантаження на CPU під час запиту.
[zlibConstants.BROTLI_PARAM_QUALITY]: 4,
},
});
}
if (encoding === "gzip") {
return createGzip();
}
return null;
}
const server = http.createServer(async (request, response) => {
const body = JSON.stringify({
message: "Відповідь сервера",
timestamp: new Date().toISOString(),
items: Array.from({ length: 200 }, (_, index) => ({
id: index + 1,
name: `Елемент ${index + 1}`,
enabled: true,
})),
});
const bodyBuffer = Buffer.from(body);
const requestedEncoding = request.headers["accept-encoding"];
const selectedEncoding = chooseEncoding(requestedEncoding);
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Vary", "Accept-Encoding");
if (selectedEncoding === null) {
response.statusCode = 406;
response.end("Жоден підтримуваний формат відповіді не дозволено");
return;
}
const shouldCompress =
bodyBuffer.length >= MINIMUM_SIZE_TO_COMPRESS &&
selectedEncoding !== "identity";
if (!shouldCompress) {
response.setHeader("Content-Length", bodyBuffer.byteLength);
response.end(bodyBuffer);
return;
}
const compressor = createCompressor(selectedEncoding);
response.setHeader("Content-Encoding", selectedEncoding);
try {
await pipeline(Readable.from(bodyBuffer), compressor, response);
} catch (error) {
// Помилка може виникнути, якщо клієнт перервав з'єднання.
if (!response.destroyed) {
response.destroy(error);
}
}
});
server.listen(PORT, () => {
console.log(`Сервер запущено на http://localhost:${PORT}`);
});Збережіть код у файлі server.mjs і запустіть:
node server.mjsПеревірити відповідь без стиснення можна так:
curl -i http://localhost:3000Перевірити вибір gzip:
curl -i -H "Accept-Encoding: gzip" http://localhost:3000Перевірити вибір Brotli:
curl -i -H "Accept-Encoding: br, gzip" http://localhost:3000У відповіді для останнього запиту очікуються заголовки:
Content-Encoding: br
Vary: Accept-EncodingКоманда curl зазвичай показує стиснуті байти як нечитабельний вивід. Для автоматичного розпакування можна використати опцію --compressed:
curl --compressed -i -H "Accept-Encoding: br, gzip" http://localhost:3000Стиснення зазвичай не застосовують до:
дуже маленьких відповідей;
зображень JPEG, PNG, WebP та інших уже стиснутих форматів;
аудіо й відео;
архівів;
даних, які вже мають Content-Encoding.
Повторне стиснення вже стиснутого формату часто майже не зменшує розмір, але витрачає CPU.
Також потрібно уникати стиснення відповідей із непередбачуваними секретними даними разом із контрольованим користувачем вмістом. Витік, пов’язаний із різницею в розмірі стиснутих відповідей, є окремим класом атак. Для звичайних публічних JSON-відповідей це зазвичай не проблема, але для автентифікованого вмісту рішення потрібно оцінювати окремо.
Порівнювати алгоритми потрібно не лише за розміром відповіді. Корисно вимірювати:
початковий розмір даних;
стиснутий розмір;
час стиснення;
використання CPU;
час до отримання першого байта;
загальний час передачі;
пропускну здатність за одночасних запитів.
Для довідки можна виміряти розмір синхронно стиснутих буферів:
import {
brotliCompressSync,
gzipSync,
constants as zlibConstants,
} from "node:zlib";
const input = Buffer.from(
JSON.stringify({
items: Array.from({ length: 10000 }, (_, index) => ({
id: index,
title: "Повторюваний текст для перевірки стиснення",
active: true,
})),
}),
);
const startedAt = process.hrtime.bigint();
const gzipResult = gzipSync(input);
const gzipTime = Number(process.hrtime.bigint() - startedAt) / 1e6;
const brotliStartedAt = process.hrtime.bigint();
const brotliResult = brotliCompressSync(input, {
params: {
// Вищі значення можуть краще стискати дані, але потребують більше CPU.
[zlibConstants.BROTLI_PARAM_QUALITY]: 4,
},
});
const brotliTime = Number(process.hrtime.bigint() - brotliStartedAt) / 1e6;
console.log(`Початковий розмір: ${input.byteLength} байт`);
console.log(
`gzip: ${gzipResult.byteLength} байт, ${gzipTime.toFixed(2)} мс`,
);
console.log(
`Brotli: ${brotliResult.byteLength} байт, ${brotliTime.toFixed(2)} мс`,
);Результат залежить від структури даних. Текст із великою кількістю повторів стискається краще, ніж випадкові або вже стиснуті байти. Тому параметри потрібно підбирати на реальних відповідях застосунку, а не на одному штучному прикладі.
Accept-EncodingНе можна просто додати Content-Encoding: br до кожної відповіді. Клієнт може не підтримувати Brotli.
Потрібно спочатку проаналізувати Accept-Encoding, а потім вибрати дозволений алгоритм.
Vary: Accept-EncodingБез цього заголовка кеш може повернути стиснуту відповідь несумісному клієнту.
Content-LengthContent-Length належить саме до переданого представлення. Розмір оригінального тіла не можна використовувати для стиснутої відповіді.
Якщо стиснення потокове і остаточна довжина невідома, Content-Length краще не встановлювати.
Для маленького тіла службові витрати gzip або Brotli можуть перевищити виграш. Використовуйте мінімальний поріг і перевіряйте його на реальному трафіку.
Максимальний рівень не є автоматично найкращим. Він може істотно збільшити CPU-час і затримку. Для динамічних відповідей зазвичай потрібен помірний рівень, а для попередньо створених статичних файлів можна дозволити дорожче стиснення під час збірки.
Не стискайте файли й відповіді, які вже стиснуті власним форматом. Це рідко дає помітне зменшення і створює зайве навантаження.
Алгоритм стиснення обирають за заголовком Accept-Encoding.
Використаний алгоритм вказують у Content-Encoding.
Brotli часто дає менший розмір для веб-тексту, а gzip забезпечує ширшу сумісність.
q визначає пріоритети клієнта, а q=0 забороняє алгоритм.
Для відповідей, що залежать від Accept-Encoding, потрібен Vary: Accept-Encoding.
Маленькі та вже стиснуті дані зазвичай не варто стискати.
Оцінювати потрібно не лише розмір, а й CPU, затримку та пропускну здатність.