Поддержка технологии HTTP/2 Server Push в Node.js +17


В июле 2017 года в Node.js 8 появилась реализация HTTP/2. С тех пор она прошла через несколько этапов улучшений, и теперь в Node.js Foundation говорят о том, что они почти готовы к тому, чтобы вывести поддержку HTTP/2 из разряда экспериментальных возможностей. Если вы хотите испытать HTTP/2 в среде Node.js, лучше всего это сделать, воспользовавшись Node.js 9 — здесь имеются все самые свежие исправления ошибок и улучшения.
image
Материал, перевод которого мы сегодня публикуем, посвящён работе с HTTP/2, и, в частности, с Server Push, в Node.js.

Основы


Для того, чтобы испытать HTTP/2, легче всего воспользоваться слоем совместимости, который является частью нового модуля ядра http2:

const http2 = require('http2');
const options = {
 key: getKeySomehow(),
 cert: getCertSomehow()
};

// Здесь необходим https, иначе браузер не сможет
// установить соединение с сервером
const server = http2.createSecureServer(options, (req, res) => {
 res.end('Hello World!');
});
server.listen(3000);

Слой совместимости предоставляет то же самое высокоуровневое API (прослушиватель запросов со знакомыми объектами request и response), которым можно воспользоваться, подключив к проекту модуль http командой require(‘http’). Это облегчает перевод существующих проектов на HTTP/2.

Слой совместимости так же даёт удобный способ перехода на HTTP/2 авторам фреймворков. Так, библиотеки Restify и Fastify уже поддерживают HTTP/2 с использованием слоя совместимости HTTP/2 Node.js.

Fastify — это новый веб-фреймворк, который ориентирован на производительность и сделан так, чтобы с ним было удобно работать программистам. Он обладает богатой экосистемой плагинов. Недавно вышла его версия 1.0.0.

Пользоваться HTTP/2 с помощью fastify довольно просто:

const Fastify = require('fastify');

// Здесь необходим https, иначе браузер не сможет
// установить соединение с сервером
const fastify = Fastify({
 http2: true
 https: {
   key: getKeySomehow(),
   cert: getCertSomehow()
 }
});

fastify.get('/fastify', async (request, reply) => {
 return 'Hello World!';
});

server.listen(3000);

В то время как возможность запустить один и тот же код приложения и поверх HTTP/1.1, и поверх HTTP/2 важна на этапе внедрения протокола, слой совместимости, сам по себе, не даёт доступа к некоторым наиболее мощным возможностям HTTP/2. Модуль ядра http2 позволяет работать с этими дополнительными возможностями через новое API ядра (Http2Stream), доступ к которому можно получить через прослушиватель «stream»:

const http2 = require('http2');
const options = {
 key: getKeySomehow(),
 cert: getCertSomehow()
};

// Здесь необходим https, иначе браузер не сможет
// установить соединение с сервером
const server = http2.createSecureServer(options);
server.on('stream', (stream, headers) => {
 // stream - это дуплексный поток
 // headers - это объект, содержащий заголовки запроса

 // команда respond отправит заголовки клиенту
 // мета-заголовки начинаются со знака двоеточия (:)
 stream.respond({ ':status': 200 });

 // тут, кроме того, доступны команды stream.respondWithFile()
 // и stream.pushStream()

 stream.end('Hello World!');
});

server.listen(3000);

В fastify получить доступ к Http2Stream можно посредством API request.raw.stream. Выглядит это так:

fastify.get('/fastify', async (request, reply) => {
 request.raw.stream.pushStream({
  ':path': '/a/resource'
 }, function (err, stream) {
  if (err) {
    request.log.warn(err);
    return
  }
  stream.respond({ ':status': 200 });
  stream.end('content');
 });

 return 'Hello World!';
});

HTTP/2 Server Push — возможности и трудности


В сравнении с HTTP/1, HTTP/2 даёт, во многих случаях, огромное улучшение производительности. Технология Server Push — это одна из возможностей HTTP/2, которая имеет к этому отношение.

Вот как, упрощённо, выглядит типичный сеанс обмена данными по протоколу HTTP.


Сеанс работы с Hacker News

  1. Браузер запрашивает у сервера HTML-документ
  2. Сервер обрабатывает запрос и отправляет браузеру документ, возможно, предварительно генерируя его.
  3. Браузер получает ответ сервера и разбирает HTML-документ.
  4. Браузер идентифицирует ресурсы, необходимые для вывода HTML-документа, такие, как таблицы стилей, изображения, JavaScript-файлы, и так далее. Затем браузер отправляет запросы для получения этих ресурсов.
  5. Сервер отвечает на каждый запрос, отправляя браузеру то, что он запросил.
  6. Браузер выводит страницу, используя код HTML-документа и связанные ресурсы.

Всё это означает, что в ходе типичного сеанса связи браузера с сервером, для вывода одного HTML-документа, браузеру требуется выполнить несколько самостоятельных запросов и дождаться ответов на них. Первый запрос загружает HTML-код, остальные — дополнительные материалы, без загрузки которых правильно вывести документ не удастся. Было бы замечательно, если бы все эти дополнительные материалы можно было бы отправить в браузер вместе с исходным HTML-документом, что избавило бы браузер от необходимости загружать их по отдельности. Собственно говоря, для организации подобных сценариев работы и предназначена технология HTTP/2 Server Push.

При использовании HTTP/2 сервер может автоматически, по своей инициативе, отправлять дополнительные ресурсы вместе с ответом на исходный запрос. Это — те ресурсы, которые, по мнению сервера, браузер обязательно запросит позже. Когда браузеру эти ресурсы понадобятся, ему, вместо отправки дополнительных запросов на их получение, достаточно воспользоваться теми данными, которые заблаговременно прислал ему сервер.

Например, предположим, что на сервере хранится файл /index.html следующего содержания:

<!DOCTYPE html>
<html>
<head>
  <title>Awesome Unicorn!</title>
  <link rel="stylesheet" type="text/css" href="/static/awesome.css">
</head>
<body>
  This is an awesome Unicorn! <img src="/static/unicorn.png">
</body>
</html>

Получив соответствующий запрос, сервер ответит на него, отправив данный файл. При этом сервер знает о том, что для правильного вывода файла /index.html нужны файлы /static/awesome.css и /static/unicorn.png. В результате сервер, пользуясь механизмом Server Push, отправит эти файлы вместе с файлом /index.html.

for (const asset of ['/static/awesome.css', '/static/unicorn.png']) {
  // stream - это ServerHttp2Stream.
  stream.pushStream({':path': asset}, (err, pushStream) => {
    if (err) throw err;
    pushStream.respondWithFile(asset);
  });
}

На стороне клиента, как только браузер разберёт код файла /index.html, он поймёт, что для визуализации этого документа нужны файлы static/awesome.css и /static/unicorn.png. Кроме того, браузеру станет ясно, что эти файлы уже отправлены ему по инициативе сервера и сохранены в браузерном кэше. В результате ему не придётся отправлять на сервер два дополнительных запроса. Вместо этого он просто возьмёт из кэша данные, которые туда уже загружены.

До сих пор всё это выглядит очень даже неплохо. Однако, если присмотреться, в вышеописанном сценарии можно обнаружить и потенциальные сложности. Для начала, серверу не так-то просто узнать о том, какие дополнительные ресурсы могут быть отправлены по его инициативе в ответ на исходный запрос браузера. Логику принятия этого решения можно вынести на уровень приложения, возложив ответственность на разработчика. Но даже разработчику сайта может быть непросто принимать подобные решения. Один из способов сделать это выглядит так: разработчик просматривает HTML-код и составляет список дополнительных ресурсов, необходимых для правильного отображения страницы в браузере. Однако, по мере развития приложения, поддерживать подобный список в актуальном состоянии трудоёмко и чревато ошибками.

Ещё одна возможная проблема кроется в том факте, что внутренние механизмы браузера занимаются кэшированием ресурсов, которые были недавно загружены. Вернёмся к вышеописанному примеру. Если, например, браузер загрузил файл /index.html вчера, то он загрузил бы и файл /static/unicorn.png, который обычно попадает в кэш браузера. Когда браузер снова загружает /index.html и после этого пытается загрузить файл /static/unicorn.png, ему известно, что этот файл уже лежит в кэше. Поэтому браузер не выполняет запрос на загрузку данного файла, вместо этого получая его из кэша. В данном случае отправка файла /static/unicorn.png браузеру по инициативе сервера окажется пустой тратой сетевых ресурсов. Серверу хорошо бы иметь какой-то механизм, позволяющий ему понять, кэшировал ли уже браузер некий ресурс.

На самом деле, с технологией Server Push связаны и другие нетривиальные задачи. Если вам это интересно — почитайте данный документ.

Автоматизация использования HTTP/2 Server Push


Для того чтобы упростить поддержку функции Server Push для Node.js-разработчиков, компания Google опубликовала npm-пакет для её автоматизации: h2-auto-push. Этот пакет разработан с целью решения многих непростых задач, среди них — те, о которых мы говорили выше, и те, которые упомянуты в этом документе.

Пакет выявляет закономерности в запросах, приходящих от браузеров, и выясняет, какие дополнительные ресурсы связаны с теми исходными ресурсами, за которыми обращаются браузеры. Позже, при запросе тех же исходных ресурсов, дополнительные ресурсы автоматически отправляются в браузер по инициативе сервера. Кроме того, пакет оценивает возможность того, что браузер уже имеет некоторые ресурсы в своём кэше, и если оказывается, что это так — не отправляет ему эти ресурсы.

Этот пакет разработан для использования в слое промежуточного ПО различных веб-фреймворков. В частности, речь идёт о средствах для обслуживания статических файлов. В результате, с использованием этого пакета, облегчается разработка вспомогательных средств для автоматизации отправки материалов браузерам по инициативе серверов. Например, взгляните на пакет fastify-auto-push. Это — плагин для fastify, рассчитанный на автоматизацию отправки материалов браузерам по инициативе серверов и использующий пакет h2-auto-push.

Это промежуточное ПО довольно просто использовать и из приложений:

const fastify = require('fastify');
const fastifyAutoPush = require('fastify-auto-push');
const fs = require('fs');
const path = require('path');
const {promisify} = require('util');

const fsReadFile = promisify(fs.readFile);

const STATIC_DIR = path.join(__dirname, 'static');
const CERTS_DIR = path.join(__dirname, 'certs');
const PORT = 8080;

async function createServerOptions() {
  const readCertFile = (filename) => {
    return fsReadFile(path.join(CERTS_DIR, filename));
  };
  const [key, cert] = await Promise.all(
      [readCertFile('server.key'), readCertFile('server.crt')]);
  return {key, cert};
}

async function main() {
  const {key, cert} = await createServerOptions();
  // Браузеры поддерживают для HTTP/2 только https.
  const app = fastify({https: {key, cert}, http2: true});

  // Создаём и регистрируем плагин AutoPush. Он должен быть зарегистрирован первым в
  // цепочке промежуточного ПО.
  app.register(fastifyAutoPush.staticServe, {root: STATIC_DIR});

  await app.listen(PORT);
  console.log(`Listening on port ${PORT}`);
}

main().catch((err) => {
  console.error(err);
});

Итоги


По результатам тестов, проведённых в Node.js Foundation, выяснено, что использование h2-auto-push улучшает производительность примерно на 12% в сравнении с использованием HTTP/2 без применения технологии Server Push, и даёт прирост производительности примерно в 135% по сравнению с HTTP/1.

Уважаемые читатели! Как вы относитесь к технологии Server Push?




К сожалению, не доступен сервер mySQL