HTTP/2 Server Push не так прост, как я думал
- среда, 21 июня 2017 г. в 03:14:43
Фото найдено на просторах Википедии
Привет! Меня зовут Макс Матюхин, я работаю PHP-программистом в Badoo. Мы постоянно изучаем различные возможности по ускорению работы нашего приложения и самыми интересными находками, конечно, делимся в нашем блоге на Хабре.
Вторая версия протокола HTTP обещает нам много улучшений, и одной из любопытных особенностей HTTP/2 является поддержка push. Теоретически эта возможность позволяет ускорить загрузку приложения. Недавно Jake Archibald написал большую статью, в которой проанализировал особенности реализации push в различных браузерах, и оказалось, что таких особенностей довольно много.
Мы уже публиковали пост, описывающий базовый функционал HTTP/2 Server Push, а этот будет хорошим дополнением, рассказывающим, как в реальности обстоят дела с поддержкой HTTP/2 Server Push в различных браузерах.
Я много раз слышал фразу «HTTP/2 Server Push справится с этим», когда дело касалось проблем с загрузкой страницы, но я мало что понимал в этой теме и потому решил разобраться подробнее.
HTTP/2 Server Push оказалась более сложной и низкоуровневой, чем я думал, но по-настоящему меня удивило то, насколько отличается её взаимодействие с различными браузерами – я-то всегда считал, что это полностью отработанная фича, готовая к использованию в production.
Я не хочу сказать, что HTTP/2 Server Push – это бесполезная ерунда; думаю, это действительно мощный инструмент, который со временем станет лучше. Но я уже не считаю его «серебряной пулей из золотого ружья».
Между вашей страницей и целевым сервером есть целая серия кешей и функций, которые могут перехватить запрос:
Указанная выше модель напоминает блок-схемы, которые используются для описания Git или её характерных признаков. Те, кто знаком с темой, убеждаются в правильности своих знаний, остальные – только пугаются. Прошу прощения, если это так, и надеюсь, следующие разделы помогут вам разобраться.
Страница: Привет, example.com, могу я получить твою домашнюю страницу? 10:24
Сервер: Конечно! О, пока она отправляется, вот ещё таблица стилей, изображения, JavaScript и JSON. 10:24
Страница: Вау, класс! 10:24
Страница: Я читаю HTML, и, похоже, мне понадобится таблица сти… О, ты уже отправил мне её, круто! 10:25
Отвечая на запрос, сервер может включить дополнительные ресурсы, такие как заголовки запросов, которые браузер сможет сопоставить позже. Они находятся в кеше до тех пор, пока браузер не запросит ресурс, соответствующий описанию.
Повышение производительности происходит благодаря тому, что ресурсы отправляются ещё до того, как браузер запросит их. Теоретически это означает, что страница должна загружаться быстрее.
Вот практически всё, что я знал об HTTP/2 Server Push. Звучало это довольно легко, но на деле всё оказалось совсем не так просто…
HTTP/2 Server Push – это низкоуровневая сетевая фича: всё, что использует сетевой стек, может использовать и её.
Но любая фича полезна, если она непротиворечива и предсказуема. Я протестировал HTTP/2 Server Push по этим показателям, запушив ресурсы и попробовав их собрать с помощью:
fetch()
XMLHttpRequest
<link rel="stylesheet" href="…">
<script src="…">
<iframe src="…">
Также я ограничил скорость загрузки body у ресурсов, которые уже были запушены, чтобы узнать, смогут ли браузеры сопоставить ресурсы, которые ещё не запушились. Небольшой набор тестов доступен на GitHub.
Edge при использовании fetch (), XMLHttpRequest или <iframe>
не извлекал элемент из push-кеша (описание проблемы).
Safari повёл себя странным образом. Невозможно предугадать, будет он использовать push-кеш или нет. Safari полагается на сетевой стек OS X, чей исходный код закрыт, но я полагаю, что некоторые ошибки относятся именно к Safari. Похоже, он открывает слишком много подключений, по которым в конечном итоге распределяются запушенные ресурсы. Это значит, что вы получаете попадание в кеш только в том случае, если запросу посчастливилось использовать то же самое соединение (это за гранью моего понимания (описание проблемы)).
Все браузеры (кроме Safari, когда он чудит) будут использовать совпадающие запушенные элементы, даже если они еще в процессе скачивания, и это очень хорошо.
К сожалению, только Chrome поддерживает инструменты разработчика, благодаря которым на сетевой панели можно узнать, какие элементы были извлечены из push-кеша.
Если браузер не будет извлекать элемент из push-кеша, то скорость будет меньше, чем если бы push вообще не применялся.
Поддержка технологии в Edge оставляет желать лучшего, но зато мы точно знаем, какие методы не работают с push-кешем. Вы можете определять User-Agent-клиент и пушить лишь те ресурсы, для которых он точно поддерживает push-кеш.
Поведение Safari не является детерминированным, так что вряд ли вы сможете покрыть его костылями. Вы можете просто отключить HTTP/2 Server Push для пользователей Safari на основе User-Agent.
При использовании HTTP-кеша элемент должен иметь что-то вроде max-age, чтобы браузер мог использовать его без повторной проверки на сервере (пост о кешировании заголовков). Для использования HTTP/2 Server Push этого не требуется – при сопоставлении элементов их «свежесть» не проверяется.
Все браузеры ведут себя одинаково.
Производительность некоторых одностраничных приложений может страдать из-за того, что их рендеринг блокируется не только JS, но и некоторыми данными (JSON, например), которые JS начинает извлекать сразу же по мере своего выполнения. Лучшее решение – это рендеринг на сервере. Однако если это невозможно, вы можете пушить JS и JSON вместе со страницей.
Учитывая вышеупомянутые проблемы Edge и Safari, встраивание JSON является более надёжным решением.
Запушенные элементы привязаны к HTTP/2-соединению, то есть браузер будет использовать их только в том случае, если до этого не будет получен ответ от кеша изображений, кеша предварительной загрузки, кеша сервис-воркера и HTTP-кеша.
Все браузеры ведут себя одинаково.
Для сведения: допустим, в HTTP-кеше у вас есть элемент, который является «новым» в соответствии с его max-age, и вы пушите более новый элемент. Тогда последний будет проигнорирован в пользу первого (если только API по какой-либо причине не обходит HTTP-кеш). Просто знайте об этом.
Знание того, что кешированные элементы связаны с HTTP-соединением, помогло мне понять другие поведенческие особенности, с которыми я столкнулся. Например…
Push-кеш связан с HTTP/2-соединением, поэтому вы потеряете его, если соединение разорвётся. Это происходит даже в том случае, если запушенный ресурс активно кешируется.
Push-кеш находится за пределами HTTP-кеша, поэтому элементы не входят в HTTP-кеш до тех пор, пока браузер их не запросит. В этот момент они извлекаются из push-кеша и через HTTP-кеш, сервис-воркер и т. д. передаются на страницу.
Если у пользователя нестабильное соединение, то вы можете успешно что-либо протолкнуть, но соединение будет прервано ещё до того, как страница получит данные. Придётся установить новое соединение и перезагрузить ресурс.
Все браузеры ведут себя одинаково.
Не следует полагаться на то, что элементы будут долго находиться в кеше. Push лучше всего использовать для срочных вещей, таких, где между пушем и использованием не проходит много времени.
Каждое соединение имеет собственный push-кеш. Но поскольку одно соединение может использоваться несколькими страницами, они могут и совместно использовать один push-кеш.
На практике это означает, что, если вы пушите ресурс вместе с ответом, относящимся к навигации (например, HTML-страница), то он будет доступен не только для этой страницы (я буду использовать термин «страницы» на протяжении всего поста, но в действительности под ним имеются в виду и другие контексты, которые могут извлекать ресурсы, например, воркеры).
Похоже, что Edge для каждой вкладки использует новое соединение (описание проблемы).
Safari безо всякой необходимости создаёт несколько подключений к одному и тому же источнику. И я почти уверен, что в этом заключается причина его странного поведения (описание проблемы).
Имейте это в виду, когда пушите вместе со страницей такие вещи, как JSON-данные – нельзя полагаться на то, что их поймает та же страница.
Но такое поведение может обернуться преимуществом, поскольку ресурсы, которые вы используете вместе со страницей, могут быть подхвачены запросами, которые сделаны устанавливающимся сервис-воркером.
Edge ведёт себя неоптимально, но беспокоиться не о чем. Вот если бы в нём была поддержка сервис-воркера, тогда это могло бы стать проблемой.
И снова я бы не советовал пушить ресурсы пользователям Safari.
Термин «данные для авторизации» ещё не раз появится в этом посте. Под ним понимаются отправляемые браузером данные, используемые для идентификации конкретного пользователя. Обычно это файлы cookie, но также это могут быть идентификаторы Basic HTTP-аутентификации и идентификаторы уровня подключения, такие как клиентские сертификаты.
Если сравнить HTTP/2-соединение с телефонным звонком, то, как только вы представитесь, звонок уже не будет анонимным, и это касается в том числе того, что вы сказали в трубку до того, как назвали себя. В целях конфиденциальности для «анонимных» запросов браузер устанавливает отдельный «вызов».
Однако из-за того, что push-кеш связан с соединением, при выполнении запросов без учётных данных (non-credentialed request) вы можете потерять закешированные элементы. Например, если вы пушите ресурс вместе со страницей (запрос с учётными данными), а затем извлекаете его (fetch ()) (без учётных данных), то будет установлено новое соединение, а протолкнутый элемент будет потерян.
Если cross-origin-таблица стилей (с учётными данными) пушит некий шрифт, то, когда браузер запросит его (без учётных данных), шрифт будет потерян в push-кеше.
Убедитесь, что ваши запросы используют один и тот же способ авторизации. В большинстве случаев это означает, что запросы содержат учётные данные, так как запрос страницы всегда выполняется с авторизационными данными.
Чтобы получить учётные данные, используйте:
fetch(url, {credentials: include});
Вы не можете добавить данные для авторизации в запрос шрифта, использующий несколько источников, но вы можете удалить их из таблицы стилей:
<link rel="stylesheet" href="…" crossorigin>
Это означает, что запрос как таблицы стилей, так и шрифта пойдёт по тому же подключению. Но если таблица стилей также применяет фоновые изображения, то такие запросы всегда идут с данными для авторизации, так что в результате вы можете получить ещё одно соединение. Единственным решением проблемы является применение сервис-воркера, который может конфигурировать каждый запрос.
Разработчики утверждают, что запросы без учётных данных повышают производительность, так как не нужно отправлять cookie. Однако стоит сопоставить их с гораздо более высокими затратами на установление нового подключения. Кроме того, HTTP/2 Sever Push может сжимать заголовки, повторяющиеся у разных запросов, поэтому cookie, на самом деле, не являются проблемой.
Edge – единственный браузер, который не следует правилам. Он позволяет совместное использование подключения как содержащими, так и не содержащими данные для авторизации запросами. Здесь я пропустил обычный ряд иконок браузеров, так как хотел, чтобы спецификации были изменены.
Если страница делает к своему источнику запрос без данных для авторизации, то вряд ли имеет смысл устанавливать отдельное соединение. Запрос инициирован ресурсом, содержащим авторизационные данные, которые могут быть добавлены в «анонимный» запрос посредством URL (это называется ambient authority).
Я не совсем уверен насчёт других случаев, но, если вы делаете к одному и тому же серверу запросы, содержащие и не содержащие авторизационные данные, то из-за «отпечатков пальцев» браузеров анонимность теряется. Если вы хотите изучить этот вопрос более подробно, прочитайте дискуссию на GitHub, список рассылки Mozilla и сообщение о баге в Firefox.
Если браузер использовал хранящийся в push-кеше элемент, то после этого последний удаляется из кеша. Он может оказаться в кеше HTTP (в зависимости от кеширования заголовков), но в push-кеше его больше не будет.
Здесь Safari страдает от состояния гонки. Если ресурс извлекается несколько раз, пока он пушится, то он несколько раз получит и запушенный элемент (описание проблемы). Если же ресурс извлекается дважды после того, как пуш закончился, тогда он ведёт себя корректно: в первый раз будет возвращён из push-кеша, а во второй – не будет.
Если вы решили протолкнуть что-то пользователям Safari, то будьте готовы к ошибке, когда пушите no-cache-ресурсы (например, JSON-данные). Вместе с ответом возможна передача случайного ID. И, если вы дважды получили тот же ID, знайте, что произошла ошибка. В этом случае подождите секунду, а затем повторите попытку.
В любом случае используйте заголовки кеширования или сервис-воркер для кеширования запушенных ресурсов после их извлечения, если кеширование нежелательно (например, одноразовые выборки JSON).
Когда вы пушите контент, вы делаете это без особого взаимодействия с клиентом, а значит, можете протолкнуть то, что уже есть у браузера в одном из его кешей. Спецификация HTTP/2 позволяет браузеру прерывать входящий поток, используя код CANCEL
или REFUSED_STREAM
, чтобы избежать потери пропускной способности.
Спецификация здесь не является строгой, поэтому мои суждения основаны на том, что полезно для разработчиков.
Chrome отклоняет push, если элемент уже содержится в push-кеше. Отклонение выполняется с помощью PROTOCOL_ERROR
, а не CANCEL
или REFUSED_STREAM
, однако это не так важно (описание проблемы). К сожалению, он не отклоняет элементы, которые уже содержатся в кеше HTTP. Похоже, сейчас эта проблема почти решена, но я не смог в этом убедиться (описание проблемы).
Safari отклоняет push, если элемент уже содержится в push-кеше, но только если он является «новым» в соответствии с заголовками в кеше (например, max-age), пока пользователь не обновит страницу. Этим Safari отличается от Chrome, но не думаю, что это «неправильно». К сожалению, как и Chrome, он не отклоняет элементы, которые уже есть в кеше HTTP (описание проблемы).
Firefox отменяет push, если элемент уже содержится в push-кеше, но затем он удаляет этот элемент и ничего не оставляет! Это делает его ненадёжным и усложняет его защиту (описание проблемы). Как и вышеупомянутые браузеры, Firefox не отклоняет элементы, которые уже содержатся в кеше HTTP (описание проблемы).
Edge не отклоняет push, если элемент уже содержится в push-кеше, но делает это, если элемент находится в кеше HTTP.
К сожалению, даже при безупречной поддержке браузера пропускная способность и серверные ресурсы ввода-вывода будут тратиться впустую, прежде чем вы получите сообщение об отмене. Решением проблемы может быть Cache Digest: расширение подсказывает серверу, что он уже закешировал.
Кроме того, с помощью cookie вы можете отследить, протолкнуты ли уже пользователю кешируемые ресурсы. Однако элементы могут исчезнуть из HTTP-кеша по прихоти браузера, тогда как cookie сохранятся. Так что наличие cookie вовсе не означает, что элементы по-прежнему содержатся в кеше пользователя.
Мы уже видели, что «новизна» не учитывается, когда дело касается сопоставления элементов в push-кеше (так сопоставляются элементы no-store и no-cache), и вместо неё необходимо использовать другие механизмы сопоставления. Я тестировал запросы POST и Vary: Cookie.
Обновление: спецификация говорит о том, что push-запросы «ДОЛЖНЫ быть кешируемыми, ДОЛЖНЫ быть безопасными и НЕ ДОЛЖНЫ содержать тело запроса». Сначала я пропустил эти определения. Запросы POST не подпадают под определение «безопасный», так что браузеры должны их отклонять.
Chrome принимает POST push-потоки, но не использует их (описание проблемы). Также он игнорирует заголовок Vary при сопоставлении загруженных элементов (описание проблемы), хотя проблема говорит о том, что он работает при использовании QUIC.
Firefox отклоняет POST push-потоки, но тоже игнорирует заголовок Vary при сопоставлении запушенных элементов (описание проблемы).
Edge также отклоняет POST push-потоки и игнорирует заголовок Vary (описание проблемы).
Safari, как и Chrome, принимает POST push-потоки, но не использует их (описание проблемы). Однако это единственный браузер, который подчиняется заголовку Vary.
Мне жаль, что ни один браузер, кроме Safari, не использует заголовок Vary для запушенных элементов. Это значит, что может возникнуть такая ситуация: вы пушите JSON, предназначенный для одного пользователя, затем этот пользователь выходит из системы и вместо него заходит другой, но вы всё же получаете push-JSON для предыдущего пользователя, если он ещё не был получен.
Если вы пушите данные, предназначенные для одного пользователя, то в ответе используйте ID ожидаемого пользователя. Если он не совпадает с ожидаемым, сделайте запрос ещё раз (пока запушенный элемент не исчезнет).
В Chrome вы можете использовать прозрачный заголовок данных сайта (Clear-Site-Data header), когда пользователь выходит из системы. Это позволяет очищать элементы из push-кеша, завершая HTTP/2-соединение.
Как владельцы developers.google.com/web мы могли бы заставить наш сервер пушить ответ, содержащий всё что угодно, для android.com и настроить его кеширование в течение года. Простого извлечения было бы достаточно, чтобы поместить его в кеш HTTP. Тогда, если бы наши пользователи зашли на сайт android.com, они бы увидели NATIVE SUX – PWA RULEZ
, написанное большим розовым Сomic Sans или любым другим шрифтом по нашему выбору.
Конечно, мы бы этого никогда не сделали – мы любим Android. Я просто хочу сказать… Android: свяжешься с Сетью, мы тебя достанем.
Ладно-ладно, шучу, но вышеуказанный способ действительно работает. Вы не можете пушить ресурсы любым источникам, но вы можете использовать их для источников, для которых ваше соединение является «авторитетным».
Если вы заглянете в сертификат developers.google.com, то увидите, что он является авторитетным для всех источников Google, в том числе для android.com.
Что ж, я немного приукрасил, потому что, когда мы обратимся к android.com, он найдёт DNS и увидит, что тот ведёт на IP-адрес, отличающийся от адреса developers.google.com, так что будет установлено новое соединение, а наш элемент будет потерян в push-кеше.
Но это можно решить с помощью ORIGIN frame, позволяющего подключению, пока оно является авторитетным, сообщать: «Эй, если вам что-то нужно от android.com, просто спросите у меня. Не надо обращаться к DNS». Эта функция полезна для общего объединения подключений, но она довольно новая и поддерживается только в Firefox Nightly.
Если вы используете CDN или какой-либо общий хост, то загляните в сертификат и определите, какие источники могли бы пушить контент вашему сайту. Это немного пугает. К счастью, ни один хост (по крайней мере, из известных мне) не предоставляет полный контроль над HTTP/2 Server Push. Хотя вряд ли причиной тому является следующая небольшая заметка в спецификации:
Если несколько арендаторов (tenant) используют пространство на одном и том же сервере, этот сервер ДОЛЖЕН исключить возможность использования ресурсов, на которые у арендаторов нет полномочий (спецификация HTTP/2).
Это соглашение должно исполняться.
Chrome позволяет сайтам пушить ресурсы от источников, для которых у него есть полномочия. Он будет использовать соединение повторно, если другой источник будет находиться на одном и том же IP-адресе, поэтому будут использоваться запушенные элементы. Chrome пока не поддерживает ORIGIN frame.
Safari позволяет сайтам пушить ресурсы от источников, для которых у него есть полномочия, но для других источников он устанавливает новое соединение, из-за чего запушенные элементы не используются. Safari также не поддерживает ORIGIN frame.
Firefox отклоняет push от других источников. Подобно Safari, для других источников он устанавливает новые подключения. Однако я обошёл предупреждение сертификата, так что не уверен в полученных результатах. Firefox Nightly поддерживает ORIGIN frame.
Edge также отклоняет push от других источников. И снова я обошёл предупреждение сертификата, так что результат может отличаться от реального. Edge не поддерживает ORIGIN frame.
Если вы используете несколько источников на одной странице, которая в конечном итоге использует один и тот же сервер, то загляните в ORIGIN frame. Благодаря его поддержке устраняется необходимость поиска DNS, что повышает производительность.
Если вы считаете, что push с использованием нескольких источников приносит вам пользу, то напишите тесты лучше моих и убедитесь, как браузеры на самом деле используют то, что вы пушите. В противном случае анализируйте User-Agent и используйте push только с определёнными браузерами.
Вместо того чтобы пушить ресурсы, вы можете попросить браузер предзагрузить их с помощью HTML:
<link
rel="preload"
href="https://fonts.example.com/font.woff2"
as="font"
crossorigin
type="font/woff2"
>
Или заголовка страницы:
Link: <https://fonts.example.com/font.woff2>; rel=preload; as=font; crossorigin; type=font/woff2
• href
– это URL для предварительной загрузки.
as
– место назначения ответа: браузер может устанавливать правильные заголовки и применять правильную CSP-политику.crossorigin
– не является обязательным. Он указывает, что запрос должен быть CORS-запросом. CORS-запрос будет отправляться без данных для авторизации, за исключением crossorigin = «use-credentials»
.type
– также не является обязательным. Позволяет браузеру игнорировать предварительную загрузку, если предоставленный MIME-тип не поддерживается.Когда браузер увидит ссылку предварительной загрузки, он выберет её. Его функциональность схожа с HTTP/2 Server Push:
Однако существуют некоторые отличия.
Браузер извлекает ресурс, а значит, он будет ждать ответы от сервис-воркера, кеша HTTP, кеша HTTP/2 или целевого сервера (именно в таком порядке).
Поскольку предварительно загруженные ресурсы хранятся вместе со страницей (или воркером), то этот кеш браузер проверяет в первую очередь (перед сервис-воркером и кешем HTTP), и при потере подключения вы не потеряете загруженные элементы. Прямая ссылка на страницу также означает, что в инструментарии разработчика может появиться полезное предупреждение о неиспользовании предварительно загруженных элементов.
Каждая страница имеет свой собственный кеш предзагрузки, поэтому бессмысленно заранее загружать то, что предназначено для другой страницы.
Другими словами, нельзя предварительно загружать элементы, предназначенные для использования после загрузки страницы. Также бессмысленно заранее загружать материал со страницы для использования в установке сервис-воркера – он не будет проверять кеш предзагрузки страницы.
Chrome не поддерживает предварительную загрузку со всеми API. Например, fetch()
не использует кеш предварительной загрузки. XHR будет использовать, но только при отправках с данными для авторизации (описание проблемы).
Safari поддерживает предварительную загрузку только в своём последнем Technology Preview. Ни fetch()
, ни XHR
не используют кеш предварительной загрузки (описание проблемы).
Firefox не поддерживает предварительную загрузку, но эта возможность уже находится в процессе реализации (описание проблемы).
Edge не поддерживает предварительную загрузку. Если хотите, можете проголосовать за неё.
Идеальная предварительная загрузка всегда будет работать немного медленнее, чем идеальный HTTP/2 Server Push, так как в последнем случае не нужно ждать, пока браузер выполнит запрос. Однако предзагрузка значительно проще и легче в отладке. Я советую использовать её, поскольку поддержка браузерами становится всё лучше. Однако внимательно следите за предупреждениями в инструментах разработчика, чтобы убедиться, что проталкиваемые элементы действительно используются.
Некоторые службы превращают заголовки предзагрузки в HTTP/2 Server Push. На мой взгляд, это ошибка, учитывая, насколько различается их поведение, но, скорее всего, нам придётся жить с этим какое-то время. Тем не менее необходимо сделать так, чтобы эти службы убирали заголовок из окончательного ответа, иначе вы рискуете столкнуться с состоянием гонки, когда предзагрузка происходит раньше, чем push, что приводит к удвоенному использованию канала.
Сейчас HTTP/2 Server Push имеет несколько довольно грубых багов. Но как только они будут исправлены, думаю, он станет идеальным для тех ресурсов (статики), которые мы вставляем в страницу (инлайним), особенно для критически важных CSS. Надеюсь, когда кеш-дайджесты займут своё место, мы получим преимущества не только от инлайнинга, но и от кеширования.
Правильно это делать смогут «умные» серверы, которые будут уметь верно приоритизировать стриминг контента. Я, например, хочу иметь возможность передавать критический CSS параллельно с заголовком страницы, но затем отдавать полный приоритет CSS, так как бессмысленно тратить ресурсы канала на передачу основного контента, который пользователь пока не может отрендерить.
Если ваш сервер медленно реагирует на запросы (из-за долгого поиска по базе данных или по каким-то другим причинам), вы можете использовать это время, отправив пуши для ресурсов, которые, скорее всего, понадобятся странице; как только она станет доступна, приоритеты можно изменить.
Этот пост не является критикой HTTP/2 Server Push, и я надеюсь, не выглядит таковым. HTTP/2 Server Push действительно может повысить производительность; просто не используйте его без тщательного тестирования, иначе вы рискуете замедлить ваше приложение.