Практика HTTP/2 Server на основе Node.js

Node.js сервер HTTP koa

Хотя HTTP/2 постепенно используется на крупных веб-сайтах, это все еще экспериментальный API для последней версии Node.js, и нет примера приложения, которое могло бы эффективно решать различные проблемы в производственной среде. Поэтому я столкнулся со многими ямами на пути применения HTTP / 2. Ниже представлена ​​​​основная архитектура проекта, проблемы, возникающие при разработке, и решения, которые могут вас немного вдохновить.

настроить

Хотя в спецификации W3C не указано, что протокол HTTP/2 должен использовать шифрование ssl, очень мало браузеров поддерживают незашифрованный протокол HTTP/2, поэтому нам необходимо подать заявку на собственное доменное имя и ssl. сертификат.
Тестовое доменное имя этого проектаyou.keyin.me, сначала идем к провайдеру доменного имени для привязки адреса тестового сервера к этому доменному имени. Затем используйте Let's Encrypt для создания бесплатного SSL-сертификата:

sudo certbot certonly --standalone -d you.keyin.me

После ввода необходимой информации и прохождения верификации вы можете/etc/letsencrypt/live/you.keyin.me/Сгенерированный сертификат находится ниже.

Модернизация Коа

Koa — очень простая и эффективная серверная среда Node.js, мы можем просто преобразовать ее для поддержки протокола HTTP/2:

class KoaOnHttps extends Koa {
  constructor() {
    super();
  }
  get options() {
    return {
      key: fs.readFileSync(require.resolve('/etc/letsencrypt/live/you.keyin.me/privkey.pem')),
      cert: fs.readFileSync(require.resolve('/etc/letsencrypt/live/you.keyin.me/fullchain.pem'))
    };
  }
  listen(...args) {
    const server = http2.createSecureServer(this.options, this.callback());
    return server.listen(...args);
  }
  redirect(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
}

const app = new KoaOnHttps();
app.use(sslify());
//...
app.listen(443, () => {
logger.ok('app start at:', `https://you.keyin.cn`);
});

// receive all the http request, redirect them to https
app.redirect(80, () => {
logger.ok('http redirect server start at', `http://you.keyin.me`);
});

Вышеприведенный код просто генерирует сервер HTTP/2 на основе Koa и одновременно прослушивает порт 80, а также автоматически перенаправляет соединение протокола http на протокол https с помощью промежуточного программного обеспечения sslify.

ПО промежуточного слоя для статических файлов

Промежуточное программное обеспечение статических файлов в основном используется для возврата локальных статических ресурсов, на которые указывает URL-адрес. На сервере http/2 мы можем отправить js\css\font и другие ресурсы, от которых зависит страница, вместе через отправку сервера при доступе к ресурсам html. Конкретный код выглядит следующим образом:

const send = require('koa-send');
const logger = require('../util/logger');
const { push, acceptsHtml } = require('../util/helper');
const depTree = require('../util/depTree');
module.exports = (root = '') => {
  return async function serve(ctx, next) {
    let done = false;
    if (ctx.method === 'HEAD' || ctx.method === 'GET') {
      try {
        // 当希望收到html时,推送额外资源。
        if (/(\.html|\/[\w-]*)$/.test(ctx.path)) {
          depTree.currentKey = ctx.path;
          const encoding = ctx.acceptsEncodings('gzip', 'deflate', 'identity');
          // server push
          for (const file of depTree.getDep()) {
            // server push must before response!
            // https://huangxuan.me/2017/07/12/upgrading-eleme-to-pwa/#fast-skeleton-painting-with-settimeout-hack
            push(ctx.res.stream, file, encoding);
          }
        }
        done = await send(ctx, ctx.path, { root });
      } catch (err) {
        if (err.status !== 404) {
          logger.error(err);
          throw err;
        }
      }
    }
    if (!done) {
      await next();
    }
  };
};

Следует отметить, что push всегда происходит до возврата текущей страницы. В противном случае может возникнуть конкуренция между запросом сервера и запросом клиента, что снизит эффективность передачи.

Запись зависимости

Из кода промежуточного программного обеспечения статического файла мы видим, что ресурсы push сервера берутся из объекта depTree, который является инструментом записи зависимостей, который записывает текущую страницу.depTree.currentKeyПути всех зависимых статических ресурсов (js, css, img...). Конкретная реализация:

const logger = require('./logger');

const db = new Map();
let currentKey = '/';

module.exports = {
    get currentKey() {
        return currentKey;
    },
    set currentKey(key = '') {
        currentKey = this.stripDot(key);
    },
    stripDot(str) {
        if (!str) return '';
        return str.replace(/index\.html$/, '').replace(/\./g, '-');
    },
    addDep(filePath, url, key = this.currentKey) {
        if (!key) return;
        key = this.stripDot(key);
        if(!db.has(key)){
            db.set(key,new Map());
        }
        const keyDb = db.get(key);

        if (keyDb.size >= 10) {
            logger.warning('Push resource limit exceeded');
            return;
        }
        keyDb.set(filePath, url);
    },
    getDep(key = this.currentKey) {
        key = this.stripDot(key);
        const keyDb = db.get(key);
        if(keyDb == undefined) return [];
        const ret = [];
        for(const [filePath,url] of keyDb.entries()){
            ret.push({filePath,url});
        }
        return ret;
    }
};

Когда задана конкретная текущая страницаcurrentKeyПосле этого позвонитеaddDepМетод может добавлять зависимости к текущей странице, вызыватьgetDepметод для получения всех зависимостей текущей страницы.addDepЭтот метод должен быть написан в промежуточном программном обеспечении маршрутизации, чтобы отслеживать все запросы к статическим файлам, которые необходимо отправить, чтобы получить путь зависимости и записать его:

router.get(/\.(js|css)$/, async (ctx, next) => {
  let filePath = ctx.path;
  if (/\/sw-register\.js/.test(filePath)) return await next();
  filePath = path.resolve('../dist', filePath.substr(1));
  await next();
  if (ctx.status === 200 || ctx.status === 304) {
    depTree.addDep(filePath, ctx.url);
  }
});

пуш сервера

В последнем документе API Node.js кратко описан метод написания push-уведомления сервера, и реализация очень проста:

exports.push = function(stream, file) {
  if (!file || !file.filePath || !file.url) return;
  file.fd = file.fd || fs.openSync(file.filePath, 'r');
  file.headers = file.headers || getFileHeaders(file.filePath, file.fd);

  const pushHeaders = {[HTTP2_HEADER_PATH]: file.url};

  stream.pushStream(pushHeaders, (err, pushStream) => {
    if (err) {
      logger.error('server push error');
      throw err;
    }
    pushStream.respondWithFD(file.fd, file.headers);
  });
};

streamПредставляет поток ответов на текущий HTTP-запрос,fileэто объект, содержащий путь к файлуfilePathСсылка на файловый ресурсurl. использовать сначалаstream.pushStreamспособ подтолкнутьPUSH_PROMISEкадр, затем вызовите функцию обратного вызоваresponseWidthFDметод для отправки определенного содержимого файла.

Приведенное выше письмо простое и легкое для понимания, и оно может быть эффективным немедленно. В Интернете есть много статей, которые здесь не представлены. Но если вы действительно сравните такой сервер HTTP/2 с обычным сервером HTTP/1.x, вы обнаружите, что реальность не так хороша, как вы думаете.Хотя HTTP/2 теоретически может повысить эффективность передачи, HTTP/1. x передает значительно меньше данных, чем HTTP/2. В конце концов, сравнение между ними на самом деле быстрее, чем HTTP/1.x.

Почему?

Ответ заключается в сжатии ресурсов (gzip/deflate), которое легко могут использовать серверы на основе Koa.koa-compressЭто промежуточное ПО сжимает статические ресурсы, такие как текст.Однако, хотя луковая модель Koa может гарантировать, что все файловые данные, возвращаемые HTTP, проходят через это промежуточное ПО, это выходит за пределы досягаемости ресурсов, передаваемых сервером. Следствием этого является то, что ресурсы, активно запрошенные клиентом, подверглись необходимой обработке сжатия, но все ресурсы, активно запрошенные сервером, представляют собой несжатые данные. То есть, чем больше ресурсов проталкивает ваш сервер, тем больше лишнего трафика тратится впустую. Вместо этого новая функция server-push становится отрицательной оптимизацией.

Поэтому, чтобы максимально ускорить передачу данных сервера, у нас есть только вышеперечисленноеpushФайл вручную сжимается в функции. Модифицированный код выглядит следующим образом, на примере gzip.

exports.push = function(stream, file) {
  if (!file || !file.filePath || !file.url) return;
  file.fd = file.fd || fs.openSync(file.filePath, 'r');
  file.headers = file.headers || getFileHeaders(file.filePath, file.fd);

  const pushHeaders = {[HTTP2_HEADER_PATH]: file.url};

  stream.pushStream(pushHeaders, (err, pushStream) => {
    if (err) {
      logger.error('server push error');
      throw err;
    }
    if (shouldCompress()) {
      const header = Object.assign({}, file.headers);
      header['content-encoding'] = "gzip";
      delete header['content-length'];
      
      pushStream.respond(header);
      const fileStream = fs.createReadStream(null, {fd: file.fd});
      const compressTransformer = zlib.createGzip(compressOptions);
      fileStream.pipe(compressTransformer).pipe(pushStream);
    } else {
      pushStream.respondWithFD(file.fd, file.headers);
    }
  });
};

мы проходимshouldCompressФункция определяет, нужно ли сжимать текущий ресурс, а затем вызываетpushStream.response(header)Сначала вернуть текущий ресурсheaderкадр, а затем эффективно вернуть содержимое файла на основе потока:

  1. Получить поток чтения текущего файлаfileStream
  2. на основеzlibСоздайте поток преобразования, который можно динамически архивировать с помощью gzip.compressTransformer
  3. Пропустите эти потоки через трубу последовательно (pipe) в пуш-поток конечного сервераpushStreamсередина

Bug

После приведенного выше преобразования общий размер ресурсов, возвращаемый одним и тем же сервером запросов HTTP/2 и сервером HTTP/1.x, в основном одинаков. Он плавно открывается в Chrome. Однако при дальнейшем тестировании с Safari возвращается ошибка HTTP 401. Кроме того, при открытии журнала сервера можно обнаружить, что есть некоторые красные исключения.

Поразмыслив некоторое время, я, наконец, нашел проблему: поскольку push-поток, отправляемый сервером, является особым прерываемым потоком, когда клиент обнаруживает, что в настоящее время отправляемый ресурс в данный момент не нужен или имеет кэшированную версию локально, он отправит его на сервер отправитьRSTФрейм, используемый, чтобы попросить сервер прервать отправку текущего ресурса. После того, как сервер получит фрейм, он сразу отправит текущий push-поток (pushStream) установлен в выключенное состояние, однако обычные читаемые потоки не прерываются, в том числе поток чтения файла (fileStream), поэтому ошибка в журнале сервера происходит из-за этого. С другой стороны, для конкретной реализации браузера стандарт W3C строго не определяет, как клиент должен обрабатывать эту ситуацию, поэтому есть фракция Chrome, которая продолжает молча получать последующие ресурсы, и фракция Safari, которая прямо и агрессивно сообщает ошибки.

Решение очень простое, вставьте часть логики, чтобы вручную прервать читаемый поток в приведенном выше коде.

//...
fileStream.pipe(compressTransformer).pipe(pushStream);
pushStream.on('close', () => fileStream.destroy());
//...

То есть прослушайте событие закрытия потока push и вручную отмените поток чтения файла.

наконец

Этот проект был стабильно развернут на aws, и скорость бесплатного сервера относительно высока (действительно совесть). Вы можете примерно проверить:you.keyin.me. Кроме того, код этого проекта является открытым исходным кодом вGithubНа, если вы думаете, что это полезно для вас, я надеюсь, что вы можете дать мне звезду.

Я новичок, если есть какие-то упущения, дайте мне знать~