Протокол HTTP/2 и его использование в Golang (перевод + исправления) | Амплеев Евгений - Agile Coach / Full stack web developer. Персональный блог.
Читаете:
Протокол HTTP/2 и его использование в Golang
Поделиться:

Протокол HTTP/2 и его использование в Golang

Avatar
Автор статьи: Yevgeniy Ampleev
12 марта 2025 в 18:32
Http2 протокол и его использование в Golang

Всем привет! Последнее время я увлекся Go и даже закончил продвинутый курс в Yandex Practicum по этому языку. Мне случайно попалась достаточно подробная статья с описанием принципов работы HTTP/2 протокола и его применение в Go и захотелось как следует в этом разобраться (т.к. ходит много слухов об эффективности этого протокола + он еще и является основой для gRPC, что некоторые считают киллер-фичей Go). В оригинале статьи также были обнаружены неточности и неработающий код, что побудило написать собственный перевод, разобраться в теме и заодно исправить ошибки в оригинале. Поехали.

Эта статья больше посвящена теории, поэтому предупреждаем, что в ней много текста. Мы сосредоточимся на понимании HTTP/2, а затем вкратце коснемся его использования в Go. Так что берите кофе, устраивайтесь поудобнее, и давайте разберем все по полочкам.

Почему HTTP/2?

HTTP/2 - это значительное усовершенствование по сравнению с HTTP/1.1, и в наши дни он практически везде используется по умолчанию. Если вы когда-нибудь открывали Chrome DevTools, чтобы проверить сетевые запросы, то, скорее всего, вы уже видели HTTP/2-соединения в действии.

HTTP/2 уже практически норма для всех современных браузеров
HTTP/2-соединения через Chrome DevTools на этом сайте

Но почему HTTP/2 так важен? Как обстоят дела с HTTP/1.1?

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

Зачем это было нужно: до HTTP/1.1 запросы отправлялись по порядку и ответы тоже должны были возвращаться в том же порядке.

Процесс отправки запросов в HTTP/1.1

Схема работы до HTTP/1.1 (При условии примерно равного времени ответа для каждого запроса и отсутствии заминок в сети)

Если один ответ задерживался (например, если серверу требовалось дополнительное время на его обработку), то все остальные в очереди вынуждены были ждать.

Процесс отправки запросов в HTTP/1.1

Схема работы до HTTP/1.1 (При условии задержки ответа по одному из запросов)

Это также происходит, если в сети происходит «заминка», которая задерживает только один запрос. Весь конвейер отклика замирает, пока не пройдет этот отложенный запрос.

Процесс отправки запросов в HTTP/1.1

Схема работы до HTTP/1.1 (При условии «заминки» в сети по одному из запросов)

Эта проблема называется блокировкой по принципу Head-of-Line (HoL).

Чтобы обойти это ограничение, клиенты HTTP/1.1 открывают несколько TCP-соединений с одним и тем же сервером, позволяя запросам проходить более свободно и синхронно.

И, хотя это работало, это было не совсем эффективно:

  • Больше соединений означало больше ресурсов, используемых как на стороне клиента, так и на стороне сервера.
  • TCP приходится проходить через процесс рукопожатия для каждого соединения, что увеличивает задержку.

- Итак, устраняет ли HTTP/2 эту проблему?

- Это так... ну, почти.

HTTP/2 берет одно соединение и разделяет его на несколько независимых Потоков. Каждый Поток имеет свой уникальный идентификатор, называемый идентификатором Потока, и эти Потоки могут работать параллельно. Такая схема позволяет решить проблему блокировки Head-of-Line (HoL) на прикладном уровне (где находится HTTP). Если один Поток задерживается, это не мешает остальным двигаться вперед.

изображение_нескольких_потоков_через_одно_соединение_http2

Схема нескольких потоков через одно соединение в HTTP/2 протоколе

Но 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 продолжается как обычно: устанавливаются ключи шифрования, проверяются сертификаты и так далее.

Connection Preface

После завершения рукопожатия клиент отправляет нечто, называемое Connection Preface. Это нечто начинается с очень специфической последовательности из 24 байт:

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

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

Сразу после отправки этой последовательности, клиент посылает Фрэйм SETTINGS. Он не привязан к какому-либо Потоку; это Фрэйм управления на уровне соединения - сообщение серверу, в котором говорится: «Вот мои предпочтения». Сюда входят такие настройки, как параметры управления Потоком, максимальный размер Фрэйма и т. д.

Схема обмена сообщениями с информацией о допустимых настройках между клиентом и сервером при использовании протокола HTTP2

Схема обмена сообщениями с информацией о допустимых настройках между клиентом и сервером при использовании протокола HTTP2

Сервер понимает, какие настройки допустимы для клиента, и отвечает собственным Connection Preface, которое включает в себя Фрейм SETTINGS.

После завершения обмена, можно приступать к настройке соединения.


HEADERS Фрейм & HPACK Сжатие

Теперь клиент готов отправить запрос. Он создает новый Поток с уникальным идентификатором, называемым ID Потока. ID Потока для Потоков, инициированных клиентом, всегда нечетное число - 1, 3, 5,...

Вы можете удивиться, почему идентификаторы потоков нечетные, а не пронумерованы, как 1, 2, 3... На самом деле здесь действует небольшое правило:

  • Потоки с нечетными номерами предназначены для запросов, инициированных клиентом.
  • Потоки с четными номерами предназначены для сервера, часто для инициируемых сервером функций, таких как server push.
  • Stream ID 0 - особенный, он используется только для управляющих Фрэймов уровня соединения (не уровня Потока), которые применяются ко всему соединению.

Когда поток готов, клиент отправляет Фрэйм HEADERS.

Этот Фрэйм содержит всю информацию о заголовках, которую вы ожидаете увидеть - эквивалент строки и заголовков запроса HTTP/1.1 (вспомните GET / HTTP/1.1 и все, что за этим следует). Но заголовки структурированы и передаются немного по-другому.

  • Структура: HTTP/2 вводит Псевдозаголовки, которые помогают определить метод, путь и статус. За ними следуют привычные заголовки User-Agent, Content-Type,...
  • Передача: Заголовки сжимаются с помощью алгоритма HPACK и передаются в двоичном формате.

Псевдозаголовоки? Сжатие HPACK? Что здесь происходит?

Давайте разберемся с этим, начав с Псевдозаголовков.

Если вы заглядывали в DevTools или любой другой инспектор Chrome, то, возможно, это покажется вам знакомым.

В HTTP/2 Псевдозаголовки - это способ хранить специальные заголовки отдельно от обычных. Эти специальные заголовки, такие как :method, :path, :scheme и :status, всегда идут первыми. После них следуют обычные заголовки, такие как Accept, Host и Content-Type, в обычном формате.

Сравнение формата заголовков HTTP1 и HTTP2

Сравнение формата заголовков в HTTP1 и в HTTP2

В 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 в пакете Go net/http2.

Статическая таблица с популярными значениями HTTP-заголовков

Статическая таблица с популярными значениями HTTP-заголовков

Допустим, вы отправляете GET-запрос с заголовком :method: GET.

Вместо того чтобы передавать весь заголовок, HPACK может передать только число 2. Это единственное число относится к паре ключ-значение :method: GET в статической таблице, и протокол обеспечивает корректность интерпретации этого значения.

Если ключ совпадает, а значение нет, например etag: some-random-value, HPACK может повторно использовать ключ (который в данном случае равен 34) и просто отправить обновленное значение. Таким образом, строка заголовка не передается полностью.

Так что же происходит со случайным значением?

Оно кодируется с помощью кодировки Хаффмана и отправляется в виде 34: huffman(«some-random-value») (псевдокод). Но что интересно, весь заголовок, etag: some-random-value, добавляется в Динамическую таблицу.

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

Каждый новый заголовок, добавленный в Динамическую таблицу, получает уникальный индекс, начиная с 62 (поскольку 1-61 зарезервированы для Cтатической таблицы). С этого момента этот индекс используется вместо повторной передачи заголовка. У этой системы есть несколько ключевых особенностей:

  • Уровень соединения: Динамическая таблица разделяется между всеми потоками в одном соединении. И сервер, и клиент поддерживают свои собственные копии.
  • Ограничение размера: По умолчанию максимальный размер Динамической таблицы установлен на 4 КБ (4 096 октетов), который может быть изменен с помощью параметра ETTINGS_HEADER_TABLE_SIZE во Фрэйме SETTINGS. Когда таблица переполняется, старые заголовки удаляются, чтобы освободить место для новых.

Фрэйм данных

Если есть тело запроса, оно отправляется в DATA-Фрэймах. А если тело превышает максимальный размер Фрэйма (по умолчанию 16 КБ), оно разбивается на несколько DATA-Фрэймов, каждый из которых имеет один и тот же идентификатор потока.

Одно TCP-соединение, передающее несколько потоков

Схема одного TCP-соединения, передающего несколько потоков

- Так где же идентификатор Потока во Фрэйме?

- Хороший вопрос. Мы еще не говорили о структуре Фрэйма.

Фрэймы в HTTP/2 - это не просто контейнеры для данных или заголовков. Каждый Фрэйм включает в себя 9-байтовый заголовок. Это не тот заголовок HTTP, о котором мы говорили ранее, это Заголовок Фрэйма.

Разбивка заголовков фреймов в HTTP/2

Разбивка Заголовков Фрэймов в HTTP/2

Итак, вот разбивка: у нас есть Длина, которая говорит нам о размере полезной нагрузки кадра (не считая самого заголовка Фрэйма). Затем Тип Фрэйма (например, DATA, HEADERS, PRIORITY и так далее). Далее идут Флаги, которые предоставляют дополнительные сведения о Фрэйме. Например, Флаг END_STREAM (0x1) сигнализирует о том, что в этом Потоке больше не будет Фрэймов.

И наконец, у нас есть Идентификатор потока. Это 32-битное число, которое определяет, к какому Потоку принадлежит Фрэйм (старший бит зарезервирован и всегда должен быть установлен в 0).

- Но как быть с порядком Фрэймов в Потоке? Что, если они поступают не по порядку?

- Да, хотя Идентификатор Потока говорит нам, к какому Потоку принадлежит Фрэйм, он не определяет порядок Фрэймов.

Ответ мы найдем на уровне TCP. Поскольку HTTP/2 работает через TCP, протокол гарантирует последовательную доставку пакетов. Даже если пакеты идут по сети разными путями, TCP гарантирует, что они попадут к получателю именно в том порядке, в котором были отправлены.

Это связано с проблемой блокировки HoL, которую мы обсуждали ранее.

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

Он начинает с отправки своего собственного Фрэйма HEADERS, который содержит статус ответа и заголовки (сжатые с помощью HPACK). После этого во Фрэймах с Типом DATA отправляется тело ответа. Благодаря мультиплексированию сервер может перемежать Фрэймы из нескольких Потоков, отправляя фрагменты разных ответов по одному и тому же соединению одновременно.

Все остается выровненным, даже если одновременно активны несколько Потоков.


Управление потоком

Когда приходит Фрэйм с установленным Флагом END_STREAM (первый бит поля flags в заголовке Фрэйма устанавливается в 1), это сигнал. Он сообщает приемнику: «Больше Фрэймов в этом Потоке не будет». В этот момент сервер может отправить обратно запрошенные данные и завершить Поток своим собственным флагом END_STREAM в ответе.

Но завершение Потока не приводит к закрытию всего соединения. Соединение остается открытым, чтобы другие Потоки могли продолжать свою работу.

Если серверу необходимо самостоятельно закрыть соединение, он использует Фрэйм GOAWAY. Это Управляющий Фрэйм на уровне соединения, предназначенный для плавного завершения работы.

Когда сервер посылает Фрэйм GOAWAY, он включает в него Идентификатор последнего Потока, который он планирует обработать. По сути, это сообщение говорит: «Я завершаю работу, все потоки с более высокими идентификаторами не будут обработаны, но все остальные, которые находятся в процессе, могут завершить работу нормально.» Вот почему это считается graceful shutdown.

После отправки GOAWAY отправитель обычно выжидает некоторое время, чтобы дать получателю возможность обработать сообщение и прекратить отправку новых потоков. Эта короткая пауза помогает избежать резкого сброса TCP (RST), который в противном случае немедленно уничтожит все потоки и вызовет хаос.

В наборе инструментов HTTP/2 есть и несколько других удобных средств. На протяжении всего соединения любая сторона может отправлять Фрэймы WINDOW_UPDATE для управления Потоком, Фрэймы PING для проверки, живо ли еще соединение, и Фрэймы PRIORITY для точной настройки приоритетов Потоков. А если что-то пойдет не так, Фрэймы RST_STREAM могут отключить отдельные Потоки, не затрагивая остальную часть соединения.

На этом мы закончим рассказ об HTTP/2. Далее давайте посмотрим, как все это работает в Go.


HTTP/2 в Go (Golang)

Вы можете даже не заметить этого, но пакет net/http в Go уже поддерживает HTTP/2 из коробки.

- Подождите, значит, он просто включен по умолчанию?

- И да, и нет.

Если ваш сервис работает по HTTPS, то HTTP/2, скорее всего, используется автоматически. Но если он работает на обычном HTTP, то, скорее всего, нет. Вот несколько распространенных сценариев, в которых HTTP/2 может не сработать:

  • Ваша служба работает на обычном HTTP, используя простой ListenAndServe.
  • Вы находитесь за прокси-сервером Cloudflare. В этом случае запросы от пользователей к Cloudflare могут использовать HTTP/2, но соединение от Cloudflare к вашему сервису (origin) обычно придерживается HTTP/1.1.
  • Вы находитесь за Nginx с включенным HTTP/2. Nginx выступает в качестве точки завершения TLS, расшифровывая запрос и повторно шифруя ответ, в то время как все передается вашему сервису по HTTP/1.1.

Смешанные протоколы: HTTP/2 и HTTP/1.1

Смешанные протоколы: HTTP/2 и HTTP/1.1

Если вы хотите, чтобы ваш сервис использовал HTTP/2 напрямую, вам нужно настроить его с помощью SSL/TLS.

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

Даже если вы запустите HTTP/2 без TLS, клиент все равно может по умолчанию использовать HTTP/1.1. Приведенное ниже решение не гарантирует, что клиенты (внешние службы) будут использовать HTTP/2 с вашим HTTP-сервером.

Давайте попробуем на простом примере увидеть это в действии. Начнем с базового сервера, работающего по простому HTTP на порту 8080:


package main

import (
	"fmt"
	"net/http"
)

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-клиент для проверки:


package main

import (
	"fmt"
	"io"
	"net/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?

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

Важно! Для корректной работы необходима версия языка не ниже go 1.24.1

Код сервера:


package main

import (
	"fmt"
	"log"
	"net/http"
)

var protocols http.Protocols

func getRequestProtocol(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Request Protocol (this is response from the server handler): %s", r.Proto)
}

func main() {
    protocols.SetUnencryptedHTTP2(true)
    server := &http.Server{
        Addr:      ":8080",
        Handler:   http.HandlerFunc(getRequestProtocol),
        Protocols: &protocols,
    }
    err := server.ListenAndServe()
    if err != nil {
        log.Fatal(err)
    }
}

Код клиента:


package main

import (
	"fmt"
	"io"
	"net/http"
)

var protocols http.Protocols

func main() {
	protocols.SetUnencryptedHTTP2(true)
	// создали клиент, который будет отправлять запросы по HTTP/2
	client := &http.Client{
		Transport: &http.Transport{
			ForceAttemptHTTP2: true,
			Protocols:         &protocols,
		},
	}
	// готовим запрос
	request, err := http.NewRequest(http.MethodGet, "http://localhost:8080", nil)
	if err != nil {
		fmt.Println(err)
	}
	response, err := client.Do(request)
	if err != nil {
		fmt.Println(err)
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println("string(body)=", string(body))
	fmt.Println("response.Proto=", response.Proto)
	/*
		string(body)= Request Protocol (this is response from the server handler): HTTP/2.0
		response.Proto= HTTP/2.0
	*/
}

                        

Включив незашифрованный HTTP/2 с помощью protocols.SetUnencryptedHTTP2(true), клиент и сервер теперь общаются по HTTP/2, даже без HTTPS. Это небольшая настройка, но благодаря ей все встает на свои места.

Интересно, что Go также поддерживает HTTP/2 через пакет golang.org/x/net/http2, что дает вам еще больше контроля. Вот пример с использованием этого пакета:

Код сервера:


package main

import (
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
	"log"
	"net/http"
)

func home(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Моя домашняя страница!"))
}

func main() {

	mux := http.NewServeMux()
	mux.HandleFunc("/", home)

	h2s := &http2.Server{
		MaxConcurrentStreams: 250,
	}
	h2cHandler := h2c.NewHandler(mux, h2s)

	server := &http.Server{
		Addr:    ":8080",
		Handler: h2cHandler,
	}
	log.Fatal(server.ListenAndServe())

}
                        

Код клиента:


package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"golang.org/x/net/http2"
	"io"
	"net"
	"net/http"
)

var protocols http.Protocols

func main() {
	protocols.SetUnencryptedHTTP2(true)
	client := &http.Client{
		Transport: &http2.Transport{
			AllowHTTP: true,
			DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
				return net.Dial(network, addr)
			},
		},
	}
	// готовим запрос
	request, err := http.NewRequest(http.MethodGet, "http://localhost:8080", nil)
	if err != nil {
		fmt.Println(err)
	}
	response, err := client.Do(request)
	if err != nil {
		fmt.Println(err)
	}
	defer response.Body.Close()
	body, err := io.ReadAll(response.Body)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println("string(body)=", string(body))
	fmt.Println("response.Proto=", response.Proto)
	/*
		string(body)= Моя домашняя страница!
		response.Proto= HTTP/2.0
	*/
}

Это показывает, что HTTP/2 на самом деле не нужно полагаться на TLS, это просто протокол, который работает поверх основы HTTP/1.1. Однако в большинстве случаев, если на вашем сервере уже включен TLS, HTTP-клиент Go по умолчанию будет автоматически использовать HTTP/2 и при необходимости возвращаться к HTTP/1.1. Никаких дополнительных действий не требуется.



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

    Возможно, вам будет интересно

    Image
    25 марта 2025 в 10:22
    heart interface icon137

    Принятие пользовательских типов в Go

    В статье рассказывается о концепции пользовательских типов в Go и о корректном ее применении

    Image
    Yevgeniy Ampleev
    Image
    19 марта 2025 в 07:50
    heart interface icon159

    Где и когда использовать iota в Golang?

    В данной статье рассказывается о том как правильно использовать iota в Golang.

    Image
    Yevgeniy Ampleev
    arrow-up icon