Краткое руководство по реализации видео стриминга на Go
Перевод статьи “Video Streaming with Go”
В 2004 году я впервые посмотрел видео в интернете. Это был клип, который я скачал через iTunes. Да, именно скачал — тогда всё было не так, как сейчас. С тех пор мы все привыкли к другому формату потребления видео: вместо загрузки теперь всё крутится через стриминг.
Неважно, смотришь ты что-то на YouTube или Netflix, где-то в мире всегда есть сервер, который отправляет тебе видео. Раньше я думал, что стриминг — это что-то сложное и непонятное. Мне казалось, что для этого нужны какие-то специальные протоколы, разбираться в которых мне было лень. Но, как оказалось, я ошибался — сделать свой стриминг-сервер не так уж и сложно. Конечно, это не значит, что у крупных платформ вроде Netflix нет своих сложностей — у них всё куда масштабнее.
В этой статье я покажу, как можно сделать простой HTTP-сервер для потокового видео на Go, который будет работать по стандарту RFC 7233.
Архитектура
Прежде чем переходить к коду на Go, давай разберёмся, как вообще работает потоковое видео в современных браузерах. Это стандартное поведение, так что в этом посте я не буду касаться HTML.
Если коротко, стриминг позволяет клиентам запрашивать не весь файл целиком, а только определённые его части. Вот как это работает: клиент отправляет HTTP-запрос на файл, добавляя заголовок Range
. В этом заголовке указывается, какой именно кусок файла нужен. Сервер, в свою очередь, отвечает, отправляя запрошенную часть, и добавляет в ответ заголовок Content-Range
, чтобы клиент понимал, какой именно фрагмент он получил.
Чтобы было понятнее, вот диаграмма, которая наглядно показывает, как это всё работает:
Код
Для того чтобы организовать потоковую передачу видео в формате MP4, мне нужно две основные вещи:
- Хранилище видео, которое умеет возвращать определённые фрагменты видеофайла.
- Конечная точка (endpoint), которая будет обрабатывать запросы клиента и возвращать запрошенные части файла.
Начну с интерфейса для хранилища видео. У этого интерфейса будет всего одна функция, которая будет отвечать за поиск и возврат нужного фрагмента файла. Вот как выглядит интерфейс VideoStreamer
:
1type VideoStreamer interface {
2 // начало и конец передаются в байтах. 1024 — это 1 килобайт
3 Seek(key string, start, end int) ([]byte, error)
4}
Далее я добавлю новый тип структуры с именем MockVideoStreamer
. У этого типа будет одно поле с именем Store
, в котором будут храниться видеоданные. Вот код для этого типа:
1// MockVideoStreamer пока просто заглушка
2// для реализации интерфейса VideoStreamer
3type MockVideoStreamer struct {
4 Store map[string][]byte
5}
Определив тип структуры, я обновлю тип MockVideoStreamer
для реализации интерфейса VideoStreamer
. Метод Seek
проверит, существует ли указанный ключ видео в поле Store
, и если да, то вернёт запрошенные байты. Вот код этой реализации:
1// Seek извлекает фрагмент видео из хранилища на основе ключа и диапазона байто
2func (m *MockVideoStreamer) Seek(key string, start, end int) ([]byte, error) {
3 // Извлекаем видеоданные из хранилища с помощью ключа
4 videoData, exists := m.Store[key]
5 if !exists {
6 return nil, fmt.Errorf("video not found")
7 }
8
9 // Убеждаемся, что диапазон находится в пределах видеоданных
10 if start < 0 || start >= len(videoData) {
11 return nil, fmt.Errorf("start byte %d out of range", start)
12 }
13
14 if end < start || end >= len(videoData) {
15 end = len(videoData) - 1 // Корректируем end до последнего байта, если он выходит за рамки
16 }
17
18 // Возвращаем запрошенный фрагмент видеоданных
19 return videoData[start : end+1], nil
20}
Следующий шаг — это добавить HTTP-обработчик, который позволит нам сделать логику стриминга доступной для клиентов. Этот обработчик будет принимать запросы от клиентов, анализировать заголовок Range
, и возвращать запрошенные фрагменты видео.
HTTP - Обработчик
В этом разделе я расскажу, как использовать HandlerFunc
из пакета http
стандартной библиотеки для организации потоковой передачи. Это довольно стандартный подход. HandlerFunc
будет создаваться через фабричную функцию, которая принимает три параметра:
streamer
: это объект типаVideoStreamer
, который будет использоваться для получения нужных частей видеофайла.videoKey
: уникальный идентификатор видео. Он передаётся как параметрkey
в методSeek
.totalSize
: общий размер видеофайла.
Я разбил функциональность HandlerFunc
на три этапа:
- Чтение и подготовка данных из запроса клиента. Обработчик сначала читает заголовок
Range
из запроса. Затем он извлекает начальный и конечный индексы байтов, которые запросил клиент. - Это делается через разделение значения заголовкаRange
и преобразование строк в числа (типint
). Вот как это выглядит в коде: - Загрузка запрошенных байтов. На этом этапе обработчик использует
streamer
для получения нужных байтов видео. - Отправка байтов клиенту. Наконец, обработчик отправляет запрошенные байты обратно клиенту, добавляя нужные заголовки.
Такой подход позволяет организовать потоковую передачу видео эффективно и понятно. Начнем писать код:
1func VideoStreamHandler(streamer VideoStreamer, videoKey string, totalSize int) http.HandlerFunc {
2 return func(w http.ResponseWriter, r *http.Request) {
3
4 rangeHeader := r.Header.Get("Range")
5 var start, end int
6
7 if rangeHeader == "" {
8 // Default to first 1MB
9 start = 0
10 end = 1024*1024 - 1
11 } else {
12
13 // Проанализируйте заголовок диапазона: "bytes=start-end"
14 rangeParts := strings.TrimPrefix(rangeHeader, "bytes=")
15 rangeValues := strings.Split(rangeParts, "-")
16 var err error
17
18 // Получить начальный байт
19 start, err = strconv.Atoi(rangeValues[0])
20 if err != nil {
21 http.Error(w, "Invalid start byte", http.StatusBadRequest)
22 return
23 }
24
25 // Получить конечный байт или установить значение по умолчанию i
26 if len(rangeValues) > 1 && rangeValues[1] != "" {
27 end, err = strconv.Atoi(rangeValues[1])
28 if err != nil {
29 http.Error(w, "Invalid end byte", http.StatusBadRequest)
30 return
31 }
32 } else {
33 end = start + 1024*1024 - 1 // По умолчанию 1 МБ
34 }
35 }
36
37 // Убедиться, что конец находится в пределах общего размера видео
38 if end >= totalSize {
39 end = totalSize - 1
40 }
41
42 ...
43 }
44}
Во второй части я передам переменные videoKey
, start
и end
в VideoStreamer
методу Seek
для получения запрошенных байтов. Вот код, выполняющий эту задачу:
1func VideoStreamHandler(streamer VideoStreamer, videoKey string, totalSize int) http.HandlerFunc {
2 return func(w http.ResponseWriter, r *http.Request) {
3
4 ...
5 // Извлеките видеоданные
6 videoData, err := streamer.Seek(videoKey, start, end)
7 if err != nil {
8 http.Error(w, fmt.Sprintf("Error retrieving video: %v", err), http.StatusInternalServerError)
9 return
10 }
11
12 ...
13 }
14}
Как только запрошенные байты загрузятся в память, можно переходить к третьей части — отправке ответа клиенту. Первым делом я отправлю набор заголовков, которые нужны для корректной работы потоковой передачи. Вот список заголовков:
Content-Range
: Указывает диапазон байтов и общий размер файла, который будет отправлен в ответе.Accept-Ranges
: Указывает, поддерживает ли сервер запросы диапазона.Content-Length
: Задает размер тела ответа в байтах.Content-Type
: Этот заголовок указывает тип данных, которые отправляются (например, видео или аудио). Он помогает клиенту (будь то браузер или медиаплеер) понять, как правильно обрабатывать содержимое. Без него клиент может просто не распознать, что за данные ему пришли
Затем я установлю код состояния ответа Partial Content (206) и запишу байты, возвращаемые интерфейсом VideoStreamer
, в качестве тела ответа.
1func VideoStreamHandler(streamer VideoStreamer, videoKey string, totalSize int) http.HandlerFunc {
2 return func(w http.ResponseWriter, r *http.Request) {
3 ...
4 // Установите заголовки и подайте фрагмент видео
5 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
6 w.Header().Set("Accept-Ranges", "bytes")
7 w.Header().Set("Content-Length", fmt.Sprintf("%d", len(videoData)))
8 w.Header().Set("Content-Type", "video/mp4")
9 w.WriteHeader(http.StatusPartialContent)
10
11 _, err = w.Write(videoData)
12 if err != nil {
13 http.Error(w, "Error streaming video", http.StatusInternalServerError)
14 }
15 }
16}
Полный исходный код этого обработчика вы можете найти в разделе “Ссылки” ниже.
Собираем все вместе
Чтобы протестировать код, который я написал ранее, я добавлю main
функцию. Вот что она будет делать:
- Скачивание MP4-файла. Я возьму MP4-файл с какого-нибудь сайта-хранилища (например, облачного сервиса) и создам экземпляр MockVideoStreamer, который будет хранить этот файл. Это нужно для имитации работы с реальным видео.
- Настройка маршрута. Я привяжу
HandlerFunc
, который возвращает функцияVideoStreamHandler
, к маршруту/video
. Это значит, что когда клиент обратится по адресу/video
, сервер начнёт потоковую передачу видео. - Запуск сервера. Наконец, я запущу веб-сервер, который будет слушать порт 8080. Это стандартный порт для локальной разработки.
Вот как это выглядит в коде:
1const url = "https://download.samplelib.com/mp4/sample-5s.mp4"
2
3func main() {
4 data, err := DownloadBytes(url)
5 if err != nil {
6 fmt.Println("Error downloading:", err)
7 return
8 }
9
10 log.Println("length of data:", len(data))
11 streamer := &MockVideoStreamer{
12 Store: map[string][]byte{"video-key": data},
13 }
14
15 http.HandleFunc("/video", VideoStreamHandler(streamer, "video-key", len(data)))
16 http.ListenAndServe(":8080", nil)
17}
И наслаждаемся работой:
Заключение
В этом посте я показал, как можно использовать запросы с диапазонами (Content-Range) для потоковой передачи данных. Также я хотел продемонстрировать, что Go способен на такие вещи “из коробки”, без необходимости подключать сторонние библиотеки. Однако у этого кода есть несколько ограничений:
- Нет поддержки нескольких диапазонов: Если клиент запрашивает несколько частей файла одновременно, этот код не сможет обработать такой запрос.
- Данные загружаются в память: В текущей реализации весь файл загружается в оперативную память. Более правильным подходом было бы использовать io.Reader для потокового чтения данных, чтобы не нагружать память.
- Нет поддержки запросов на полный контент: Код работает только с частичными запросами (range requests), а запросы на полный файл не обрабатываются.
- Не подходит для высоконагруженных систем: Если вы планируете использовать этот код в среде с большим количеством запросов, стоит пересмотреть архитектуру. Этот код не оптимизирован для таких сценариев.
Спасибо, что дочитали до конца! Надеюсь, этот пост был полезен, и вы смогли почерпнуть что-то новое. Если у вас есть вопросы или предложения по улучшению, дайте знать! 😊
Ссылки
- RFC 7233
- Полный исходный код