Хотя 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
кадр, а затем эффективно вернуть содержимое файла на основе потока:
- Получить поток чтения текущего файла
fileStream
- на основе
zlib
Создайте поток преобразования, который можно динамически архивировать с помощью gzip.compressTransformer
- Пропустите эти потоки через трубу последовательно (
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На, если вы думаете, что это полезно для вас, я надеюсь, что вы можете дать мне звезду.
Я новичок, если есть какие-то упущения, дайте мне знать~