Почему HTTP/2?
HTTP/2 — это крупное обновление по сравнению с HTTP/1.1, и в наши дни он практически везде используется по умолчанию. Если вы открывали Chrome DevTools для проверки сетевых запросов, то, скорее всего, уже видели соединения по протоколу HTTP/2.
Но почему HTTP/2 так важен? Что не так с HTTP/1.1?
В HTTP/1.1 была введена конвейерная обработка, которая на первый взгляд казалась значительным шагом вперед. Идея была проста: несколько запросов могли совместно использовать одно соединение и запускаться, не дожидаясь завершения предыдущего.
Проблема заключается в том, что запросы должны отправляться по порядку, а ответы приходить в том же порядке. Если один ответ задерживается — серверу требуется дополнительное время для его обработки, — все остальные в очереди должны ждать.
Это также происходит, если случается сетевой «сбой», который задерживает один запрос. Весь конвейер обработки запросов останавливается до тех пор, пока не будет обработан задержанный запрос.
Эта проблема называется блокировкой Head-of-Line (HoL).
Чтобы обойти это ограничение, клиенты HTTP/1.1 (например, ваш браузер) начинают открывать несколько TCP-соединений с одним и тем же сервером, что позволяет одновременно отправлять запросы.
Хоть это работает, но не совсем эффективно:
Чем больше подключений, тем больше ресурсов используется как на стороне клиента, так и на стороне сервера.
TCP должен проходить процесс установления соединения для каждого подключения, что увеличивает задержку.
“Итак, решает ли HTTP / 2 эту проблему?”
Работает.… в основном, хорошо.
HTTP/2 использует одно соединение и разделяет его на несколько независимых потоков. У каждого потока есть свой уникальный идентификатор, называемый идентификатором потока, и эти потоки могут работать параллельно. Эта настройка устраняет проблему блокировки Head-of-Line (HoL) на прикладном уровне (где находится HTTP). Если один поток задерживается, это не мешает остальным.
Однако HTTP/2 по-прежнему работает на TCP, поэтому полностью избежать блокировки HoL не удаётся.
На транспортном уровне TCP настаивает на доставке пакетов в порядке, необходимом для прикладного уровня. Если один пакет теряется или задерживается, TCP заставляет всех ждать, пока он не восстановит недостающий фрагмент. Как только задержанный пакет появляется, TCP доставляет пакеты в правильном порядке на уровень HTTP/2 (или прикладной уровень).
Таким образом, даже если все данные остальных потоков находятся в буфере и готовы к отправке, серверу всё равно придётся ждать поступления данных отложенного потока, прежде чем он сможет обработать остальные.
Если вы хотите полностью обойти ограничения TCP, вам понадобится что-то вроде QUIC, которое построено на базе UDP (User Datagram Protocol) и поддерживает HTTP/3.
Конечно, HTTP/2 не только устраняет недостатки HTTP/1.1, но и открывает новые возможности. Давайте подробнее рассмотрим, как всё это работает.
Как работает HTTP/2?
Когда клиент устанавливает TLS-соединение, процесс начинается с сообщения ClientHello
. Это сообщение включает расширение ALPN (Application Layer Protocol Negotiation), которое по сути является списком протоколов, поддерживаемых клиентом. Обычно оно включает в себя как «h2» для HTTP/2, «http/1.1» в качестве резервного варианта, так и другие.
Затем стек TLS сервера проверяет этот список на соответствие протоколам, которые он поддерживает. Если обе стороны согласны с «h2», сервер подтверждает выбор в своём ServerHello
ответе.
После этого рукопожатие TLS продолжается в обычном режиме: устанавливаются ключи шифрования, проверяются сертификаты и т.д.
Предисловие к предварительному соединению
После завершения рукопожатия клиент отправляет так называемое предварительное соединение (префикс). Оно начинается с очень специфической 24-байтовой последовательности: PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
. Эта последовательность подтверждает, что HTTP/2 — это используемый протокол. На этом этапе еще нет сжатия или кадрирования.
Сразу после отправки префикса подключения клиент отправляет кадр SETTINGS
. Он не привязан ни к какому потоку; это управляющий кадр на уровне соединения — сообщение серверу, в котором говорится: «Вот мои предпочтения». Сюда входят такие настройки, как параметры управления потоком, максимальный размер кадра и т. д.
Сервер распознает, что именно хочет получить клиент, и отвечает собственным префиксом соединения, которая включает в себя кадр SETTINGS
.
После завершения обмена можно приступать к настройке соединения.
Кадр HEADERS и сжатие HPACK
Теперь клиент готов отправить запрос, он создаёт новый поток с уникальным идентификатором, который называется идентификатором потока. Идентификатор потока для потоков, инициированных клиентом, всегда является нечётным числом — 1, 3, 5,…
Вы можете задаться вопросом, почему идентификаторы потоков имеют нечётные значения, а не нумеруются как 1, 2, 3… На самом деле здесь есть небольшое аккуратное правило:
Потоки с нечётными номерами предназначены для запросов, инициированных клиентом.
Потоки с чётными номерами предназначены для сервера, часто для функций, инициируемых сервером, таких как push-уведомления от сервера.
Идентификатор потока 0 является специальным, он используется только для кадров управления на уровне соединения (не на уровне потока), которые применяются ко всему соединению.
Как только поток готов, клиент отправляет кадр HEADERS
.
Этот кадр содержит информацию — эквивалент строки запроса HTTP/1.1 и заголовков (GET / HTTP/1.1
и все, что следует за этим). Однако в HTTP/2 заголовки структурированы и передаются немного иначе.
Структура: HTTP/2 вводит псевдозаголовки, которые помогают определять такие параметры, как метод, путь и статус. Затем следуют знакомые заголовки, такие как
User-Agent
,Content-Type
,…Передача: заголовки сжимаются с помощью алгоритма HPACK и отправляются в двоичном формате.
— Псевдозаголовок? Сжатие HPACK? Что здесь происходит?
Давайте разберёмся с этим, начиная с псевдозаголовков.
Если вы пользовались DevTools в Chrome или любым другим инспектором, это может показаться вам знакомым.
В HTTP/2 псевдозаголовки — это способ отделить специальные заголовки от обычных. Эти специальные заголовки, такие как :method
, :path
, :scheme
, и :status
, всегда идут первыми. После них следуют обычные заголовки, такие как Accept
, Host
, и Content-Type
, в обычном формате.
В HTTP/1.1 эта информация разбросана по строке запроса и заголовкам. Это не очень удобно, и для заполнения пробелов приходится полагаться на соглашения или контекст. Например:
Схема (HTTP или HTTPS) подразумевает тип соединения. Если это TLS на порту 443, то знаете, что это HTTPS .
Заголовок
Host
, добавленный в HTTP/1.1 для виртуального хостинга - это просто обычный заголовок, а не формальная часть структуры запроса.
С псевдозаголовками HTTP/2 (те, что начинаются с двоеточия, например :method или :path) вся эта неоднозначность исчезает.
“А как насчет сжатия HPACK?”
В отличие от HTTP/1.1, где заголовки представляют собой обычный текст и разделяются символами новой строки (\r\n
), в HTTP/2 для кодирования заголовков используется двоичный формат. Именно здесь на помощь приходит сжатие HPACK
— алгоритм, созданный специально для HTTP/2. Он не просто сжимает заголовки для экономии места, но и позволяет избежать повторной отправки одних и тех же данных заголовка.
HPACK
использует две умные таблицы для управления заголовками: статическую таблицу и динамическую таблицу.
Статическая таблица похожа на общий словарь, который уже известен и клиенту, и серверу. Она содержит 61 наиболее распространённый HTTP-заголовок. Если вам интересны подробности, вы можете ознакомиться с файлом static_table.go в пакете net/http2
.
Допустим, вы отправляете GET-запрос с заголовком :method: GET
.
Вместо передачи всего заголовка HPACK
может просто отправить число 2. Это число относится к паре «ключ-значение» :method: GET
в статической таблице, и все участники группы знают, что оно означает.
Если ключ совпадает, но значение не совпадает, например etag: some-random-value
, HPACK
может повторно использовать ключ (в данном случае 34) и просто отправить обновлённое значение. Таким образом, имя заголовка не будет передаваться повторно.
“Так что же происходит с
some-random-value
?”
Он кодируется с помощью кодирования Хаффмана и отправляется как 34: huffman("some-random-value")
(псевдокод). Но что интересно, так это то, что весь заголовок, etag: some-random-value
добавляется в динамическую таблицу.
Динамическая таблица начинается с пустого состояния и заполняется по мере отправки новых заголовков (не входящих в статическую таблицу). Это делает HPACK
сохраняющим состояние, то есть и клиент, и сервер поддерживают собственные динамические таблицы на протяжении всего соединения.
Каждый новый заголовок, добавляемый в динамическую таблицу, получает уникальный индекс, начиная с 62 (поскольку индексы с 1 по 61 зарезервированы для статической таблицы). С этого момента вместо повторной передачи заголовка используется этот индекс. У такой настройки есть несколько ключевых особенностей:
Уровень соединения: динамическая таблица используется во всех потоках в рамках одного соединения. И сервер, и клиент поддерживают собственные копии.
Ограничение размера: по умолчанию максимальный размер динамической таблицы составляет 4 КБ (4096 октетов), который можно изменить с помощью параметра
SETTINGS_HEADER_TABLE_SIZE
в кадреSETTINGS
. Когда таблица заполняется, старые заголовки удаляются, чтобы освободить место для новых.
Кадр DATA
Если есть тело запроса, оно отправляется в кадре DATA
. Если размер тела превышает максимальный размер кадра (по умолчанию 16 КБ), оно разбивается на несколько кадров DATA
с одним и тем же идентификатором потока.
“Итак, где идентификатор потока во кадре?”
Хороший вопрос. Мы еще не говорили о структуре кадра.
Кадры в HTTP/2 - это не просто контейнеры для данных или заголовков. Каждый кадр содержит 9-байтовый заголовок. Это не тот заголовок HTTP, о котором мы говорили ранее, это заголовок кадра.
Итак, вот разбивка: у нас есть length, которая сообщает нам размер полезной нагрузки кадра (исключая сам заголовок кадра). Затем идет type, который определяет тип кадра (например, DATA
, HEADERS
, PRIORITY
и т. д.). Далее идут flags, которые предоставляют дополнительную информацию о кадре. Например, флаг (0x1) END_STREAM
сигнализирует о том, что в этом потоке больше не будет кадров.
И, наконец, у нас есть идентификатор потока. Это 32-битное число, которое идентифицирует, к какому потоку принадлежит кадр (старший бит зарезервирован и всегда должен быть установлен в 0).
— Но как насчёт порядка кадров в потоке? Что, если они приходят не по порядку?
Хотя идентификатор потока сообщает нам, к какому потоку относится кадр, он не определяет порядок кадров.
Мы найдём ответ на уровне TCP. Поскольку HTTP/2 работает поверх TCP, протокол гарантирует последовательную доставку пакетов. Даже если пакеты проходят по сети разными путями, TCP гарантирует, что они дойдут до получателя в том порядке, в котором были отправлены.
Это связано с проблемой блокировки HoL, о которой мы говорили ранее.
Когда сервер получает кадр HEADERS
, он создаёт новый поток с тем же идентификатором, что и у запроса.
Сначала он отправляет собственный HEADERS
кадр, который содержит статус ответа и заголовки (сжатые с помощью HPACK
). После этого тело ответа отправляется в DATA
кадрах. Благодаря мультиплексированию сервер может чередовать кадры из нескольких потоков, одновременно отправляя фрагменты разных ответов по одному соединению.
На стороне клиента кадры ответа сортируются по идентификатору потока. Клиент распаковывает HEADERS
кадр и обрабатывает DATA
кадры по порядку.
Все остается согласованным, даже когда одновременно активны несколько потоков.
Управление потоком
Когда поступает кадр с установленным флагом END_STREAM
(первый бит в поле флагов в заголовке кадра установлен в 1), это сигнал. Он сообщает получателю: «Всё, больше кадров в этом потоке не будет». В этот момент сервер может отправить запрошенные данные и завершить поток своим собственным флагом END_STREAM
в ответе.
Но завершение потока не закрывает всё соединение. Соединение остаётся открытым, чтобы другие потоки могли продолжать работу.
Если серверу нужно закрыть соединение, он использует кадр GOAWAY
. Это управляющий кадр уровня соединения, предназначенный для корректного завершения работы.
Когда сервер отправляет кадр GOAWAY
, он включает в него последний идентификатор потока, который он планирует обработать. По сути, сообщение говорит: «Я завершаю работу, потоки с более высокими идентификаторами не будут обработаны, но всё остальное, что выполняется, может завершиться в обычном режиме». Вот почему это считается корректным завершением работы.
После отправки GOAWAY
отправитель обычно ждёт некоторое время, чтобы получатель обработал сообщение и перестал отправлять новые потоки. Эта короткая пауза помогает избежать жёсткого сброса TCP (RST), который в противном случае немедленно прервал бы все потоки и вызвал хаос.
В наборе инструментов HTTP/2 есть и другие полезные функции. В процессе соединения обе стороны могут отправлять кадры WINDOW_UPDATE
для управления потоком, карды PING
для проверки, что соединение всё ещё активно, и кадры PRIORITY
для точной настройки приоритетов потоков. А если что-то пойдёт не так, RST_STREAM
кадры могут использоваться для отключения отдельных потоков без влияния на остальную часть соединения.
На этом мы завершаем рассказ об HTTP/2. Далее давайте посмотрим, как всё это работает в Go.
HTTP/2 в Go
Уточнение: код, содержащий части http-протоколов, основан на go 1.24, который еще не был выпущен. (спасибо @tumblerза замечание).
Возможно, вы даже не заметите этого, но пакет net/http
в Go уже поддерживает HTTP/2 «из коробки».
“Подожди, так это просто включено по умолчанию?”
Ну, и да, и нет.
Если ваш сервис работает по протоколу HTTPS, то HTTP/2, скорее всего, используется автоматически. Но если он работает по простому протоколу HTTP, то, вероятно, нет. Вот несколько распространённых сценариев, в которых HTTP/2 может не использоваться:
Ваш сервис работает по протоколу HTTP, используя простой
ListenAndServe
.Вы находитесь за прокси-сервером Cloudflare. В этом случае запросы пользователей к Cloudflare могут использовать HTTP/2, но соединение от Cloudflare к вашему сервису (источнику) обычно использует HTTP/1.1.
Вы находитесь за Nginx с поддержкой HTTP/2. Nginx выступает в качестве точки завершения TLS, расшифровывая запрос и повторно шифруя ответ, при этом перенаправляя все запросы к вашему сервису по HTTP/1.1.
Если вы хотите, чтобы ваш сервис напрямую использовал HTTP/2, вам нужно настроить его с помощью SSL/TLS.
Технически вы можете использовать HTTP/2 без TLS, но это не является хорошей практикой для внешнего трафика. Однако его можно использовать во внутренних средах, таких как микросервисы или частные сети.
Даже если вы используете HTTP/2 без TLS, клиент всё равно может по умолчанию использовать HTTP/1.1. Приведённое ниже решение не гарантирует, что клиенты (внешние сервисы) будут использовать HTTP/2 с вашим HTTP-сервером.
Давайте рассмотрим простой пример. Начнём с базового сервера, работающего по протоколу HTTP на порту 8080:
func getRequestProtocol(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Request Protocol: %s\n", r.Proto)
}
func main() {
http.HandleFunc("/", getRequestProtocol) // Root endpoint
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Printf("Error starting server: %s\n", err)
}
}
А вот базовый HTTP-клиент для его тестирования:
func main() {
resp, _ := (&http.Client{}).Get("http://localhost:8080")
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("Response:", string(body))
}
// Response: Request Protocol: HTTP/1.1
Здесь мы пропустим обработку ошибок, чтобы сосредоточиться на основной идее.
Из вывода видно, что и запрос, и ответ, как и ожидалось, используют HTTP/1.1. Без HTTPS или специальной конфигурации HTTP/2.
По умолчанию HTTP-клиент Go использует DefaultTransport
, который уже настроен для работы как с HTTP/1.1, так и с HTTP/2. Есть даже удобное поле под названием ForceAttemptHTTP2
, которое включено по умолчанию:
var DefaultTransport RoundTripper = &Transport{
...
ForceAttemptHTTP2: true, // <---
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
«Значит, наш клиент и сервер поддерживают HTTP/2? Почему они его не используют?»
Да, оба готовы к HTTP/2, но только через HTTPS. Для простого HTTP не хватает одного элемента: поддержки незашифрованного HTTP/2. Вот как можно включить незашифрованный HTTP/2 с помощью быстрой настройки:
var protocols http.Protocols
protocols.SetUnencryptedHTTP2(true)
// server
server := &http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(rootHandler),
Protocols: &protocols,
}
// client
client := &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
Protocols: &protocols,
},
}
// Response: Request Protocol: HTTP/2.0
Если включить незашифрованный HTTP/2 с помощью protocols.SetUnencryptedHTTP2(true)
, клиент и сервер будут взаимодействовать по HTTP/2 даже без HTTPS. Это небольшая настройка, но она позволяет всему встать на свои места.
Интересно, что Go также поддерживает HTTP/2 через пакет golang.org/x/net/http2
, который даёт вам ещё больше возможностей для контроля. Вот пример его настройки:
// server
h2s := &http2.Server{
MaxConcurrentStreams: 250,
}
h2cHandler := h2c.NewHandler(handler, h2s)
server := &http.Server{
Addr: ":8080",
Handler: h2cHandler,
}
// client
client := &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
return net.Dial(network, addr)
},
},
}
Таким образом, мы видим, что HTTP/2 на самом деле не нуждается в TLS, это просто протокол, который работает на основе HTTP/1.1. Однако в большинстве случаев, если на вашем сервере уже включён TLS, HTTP-клиент Go по умолчанию автоматически использует HTTP/2 и при необходимости возвращается к HTTP/1.1. Никаких дополнительных действий не требуется.
qrdl
Ни в http.Server (https://pkg.go.dev/net/http#Server), ни в http2.Server (https://pkg.go.dev/golang.org/x/net/http2#Server) нет поля Protocol, которое предлагается менять.
И вот что говорит официальная документация (https://pkg.go.dev/net/http#hdr-HTTP_2):
Статья - галлюцинация AI?
tumbler
Из комментария к оригиналу:
Короче ждем Go1.24
qrdl
Да, в 1.24 действительно есть/будет - https://pkg.go.dev/net/http@go1.24rc2#Server
В переводе по какой-то причине это упущено.
UPD. Уже и в перевод добавлено