Разбираем HTTP/2 по байтам

image

Откройте любую статью с обзором HTTP/1.1. Скорее всего, там найдётся хотя бы один пример запроса и ответа, допустим, такие:

GET / HTTP/1.1 Host: localhost

HTTP/1.1 200 OK Date: Sat, 09 Oct 2010 14:28:02 GMT Server: Apache Content-Length: 38 Content-Type: text/html; charset=utf-8  <!DOCTYPE html> <h1>Привет!</h1>

Теперь откройте статью с обзором HTTP/2 или HTTP/3. Вы узнаете о мультиплексировании запросов, о сжатии заголовков, о поддержке push-технологий, но вряд ли увидите хоть одно конкретное сообщение. Ясно, почему так: HTTP/1.1 — текстовый протокол, тогда как сиквелы к нему бинарные. Это очевидное изменение открывает дорогу ко множеству оптимизаций, но упраздняет возможность просто и доступно записать сообщения.

Поэтому в этой статье предлагаю покопаться в кишках у HTTP/2: разобрать алгоритмы установки соединения, формат кадров, примеры взаимодействия клиента с сервером.

Статья рассчитана как на давно знакомых с HTTP, так и начинающих фронтендеров недавно изучивших HTTP/1.1, и пытающихся осознать, что там с HTTP/2. Естественно, я не могу описывать все подробности, поэтому новичкам в сетевых технологиях для лучшего понимания материала предлагается ознакомиться хотя бы с тем, как работает стек TCP/IP. Для этого очень рекомендую плейлист Networking tutorial на канале Ben Eater. Кроме того, как увидим далее, HTTP/2 на практике работает только поверх защищённого соединения (https), поэтому рекомендуется что-то в общих чертах понимать про TLS.

Спецификация HTTP/2 описана в RFC 9113 — здесь нужно искать все подробности о протоколе. Также пару раз я буду ссылаться на устаревшую спецификацию — RFC 7540. Она не актуальна, но для исторической справки бывает полезна. Плюс на ней были основаны большинство реализаций серверов, так что если вам доведётся с ними работать, возможно пересечётесь с какими-нибудь отголосками прошлого.

Вспомним былое

Что значит фраза «HTTP/1.1 — текстовый протокол»? То, что все служебные данные являются простыми строками. Клиент, отправляя запрос, записывает метод, путь и заголовки в виде строки ASCII в своей памяти, и затем отправляет по сети получившиеся байты. Например, запрос из начала статьи в памяти и при передаче по сети, выглядит так (в шестадцатеричной записи. Также далее везде числа с приставкой 0x шестнадцатеричные, c 0b двоичные, # отделяет комментарии):

47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31    # GET / HTTP/1.1 0d 0a                                        # \r\n 48 6f 73 74 3a 20 6c 6f 63 61 6c 68 6f 73 74 # Host: localhost 0d 0a 0d 0a                                  # \r\n\r\n

Тело же сообщения — просто какой-то набор байтов, а то, как их следует интерпретировать, указано в заголовке Content-Type. Например, в ответе из начала статьи указано Content-Type: text/html; charset=utf-8. Это значит, что тело представляет собой разметку html, передаваемую в кодировке UTF-8. Если не указать атрибут charset, браузеру ничего не останется, кроме как попытаться угадать кодировку самостоятельно (спойлер: вряд ли угадает).

Пример, как выглядит запрос из начала статьи при передаче по сети

# Строка ответа и заголовки в ASCII 48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b           # HTTP/1.1 200 OK 0d 0a                                                  # \r\n 44 61 74 65 3a 20 53 61 74 2c 20 30 39 20 4f 63 74 20  # Date: ... 32 30 31 30 20 31 34 3a 32 38 3a 30 32 20 47 4d 54  0d 0a                                                  # \r\n  53 65 72 76 65 72 3a 20 41 70 61 63 68 65              # Server: Apache 0d 0a                                                  # \r\n 43 6f 6e 74 65 6e 74 2d 4c 65 6e 67 74 68 3a 20 33 38  # Content-Length: 38 0d 0a                                                  # \r\n 43 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20              # Content-Type: 38 74 65 78 74 2f 68 74 6d 6c 3b 20                       # text/html; 63 68 61 72 73 65 74 3d 75 74 66 2d 38                 # charset=utf-8 0d 0a 0d 0a                                            # \r\n\r\n  # Заголовки закончились, дальше идёт тело в UTF-8 (как указано в Content-Type) 3c 21 44 4f 43 54 59 50 45 20 68 74 6d 6c 3e 0a                   # <!DOCTYPE html>\n 3c 68 31 3e d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82 21 3c 2f 68 31 3e # <h1>Привет!</h1>

Можно обратить внимание, что в теле 32 символа (включая один перевод строки), но в заголовках написано Content-Length: 38. Это связано с тем, что Content-Length содержит длину тела в октетах, а не символах. А так как тело записано в UTF-8, некоторые символы (а именно 6 кириллических в слове Привет) занимают больше одного октета. То есть HTTP вообще не имеет особого понятия, что написано в теле, с точки зрения протокола это просто какой-то набор байтов. Смысл в этот набор вкладывают приложения (клиент и сервер) с помощью подсказок в заголовках.

HTTP/2

Перейдём к виновнику торжества. Развлекательная программа следующая: сначала рассмотрим основные абстракции, которыми оперирует HTTP/2. Затем посмотрим на то, какие существуют кадры, и какое у них назначение. В заключение, разберём, как происходит обмен сообщениями.

Абстрактные основы

В HTTP/1.1 по большому счёту была одна сущность — сообщение. Клиент и сервер обменивались сообщениями, которые бывали двух видов: запросы и ответы. Внутри одного TCP-соединения они чередовались: клиент отправляет запрос, ждёт на него ответ, затем отправляет следующий.

Одной из задач HTTP/2 же стало мультиплексировать запросы, то есть позволить клиенту отправить второе сообщение-запрос по тому же TCP-соединению, не дожидаясь ответа на первый. В такой схеме клиенту при получении сообщения от сервера нужно как-то узнать, на какой из запросов это ответ. Очевидный способ — клиенту генерировать для каждого сообщения идентификатор и, например, отправлять его в заголовке Request-Id, а серверу указывать этот же идентификатор в заголовках ответа. Однако есть ещё одна проблема: сейчас мы отправляем каждое сообщение целиком. Если первый запрос уже начал отправляться, то второй должен будет дождаться конца передачи. Если первый запрос очень большой и менее приоритетный, чем второй, получается неловкая ситуация. Хотелось бы иметь возможность дробить сообщения на более мелкие куски, тогда можно было бы, чередуя их, отправлять несколько сообщений параллельно.

Из этих соображений в HTTP/2 возникают следующие абстракции:

  • Сообщения (messages) — запросы и ответы, всё как в HTTP/1.1. Состоят из заголовков и (необязательно) тела и трейлеров. Дробятся на более мелкие составные части — кадры.
  • Кадр (frame) — простейшая единица обмена информацией, передаются целиком (нельзя начать передавать второй кадр, пока полностью не передался один). Бывают разных типов, в зависимости от чего чуть отличается их содержимое. Есть служебные кадры, например, для управления соединением и потоками; есть кадры, из которых составляются сообщения.

    Замечание для смышлёных: кадры HTTP никак не связаны с пакетами TCP. Один пакет может содержать несколько кадров, один кадр может начинаться и заканчиваться в разных пакетах.

  • Поток (stream) — последовательность кадров, составляющих один запрос и ответ на него. Тогда как предыдущие абстракции имеют физическое представление (каждый кадр — это последовательность октетов1 в определённом формате, а сообщение — некоторый набор кадров), потоки существуют только в наших сердцах мыслях. В каждом кадре есть поле с идентификатором потока, соответственно все кадры с одинаковым идентификатором считаются принадлежащими одному потоку.

    Новый поток может создать как клиент (отправив запрос с идентификатором, который не использовался ранее), так и сервер (если используется server push). По одному потоку можно передать только одну пару сообщений: после того, как получено сообщение-ответ, поток считается закрытым, и по нему больше нельзя ничего передавать (то есть если одна из сторон получает кадр с идентификатором потока, который закрыт, она либо его игнорирует, либо закрывает соединение с ошибкой).


1 Так как по определению (по крайней мере изначальному) байт не обязательно состоит из восьми бит, а октет — обязательно, то при обсуждении сетевых протоколов принято использовать именно октет. И я буду, чем я хуже.

Раздел кадров

Каждый кадр условно делится на заголовок (header, не путать с заголовками сообщения) и содержимое (payload). Заголовок содержит общую информацию о кадре, и одинаковый у них всех, а содержимое уже зависит от конкретного типа. Формат такой:

HTTP Frame {   Length (24),   Type (8),   Flags (8),    Reserved (1),   Stream Identifier (31),    Frame Payload (..), }

Здесь

  • Length (24 бита/3 октета) — длина кадра в октетах, не включая заголовок. Читатель, слышавший что-то об арифметике, заметит, что длина заголовка — 9 октетов, так что длина всего кадра отличается от значения Lengh ровно на 9.
  • Type (8 бит/1 октет) — тип кадра. Например, значение 0x00 значит, что это кадр DATA, а 0x04 — кадр SETTINGS.
  • Flags (8 бит/1 октет) — набор булевых флагов. Каждый из восьми бит может быть 0 или 1, получается восемь флагов, которые можно устанавливать независимо. Доступные флаги зависят от типа кадра, так что о них будем говорить, обсуждая конкретные типы.
  • Reserved (1 бит) — не используется, всегда равен 0.
  • Stream Identifier (31 бит/почти 4 октета) — идентификатор потока, которому принадлежит кадр. Чтобы создать новый поток, клиент отправляет кадр (определённого типа, увидим позже) со значением Stream Identifier, которое не использовалось ранее. Чтобы отправить кадр по конкретному потоку, туда пишется соответствующий идентификатор. Есть кадры, которые относятся не к конкретному потоку, а к соединению в целом — для них используется зарезервированный идентификатор потока 0.
  • Frame Payload (Length октетов) — содержимое, зависит от типа кадра.

Рассмотрим конкретные типы кадров и их назначения. В скобках после названия указан идентификатор типа (значение поля Type в заголовке). Кадры упорядочены не по возрастанию идентификатора, а по зову сердца.

HEADERS (0x01) и CONTINUATION (0x09)

Вернёмся к сообщениям. В HTTP/1.1 они состояли из трёх основных частей: заголовков (включая строку запроса/ответа) и (необязательно) тела и трейлеров. В HTTP/2 они состоят из двух типов кадров: HEADERS и DATA.

Сначала посмотрим на заголовки и трейлеры. Несмотря на название, и для того, и для того используется кадр HEADERS, который имеет такой формат, самый сложный и основных кадров:

HEADERS Frame {   Length (24),   Type (8) = 0x01,    Unused Flags (2),   PRIORITY Flag (1),        # Устарело   Unused Flag (1),   PADDED Flag (1),   END_HEADERS Flag (1),   Unused Flag (1),   END_STREAM Flag (1),    Reserved (1),   Stream Identifier (31),    [Pad Length (8)],   [Exclusive (1)],          # Устарело   [Stream Dependency (31)], # Устарело   [Weight (8)],             # Устарело   Field Block Fragment (..),   Padding (..2040), }

Первые 9 октетов — стандартный для всех кадров заголовок. Содержимое состоит из так называемого «блока заголовков» (header block) и дополнения (padding). Вообще говоря, в новой спецификации используется термин «блок полей» (field block), а не «блок заголовков». Это связано как раз с тем, что HEADERS используется для передачи не только заголовков, но и трейлеров, поэтому говорить «блок заголовков» не совсем корректно. Но, я считаю, название «блок полей» может сбить с толку, поэтому дальше везде буду говорить только о заголовках и подразумевать, что всё что здесь описано, применимо и к трейлерам.

Флаг PRIORITY, как и значения Exclusive, Stream Dependency и Weight в содержимом, — останки механизма приоритизации потоков, который был предложен в первой итерации HTTP/2. На практике он оказался довольно сложным, и большинство клиентов и серверов либо вообще его не реализовали, либо реализовали очень неоптимально. Поэтому этот механизм и все сопутствующие части кадров в новой спецификации были признаны устаревшими, и предложен новый механизм приоритизации на основе заголовков и специальных кадров PRIORITY_UPDATE, который описан отдельно (RFC 9218). Его разбирать здесь не будем.

Если установлен флаг PADDED, первые 8 бит/1 октет в содержимом указывают длину дополнения (Pad Length), а само дополнение идёт в конце кадра, после заголовков. Дополнение представляет собой просто Pad Length нулевых октетов. Оно используется в качестве меры безопасности в дополнение к шифрованию — если нужно скрыть истинную длину заголовков.

Гвоздь кадра — блок заголовков. Заголовки передаются в сжатом с помощью алгоритма HPACK (RFC 7541) виде. Алгоритм довольно хитрый, так что разбирать здесь его не будем. В примерах я буду записывать заголовки трейлеры самым простым (по совместительству неэффективным) способом, каким позволяет HPACK.

Замечание о легальности этого способа

У меня есть сомнения, можно ли кодировать заголовки, для которых есть сокращённые формы записи (например, заголовок accept в HPACK, вообще говоря, можно кодировать всего одним октетом 0x13 плюс сколько-то байт, чтобы закодировать значение), не используя эти сокращённые формы, то есть полностью выписывая названия заголовка в ASCII.

В спецификации HPACK я не нашёл раздела, где такой способ бы был запрещен или хотя бы не рекомендован. Тем не менее, судя по всему, некоторые реализации его не поддерживают. В моих тестах самописный HTTP/2 сервер на NodeJS (использующий встроенную библиотеку node:http2, которая, в свою очередь, использует C++ библиотеку nghttp2) вполне себе замечательно обрабатывает заголовки, записанные таким образом. А сервера гугла при попытке отправить заголовки в таком виде возвращают ошибку сжатия. Но пока меня не ткнут носом, где написано, что так кодировать нельзя, буду использовать эту простую схему.

Сначала пишется один нулевой октет. Далее один октет — длина названия заголовка (только старший бит длины должен быть 0, но это детали) и само название в ASCII, затем длина значения и само значение тоже в ASCII. Все названия заголовков в HTTP/2 пишутся строчными буквами. Рассмотрим конкретный пример кадра HEADERS для наглядности:

00 00 4b 01             # Длина содержимого 0x00 00 4b=75 октетов, тип HEADERS (0x01) 05                      # Установлены флаги END_HEADERS и END_STREAM (6й и 8й биты, 0x05=0b0000 0101) 00 00 00 01             # Идентификатор потока 1 00                      # Начинаются заголовки. Сначала нулевой октет 07 3a 6d 65 74 68 6f 64 # Длина названия 7 октетов, само название ":method" записано в ASCII 03 47 45 54             # Длина значения 3 октета, значение "GET" 00                      # Начало второго заголовка 07 3a 73 63 68 65 6d 65 # Длина 7, название ":scheme" 05 68 74 74 70 73       # Длина 5, значение "https" 00 05 3a 70 61 74 68       # Название ":path" 01 2f                   # Значение "/" 00                       0a 3a 61 75 74 68 6f 72 69 74 79 # Название ":authority" 09 6c 6f 63 61 6c 68 6f 73 74    # Значение "localhost" 00                       04 68 6f 73 74                   # Название "host" 09 6c 6f 63 61 6c 68 6f 73 74    # Значение "localhost" 

Только что мы невольно лицезрели простейший запрос в HTTP/2, аналогичный запросу HTTP/1.1 из самого начала статьи. Сразу бросаются в глаза заголовки, начинающиеся с двоеточия. Это так называемые псевдозаголовки. Они содержат в том числе данные, которые в HTTP/1.1 передавались в строке запроса. Невероятно, но :method — это метод, :path — путь. :scheme — схема URI, на который поступает запрос, обычно это https (браузеры не поддерживают использование HTTP/2 по http, это ещё обсудим позднее), :authority — имя сервера, на который поступает запрос, грубо говоря то, что в HTTP/1.1 указывалось в заголовке Host. Заголовок host однако, как видно в примере, в HTTP/2 тоже есть, только теперь не обязателен. Чем отличается от :authority позволю себе здесь не обсуждать.

Псевдозаголовки чуть отличаются от обычных заголовков. Например, они обязательно должны идти первыми. Кому интересны подробности, тот почитает (спецификацию)[https://www.rfc-editor.org/rfc/rfc9113#section-8.3-2]. Ответы тоже не обделили псевдозаголовками. Правда он всего один: :status — код статуса. Простейший пример ответа в HTTP/2:

00 00 0d 01    # Длина содержимого 13, тип кадра HEADERS 05 00 00 00 01 # Флаги END_HEADERS и END_STREAM, идентификатор потока 1 00 07 3a 73 74 61 74 75 73 # Псевдозаголовок ":status" 03 32 30 34             # Значение "204"

Постоянно писать байты не удобно, поэтому кадры будем записывать в более простом виде, указывая только тип, установленные флаги и содержимое. После типа кадра через черту будем писать номер потока, по которому кадр отправляется:

# Запрос HEADERS/1   + END_HEADERS   + END_STREAM     :method = GET     :scheme = https     :path = /     :authority = localhost     host = localhost  # Ответ HEADERS/1   + END_HEADERS   + END_STREAM     :status = 204

Вот и вышел простейший обмен сообщениями (предполагая, что стороны уже обменялись всеми необходимыми рукопожатиями. Конкретно о том, что должно произойти до обмена сообщениями, речь пойдёт позже).

Осталось что-то сказать про флаги END_HEADERS и END_STREAM. Бывают ситуации (например, большие файлы куки), когда заголовков нужно отправить очень много, и они не влезают в максимально разрешённый размер кадра (такой есть, им можно управлять с помощью кадров SETTINGS). В таком случае можно разбить их на несколько частей. Первую часть отправить в кадре HEADERS, а оставшиеся в одном или нескольких кадрах CONTINUATION, которые выглядят почти также, только более простые:

CONTINUATION Frame {   Length (24),   Type (8) = 0x09,    Unused Flags (5),   END_HEADERS Flag (1),   Unused Flags (2),    Reserved (1),   Stream Identifier (31),    Field Block Fragment (..), }

Флаг END_HEADERS нужен, чтобы обозначить, где заканчиваются заголовки. Он устанавливается на последнем кадре CONTINUATION, либо на самом кадре HEADERS, если CONTINUATION не требуются. Нужно отметить, что между кадр HEADERS и соответствующие CONTINUATION должны идти подряд: между ними не может быть других кадров, даже из других потоков.

Флаг END_STREAM обозначает последний кадр сообщения. Так как одно сообщение отправляется по одному потоку, то после отправки такого кадра, сторона больше не может передавать данные по этому потоку. Флаг END_STREAM на кадре HEADERS с заголовками означает, что у сообщения нет тела и трейлеров. Если же тело есть, то он выставляется на последнем относящемся к сообщению кадре: это либо DATA, если трейлеров нет, либо HEADERS с трейлерами, если есть.

DATA (0x00)

Используется для передачи тела сообщения. Формат простой:

DATA Frame {   Length (24),   Type (8) = 0x00,    Unused Flags (4),   PADDED Flag (1),   Unused Flags (2),   END_STREAM Flag (1),    Reserved (1),   Stream Identifier (31),    [Pad Length (8)],   Data (..),   Padding (..2040), }

Ситуация с дополнением (padding) точно такая же, как в кадрах HEADERS. С END_STREAM уже разобрались, поэтому без лишних слов посмотрим на более сложный пример сообщения.

HEADERS/1   + PADDED     :status = 200     date = Sat, 09 Oct 2010 14:28:02 GMT     server = apache CONTINUAION/1   + END_HEADERS     content-length = 38     content-type = text/html; charset=utf-8 DATA/1     <!DOCTYPE html>\n DATA/1   + PADDED   + END_STREAM     <h1>Привет!</h1>

Разбор по байтам

# Первый кадр 00 00 45 01             # Длина содержимого 0x45=73 октета, тип HEADERS 08 00 00 00 01          # Флаг PADDED, поток 1 04                      # Длина дополнения 4 октета 00 07 3a 73 74 61 74 75 73 # Псевдозаголовок ":status" 03 32 30 30             # Значение "200" 00 04 64 61 74 65          # Заголовок "date", значение "Sat, 09 Oct...." 1d 53 61 74 2c 20 30 39 20 4f 63 74 20 32 30 31 30 20 31 34 3a 32 38 3a 30  32 20 47 4d 54 00 06 73 65 72 76 65 72    # Заголовок "server"  06 61 70 61 63 68 65    # Значение "apache" 00 00 00 00             # 4 октета дополнения (padding)  # Второй кадр 00 00 3a 09             # Длина содержимого 0x3a=58 октетов, тип CONTINUATION 04 00 00 00 01          # Флаг END_HEADERS, поток 1 00 0e 63 6f 6e 74 65 6e 74 2d 6c 65 6e 67 74 68 # Заголовок "content-length" 02 33 38                                     # Значение "38" 00 0c 63 6f 6e 74 65 6e 74 2d 74 79 70 65       # Заголовок "content-type", 18 74 65 78 74 2f 68 74 6d 6c 3b 20 63       # Значение "text/html; charset=utf-8" 68 61 72 73 65 74 3d 75 74 66 2d 38  # Третий кадр 00 00 10 00             # Длина содержимого 0x10=16 октетов, тип DATA 00 00 00 00 01          # Флагов нет, поток 1 3c 21 44 4f 43 54 59 50 # Строка "<!DOCTYPE html>\n" в UTF-8 45 20 68 74 6d 6c 3e 0a   # Четвёртый кадр 00 00 1f 00             # Длина содержимого 0x1f=31 октет, тип DATA 09 00 00 00 01          # Флаги PADDED и END_STREAM, поток 1 08                      # Длина дополнения 8 октетов 3c 68 31 3e d0 9f d1 80 d0 b8 d0 # Строка "<h1>Привет!</h1>" в UTF-8 b2 d0 b5 d1 82 21 3c 2f 68 31 3e 00 00 00 00 00 00 00 00          # 8 октетов дополнения

Это сообщение, аналогичное примеру ответа из начала статьи. Только я разбил заголовки и тело на несколько кадров как мне вздумалось, чтобы получилось более интересно.

SETTINGS (0x04)

Используется для установки параметров соединения. Формат такой:

SETTINGS Frame {   Length (24),   Type (8) = 0x04,   Unused Flags (7),   ACK Flag (1),   Reserved (1),   Stream Identifier (31) = 0,    Setting (48) ..., }  Setting {   Identifier (16),   Value (32), }

Потоки не имеют настраиваемых параметров, SETTINGS применяется только к соединению в целом, так что идентификатор потока всегда 0. В содержимом перечисляются параметры, которые нужно изменить. Каждый параметр занимает 48 бит/6 октетов, из которых 2 октета — идентификатор, а остальные 4 — значение. Рассмотрим некоторые параметры в качестве примера. Список не полный, полный список и ссылки на спецификации, где параметры описаны, нужно искать на сайте IANA.

  • SETTINGS_ENABLE_PUSH (идентификатор 0x02) — клиент может установить значение 0 этому параметру, чтобы отключить использование server push. Эту технологию оказалось довольно сложно использовать с толком, поэтому, например, хромиум всегда так делает.
  • SETTINGS_MAX_CONCURRENT_STREAMS (0x03) — максимальное количество потоков, которое другой стороне разрешается использовать одновременно. Этот параметр устанавливается именно для другой стороны: клиент ограничивает количество потоков, которые может создавать сервер, и наоборот. Можно указать значение 0: тогда другая сторона не сможет отправлять новые сообщения, пока этот параметр снова не поменяется.
  • SETTINGS_INITIAL_WINDOW_SIZE (0x04) — элемент управления потоками (flow control). Подробнее в разделе про кадры WINDOW_SIZE.
  • SETTINGS_MAX_FRAME_SIZE (0x05) — максимальный размер кадра в октетах, который собеседнику разрешено отправлять. Значение по умолчанию, оно же минимальное — 16 384=214 октетов.

При отправке SETTINGS, важно знать, получил и применил ли собеседник их новые значения. Для этого каждая клиент/сервер после получения SETTINGS отправляет в ответ ещё один такой кадр, в котором устанавливает единственный доступный для этого типа флаг ACK (acknowledged). Например, сервер отправил кадр SETTINGS и указал, что максимальное количество потоков, которые он готов одновременно обрабатывать (параметр SETTINGS_MAX_CONCURRENT_STREAMS с идентификатором 0x03) равно 100. Как только клиент его получит, он должен запомнить новое значение параметра, и отправить в ответ пустой кадр SETTINGS с флагом ACK. Пока сервер не получил это подтверждение, он не может быть уверен, что клиент узнал о новом значении параметра, поэтому не может рассчитывать, что клиент не откроет больше 100 потоков.

Пример такого обмена:

# Сервер отправляет клиенту SETTINGS/0     SETTINGS_ENABLE_PUSH  = 0     SETTINGS_MAX_CONCURRENT_STREAMS = 100  # Клиент отправляет в ответ SETTINGS/0   + ACK

Разбор по байтам

# Сервер клиенту 00 00 0c 04        # Длина содержимого 0x0c=12 октетов, тип SETTINGS 00 00 00 00 00     # Флагов нет, поток 0 00 02 00 00 00 00  # Параметру SETTINGS_ENABLE_PUSH (идентификатор 0x00 02) установить значение 0 00 03 00 00 00 64  # Параметру SETTINGS_MAX_CONCURRENT_STREAMS (0x00 03) установить значение 100 (0x64)  # Клиент серверу 00 00 00 04        # Длина содержимого 0 (пустой кадр), тип SETTINGS 01 00 00 00 00     # Флаг ACK, поток 0

PING (0x06)

Пока не происходит обмена сообщениями, клиент и сервер периодически обмениваются такими кадрами, чтобы проверить, не закрылось ли соединение, не идёт ли коммунизм. Также с помощью них можно оценивать круговую задержку (round-trip time): сколько времени нужно, чтобы отправить кадр и получить на него ответ. Формат:

PING Frame {   Length (24) = 0x08,   Type (8) = 0x06,    Unused Flags (7),   ACK Flag (1),    Reserved (1),   Stream Identifier (31) = 0,    Opaque Data (64), }

Содержимое состоит из 64 бит/8 октетов произвольных данных (Opaque Data). Сами по себе эти данные ничего не значат, но если одна и сторон получает кадр PING, она должна отправить в ответ ещё один кадр PING с флагом ACK и продублировать содержимое.

Пример обмена пингами

# Клиент отправляет серверу 00 00 08 06    # Длина содержимого 8 октетов, тип PING 00 00 00 00 00 # Флагов нет, поток 0 2f b0 7a ee    # 8 произвольных, ничего не значащих октетов 92 01 8a bc  # Сервер отвечает 00 00 08 06    # Длина содержимого 8 октетов, тип PING 01 00 00 00 00 # Флаг ACK, поток 0 2f b0 7a ee    # Продублировано из предыдущего кадра 92 01 8a bc

WINDOW_UPDATE (0x08)

Во многих сетевых протоколах, в том числе TCP и HTTP, обработка данных происходит не мгновенно по получении. Операционная система получает сетевые пакеты и складывает их в некоторый буфер, из которого приложение потом может их считать сразу или через какое-то время. Из-за этого возникает необходимость в некотором механизме управления потоком (flow control): нельзя отправлять слишком много данных сразу, если они обрабатываются медленно, у собеседника может, например, банально не хватить памяти всё сохранить для последующей обработки.

Самые большие кадры в HTTP — это кадры DATA, поэтому flow control применяется только к ним. Все остальные кадры, в частности HEADERS, обычно не слишком большие, поэтому обрабатываются сразу. За то, чтобы не отправить слишком много заголовков, отвечает механизм flow control, который уже есть в TCP.

У каждого потока и у соединения в целом есть «размер окна» (window size) — максимальное количество октетов, которые собеседнику разрешено отправить в данный момент на данном потоке/соединении. С каждым полученным кадром DATA размер окна уменьшается на размер его содержимого. Если отправить слишком много, размер окна станет слишком маленьким, и собеседник откажется принимать новые кадры. Размер окна свой у клиента и у сервера.

Когда же собеседник обработает полученные ранее данные и решит, что готов получать новые, он отправит кадр WINDOW_UPDATE, в котором увеличит свой размер окна, и позволит отправлять новые кадры. Формат такой:

WINDOW_UPDATE Frame {   Length (24) = 0x04,   Type (8) = 0x08,    Unused Flags (8),    Reserved (1),   Stream Identifier (31),    Reserved (1),   Window Size Increment (31), }

Тело состоит из 4 октетов (только старший бит не используется), которые указывают, на сколько надо увеличить размер окна.

Пример работы WINDOW_UPDATE

Изначальный размер окна задаётся параметром SETTINGS_INITIAL_WINDOW_SIZE, по умолчанию 65 535 октетов. Предположим, сервер отправил кадр размером 65 500 октетов:

00 ff dc 00    # Размер 0xff dc=65 500 октетов, тип DATA 01 00 00 00 01 # Флаг END_STREAM (но не END_HEADERS), поток 1 1a 2b 3c 4d... # Куча данных на 65 500 октетов

И теперь хочет отправить ещё столько же. Но размер окна сервера только что уменьшился на 65 500 и стал 35 октетов, то есть ещё один такой же кадр не поместится. Поэтому сервер не может отправить его сразу же, он должен подождать некоторое время. Когда клиент получит первый кадр и обработает его, он отправит, например, такой WINDOW_UPDATE:

00 00 04 08 00 00 00 00 01 00 00 ff ff

Таким образом, увеличив размер окна для потока 1 ещё на 65 535 октетов. То есть после отправки этого кадра размер окна клиента станет 65 570 октетов. Rогда сервер получит этот WINDOW_UPDATE, он узнает, что размер окна клиента увеличился, и сможет отправить следующий кадр DATA.

Примечание: я не нахожу в спецификации места, где было бы оговорено, нужно ли увеличивать в этом сценарии размер окна для соединения в целом и для потока 1 двумя отдельными WINDOW_UPDATE, или же увеличение окна потока 1 автоматически увеличивает окно всего соединения. Попытался проверить на практике, отправляя кадры на самодельный HTTP/2 сервер на NodeJS, использующий встроенную библиотеку node:http2 (которая, в свою очередь, использует C++ библиотеку nghttp2) и на сервера гугла: в обоих случаях выглядит, как будто достаточно одного кадра, который увеличивает окно только для потока 1.

RST_STREAM (0x03) и GOAWAY (0x07)

Эти кадры позволяют преждевременно закрыть поток/соединение. Например, если клиент на каком-то потоке отправил кадр с ошибкой (не соответствующий спецификации HTTP/2), сервер скорее всего закроет этот поток с помощью RST_STREAM (по-русски reset stream), в котором укажет код ошибки. Либо, например, пока сервер отправляет ответ на запрос, клиент решил, что этот он больше не нужен (скажем, пользователь отменил загрузку картинки в вотсапе). Тогда он может остановить передачу ответа с помощью RST_STREAM. В общем, причин закрывать поток может быть множество.

Аналогично с GOAWAY: он служит для закрытия соединения либо в случае ошибки, либо просто по инициативе одной из сторон. В кадре GOAWAY содержится самый большой идентификатор потока, который отправитель смог получить и каким-то образом обработать — Last-Stream-Id. Таким образом, получатель GOAWAY узнаёт, какие сообщения нужно будет отправлять заново, а какие нет.

Форматы кадров и примеры

RST_STREAM Frame {   Length (24) = 0x04,   Type (8) = 0x03,    Unused Flags (8),    Reserved (1),   Stream Identifier (31),    Error Code (32), }  GOAWAY Frame {   Length (24),   Type (8) = 0x07,    Unused Flags (8),    Reserved (1),   Stream Identifier (31) = 0,    Reserved (1),   Last-Stream-ID (31),   Error Code (32),   Additional Debug Data (..), }

Возможные коды ошибок и спецификации, в которых они определены, надо вновь искать на страничке IANA.

Additional Debug Data — произвольны данные, которые могут помочь при отладке ошибок. Обычно это какая-нибудь строка в ASCII с более подробным описанием ошибки, чем позволяет Error Code, но в принципе там могут быть любые данные.

Пример: клиент закончил передачу запроса на потоке 5, отправив все необходимые кадры и установив в последнем флаг END_STREAM, а затем пытается отправить ещё один кадр HEADERS на том же потоке 5. Сервер закрывает его с ошибкой:

00 00 04 03    # Длина содержимого 4 октета, тип RST_STREAM 00 00 00 00 05 # Флагов нет, поток 5 00 00 00 05    # Ошибка 0x05 — STREAM_CLOSED

Ещё пример: на клиенте некорректно реализован алгоритм HPACK, и сервер не может понять заголовки в запросе из потока 7. В связи с особенностями HPACK, в этом случае сервер должен закрыть соединение. При этом запросы, которые ранее приходили на потоках 1 и 3 уже обработаны, и на них отправлены ответы, а на потоке 5 сообщение начало обрабатываться, но не закончило, и ответ ещё даже не сформирован. Тогда сервер отправляет такой кадр:

00 00 14 07    # Длина содержимого 0x14=20 октетов, кадр GOAWAY 00 00 00 00 00 # Флагов нет, поток 0 00 00 00 05    # Последний обработанный поток — пятый 00 00 00 09    # Ошибка 0x09 — COMPRESSION_ERROR 68 70 61 63 6b 5f 65 72 72 6f 72 0a # Строка "hpack_error" для отладки, записанная в ASCII

После этого он закрывает TCP-соединение, но может продолжить обработку запроса из потока 5 (скажем, это был запрос на перевод денег, который нельзя просто оборвать посередине, чтобы база данных не оказалось в невалидном состоянии).


На этом завершим обзор кадров. Необсуждёнными из спецификации HTTP/2 остались только кадры PRIORITY (устаревшие останки вышеупомянутой системы приоризитации) и PUSH_PROMISE (используется для server push. Позволю себе пропустить его обсуждение, так как технология на данный момент не слишком востребованная, да и интересных подробностей нет: просто сервер отправляет PUSH_PROMISE с описанием запроса, на который он хочет без спросу выдать ответ, а затем сам ответ). Кроме того, упустили кадры, которые были введены в отдельных RFC. Полным списком кадров со ссылками в очередной раз предлагаю искать на страничке IANA.

Как завязать общение с понравившимся сервером

Обсудили кусочки, из которых составляется обмен данными в HTTP/2. Теперь посмотрим на соединение с высоты: как оно начинается и живёт.

Сервера HTTP/1.1 и HTTP/2 по умолчанию используют один и тот же порт — 443. Передаваемые данные у протоколов сильно отличаются, поэтому если клиент не знает заранее, какую версию поддерживает сервер, нужен какой-то механизм, как её выбрать.

Изначально (в старой спецификации) таких было два: один для защищённых соединений (тогда протокол называется HTTP/2 over TLS, сокращённо h2), один для незащищённых (HTTP/2 over cleartext TCP, сокращённо h2c). Однако большинство браузеров в принципе не стали поддерживать возможность использовать h2c, поэтому второй механизм в новой спецификации был признан устаревшим. Но для исторической справки и полноты картины описание, как это должно было работать, приведу в спойлере.

В случае защищённого соединения (с HTTP over TLS, он же h2, схема https) идея очень простая, но только если вы знаете TLS. Этот протокол помимо своей основной цели (шифрование трафика) поддерживает множество расширений — дополнительных полей, которыми сервер и клиент могут обменяться во время установки зашифрованного соединения. Одно из них — согласование протокола прикладного уровня (ALPN, Application-Level Protocol Negotation). Клиент отправляет список поддерживаемых протоколов (например, http/1.1 и h2), а сервер в ответе выбирает, какой из них предпочтителен. К сожалению, разбор TLS по байтам в этой статье не предусмотрен, так что как именно отправляется информация в ALPN как-нибудь в другой раз. Но суть в том, что сервер, увидев, что клиент поддерживает h2, просто сразу с помощью того же ALPN сообщает «будем использовать HTTP/2».

Как могло бы быть в случае незащищённого соединения

Так как TLS в этом случае не используется, а в TCP расширения ALPN не предусмотрено, выбор протокола происходил бы на уровне HTTP. Если бы клиент заранее знал, что сервер поддерживает HTTP/2 (например, вы ручками указали в настройках, либо уже подключались к этому серверу ранее, и браузер сохранил версию протокола в кеш), то клиент сразу бы отправил свои вступительные HTTP/2-кадры, как описано ниже, по которым сервер бы понял, что надо использовать HTTP/2. Если бы клиент ошибся (думал, что сервер поддерживает HTTP/2, а на самом деле нет), сервер бы просто закрыл соединение, не поняв, что от него требуется.

Иначе же, сначала клиент отправил бы запрос HTTP/1.1, в котором, среди прочего, указал заголовки Upgrade и HTTP2-Settings:

GET /hot-asian-girls.php HTTP/1.1 Host: example.com Accept: text/html Accept-Language: en Connection: Upgrade, HTTP2-Settings Upgrade: h2c HTTP2-Settings: ...

Значением Upgrade являлся бы идентификатор протокола HTTP/2 over cleartext TCP, то есть h2c. А HTTP2-Settings служил бы, чтобы сразу установить параметры соединения: в нём бы та же последовательность, что и в содержимом кадра SETTINGS, только закодированная в base64. Если бы сервер не поддерживал HTTP/2, он бы просто отправил ответ, как будто это обычный запрос HTTP/1.1. Иначе, он бы отправил ответ со статусом 101:

HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: h2c

За ним сразу последовали бы вступительные кадры HTTP/2 и ответ на запрос клиента. Но сейчас вся эта схема считается устаревшей, и не поддерживается практически ни одним браузером. Здесь приведена только в качестве исторической справки.

Итак, соединение установили, протокол выбрали, пора бы и чем-нибудь содержательным обменяться.

Что-нибудь содержательное

Первым делом, клиент сразу же отправляет серверу так называемое вступление2 (connection preface). Первая часть вступления — это фиксированная последовательность октетов

50 52 49 20 2a 20 48 54 54 50 2f 32  2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a

О сакральном смысле

Если интерпретировать эти октеты как последовательность кодов символов ASCII, получится следующая строка. Если закрыть глаза, она чем-то напоминает начало запроса HTTP/1.1.

PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n

Кто читал спойлер про незащищённое соединение, тому будет понятен смысл. Если бы протокол использовался без TLS, и клиент заранее знал, что сервер поддерживает HTTP/2, то эта строка служила бы индикатором для сервера, что клиент собирается использовать HTTP/2. Если же сервер поддерживал бы только HTTP/1.1, он бы распарсил её как запрос с невалидным методом, неправильной версией, и вообще чёрт знает чем. Тогда бы он просто закрыл соединение, и клиент бы понял, что ошибся с протоколом.

При использовании TLS важность этой строки теряется, поскольку выбор протокола происходит уже на этапе TLS-рукопожатия. Но по историческим причинам, и как дополнительное подтверждение выбора HTTP/2, она остаётся частью протокола.

Вторая часть — (возможно пустой) кадр SETTINGS с изначальными параметрами соединения. После вступления клиент может сразу начать отправлять запросы, не дожидаясь вступления сервера. А у сервера оно тоже есть, правда состоит всего из одного (опять же, возможно пустого) кадра SETTINGS.

Сообщение-запрос выглядит так:

  1. Один кадр HEADERS, в котором указывается идентификатор потока, который не использовался ранее. Идентификаторы выбираются обязательно по возрастанию, причём клиент может открывать только потоки с нечётными номерами. Это нужно, потому что сервер тоже может открывать новые потоки (с помощью кадров PUSH_PROMISE при использовании server push) — он будет выбирать только чётные номера. Таким образом не возникает коллизий.

    Если все заголовки поместились в HEADERS, у него должен быть флаг END_HEADERS. Иначе за HEADERS могут следовать сколько угодно кадров CONTINUATION, и флаг выставляется на последнем. Если у запроса нет тела, то у HEADERS должен быть флаг END_STREAM.

  2. Возможно, один или больше кадров DATA, содержащих тело запроса. Если нет трейлеров, на последнем должен быть флаг END_STREAM.
  3. Возможно, ещё один кадр HEADERS, содержащий трейлеры. Если он есть, то должен быть с флагом END_STREAM. Если трейлеров много, далее также могут следовать кадры CONTINUATION. В этом случае на последнем должен быть флаг END_HEADERS, иначе он должен быть на самом HEADERS. Список трейлеров, так же как и в HTTP/1.1, отправляется в заголовке `Trailers`.

В кадре HEADERS запроса первыми обязательно должны быть псевдозаголовки :method, :path и :scheme (кроме запросов с методом CONNECT, но это детали); псевдозаголовок :authority может отсутствовать.

После того, как сервер получил на потоке кадр с флагом END_STREAM, он может начинать отправлять ответ. Формат сообщения-ответа точно такой же, как и у запроса, только идентификатор потока указывается не новый, а тот, на котором пришёл запрос; и вместо :method, :path, :scheme и :authority, единственный псевдозаголовок, который ответ может и обязан содержать — это :status.

Когда размер окна одной из сторон подходит к концу, она увеличивает его кадром WINDOW_UPDATE. Пока никакие сообщения не отправляются, стороны периодически обмениваются кадрами PING. В любой момент можно изменить параметры соединения кадром SETTINGS. Любая из сторон может закрыть любой поток кадром RST_STREAM. HTTP-соединение прерывается, когда закрывается TCP-соединение. Перед тем как закрыть TCP-соединение, сторона может отправить кадр GOAWAY с объяснением причин (вообще говоря, не обязана, но рекомендуется).

Вот так и живём. Основные детали протокола разобрали, под конец приведу содержательный пример обмена сообщениями. Домашнее задание: написать простой клиент и сервер HTTP/2 на любимом языке.

Содержательный пример

Слева записаны кадры, которые отправляет клиент, справа — сервер. Кадры указаны сверху вниз в порядке, в котором они отправляются. Ещё домашнее задание: расписать этот обмен кадрами по байтам.

PRI * HTTP/2.0\r\n\r\n                  | SM\r\n\r\n                              |  SETTINGS/0                                         |      SETTINGS_MAX_CONCURRENT_STREAMS = 100 SETTINGS/0                              |       SETTINGS_ENABLE_PUSH = 0            |     SETTINGS_NO_RFC7540_PRIORITIES = 1  |                                         | HEADERS/1                               |     + END_STREAM                          |  SETTINGS/0     :method = GET                       |    + ACK     :scheme = https                     |       :path = /                           |     :authority = example.com            |     accept = text/html                  |     accept-language = ru                |     user-agent = My-Browser/1.0         |                                         | CONTINUATION/1                          |   + END_HEADERS                         |     cookie = <2048 октетов>             |                                         | SETTINGS/0                              |   + ACK                                 |  HEADERS/1                                         |    + END_HEADERS HEADERS/3                               |    + END_STREAM   + END_HEADERS                         |      :status = 302   + END_STREAM                          |      location = https://example.com/ru     :method = GET                       |      content-length = 0     :scheme = https                     |      vary = accept-language     :path = /favicon.ico                |     :authority = example.com            |     accept = image/webp                 |     user-agent = My-Browser/1.0         |                                         | HEADERS/5                               |   + END_HEADERS                         |   + END_STREAM                          |  HEADERS/3     :method = GET                       |    + END_HEADERS     :scheme = https                     |      :status = 200     :path = /ru                         |      content-length = 68792     :authority = text/html              |      content-type = image/webp     accept-language = ru                |        user-agent = My-Browser/1.0         |  DATA/3     cookie = <2048 октетов>             |      <65535 октетов>                                         |                                         |  HEADERS/5 WINDOW_UPDATE/3                         |    + END_HEADERS     Window Size Increment = 65535       |      :status = 200                                         |      content-length = 38 WINDOW_UPDATE/5                         |      content-type = text/html; charset=utf-8     Window Size Increment = 65535       |                                           |  DATA/3                                         |    + END_STREAM                                         |      <3257 октетов>                                         |                                         |  DATA/5                                          |    + END_STREAM                                         |      <!DOCTYPE html>\n                                         |      <h1>Привет!</h1>                                         |                                           |                                         |                                         | PING/0                                  |     2f b0 7a ee 92 01 8a bc             |                                         |  PING/0                                         |    + ACK                                         |      2f b0 7a ee 92 01 8a bc                                         | GOAWAY/0                                |     Last-Stream-Id = 0                  |     Error Code = 0x00 NO ERROR          |                                         | # Клиент закрыл TCP-соединение          |                                         |


2 «Вступлением» эту штуку я обозвал сам. Без перевода оставлять не хочу, а в русском языке, кажется, не закрепилось такого термина. Либо я просто не посвящён.


ссылка на оригинал статьи https://habr.com/ru/companies/timeweb/articles/751338/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *