Привет всем! Меня зовут Владислав Хирса, я — Software Engineer в Grid Dynamics. Сегодня я расскажу вам, как с помощью Node.js создать поток видеоданных. Статья будет полезна тем, кто еще только начинает разбираться в теме Streams in Node.js (в конце будет немного важной теории).
Создать поток данных в Node.js сейчас просто, но все ли мы понимаем о том, как работает эта абстракция?
Найти код вы можете по ссылке.
Начнем!
Сначала запустим наш сервер, находясь в папке проекта, командой npm start
.
Далее наш сервер запустится по адресу http://localhost:8000/
. Перейдя по ссылке, в вашем браузере должна появиться вкладка следующего содержания:
Здесь мы можем увидеть в действии наш проект. И теперь то, ради чего мы здесь — узнать, как все работает.
Файл index.js
Здесь мы создали простой сервер, функцией обратного вызова назначили функцию router
, получающую параметры request
и response
. Далее мы проверяем, имеем ли по полученному request.url
совпадению в нашем объекте runnersByRouts
по данному имени ключа. Если да — то вызываем соответствующую функцию, если нет — то возвращаем ответ об ошибке к клиенту.
При открытии вкладки в браузере по нашей ссылке на сервер поступает запрос с url /
и мы отдаем нашу страницу, файл index.html
.
Файл src/send-home-page.js
Сначала мы находим путь к нашей папке с помощью url.fileURLToPath(new URL('.', import.meta.url))
, назначаем тип контента, который собираемся отправить клиенту res.setHeader('Content-Type', 'text/html')
, далее создаем читаемый поток fs.createReadStream(__dirname + '../public/index.html')
и на последней строке вызываем наш поток с помощью функции pipeline()
.
Пока все предельно ясно, но чуть ниже мы обсудим как все работает немного подробнее.
Файл index.html
Рассмотрим несколько важных для нас атрибутов в html-элементе <video>
:
src="/video-stream"
— при рендеринге нашей страницы в браузере мы обращаемся к серверу по адресуhttp://localhost:8000/video-stream
и получаем наше видео.controls
— этот атрибут позволяет пользователю иметь контроль над видео (старт/пауза, звук и т.п.).preload="auto"
— в спецификации указано, что весь видеофайл может загрузиться даже если пользователь не будет использовать его. Но на практике все зависит от браузера и будет происходить скорее всего более динамично. Например, да — ваше видео будет загружено примерно на 1 мин. наперед и через каждые 5 секунд воспроизведенного видео дозагрузятся еще 5 секунд и т.д.
Поэтому нам поступает запрос с url /video-stream
и мы вызываем нашу функцию sendVideoFile
.
Файл src/send-video-file.js
В нашей функции sendVideoFile
все начинается с того, что:
- Мы создаем абсолютный путь к файлу — путь, который нам указали в параметре
pathToVideo
. Былоpublic/nature.mp4
— стало/your_folder/your_folder/project_folder/public/nature.mp4
. fs.statSync(resolvedPath).size
— узнаем размер файла в байтах.req.headers.range
— получаем параметрrange (bytes=12582912-)
, то есть то, с какой позиции нужно скачивать видео в байтах.
В зависимости от браузера и проигрывателя параметр range
может быть null
или, например, bytes=123456-
, поэтому у нас есть две различные функции для обработки этих на самом деле разных подходов.
Файл utils/create-video-stream.js
Здесь уже все просто — код схож с тем, который мы уже рассматривали в src/send-home-page.js
. Единственная разница в том, что мы назначаем обязательные заголовки Content-Type
и Content-Length
для того, чтобы браузер понимал, какого типа мы посылаем ему информацию и какого размера. Это необходимо как для корректной работы проигрывателя, так и для дальнейшего взаимодействия проигрывателя с сервером во время последующих транзакций данных.
И внизу также один из самых частых случаев — когда параметр range
существует.
Файл utils/create-video-stream-by-range.js
Здесь у нас есть функция getChunkData
, которая принимает входящий параметр range
и fileSize
осуществляет следующие шаги:
- Берет пару значений из
range = bytes=36634624-
и получает массивparts = [ '36634624', '' ]
. - Вычисляет значение
start = 36896768
,end = 86890916
,chunkSize = 49994149
.
Файл utils/get-chunk-data.js
Также createVideoStreamByRange
мы указываем обязательным статус ответа 206, а также Content-Range
— какую часть данных со всего видео мы отправляем, а также Accept-Ranges
— в каком формате данные, которые мы отправляем.
Еще несколько дополнений, которые полезно знать:
pipeline
почему лучше использоватьpipeline()
, а не очередьpipe()
при работе с потоками. В функцииpipeline()
последний аргумент — функция обратного вызова. Мы использовали ее в вышеперечисленных примерах кода. Если возникнет ошибка в любом из переданных потоков, то мы ее можем обработать в одном месте. Такжеpipeline()
самостоятельно закрывает все оконченные, но не закрытые запросы к серверу. Например, когда мы используемsomeReadStream(path).pipe(res)
, то после ошибки или окончания передачи данных запрос на сервер скорее всего не закроет, из-за чего возникают непонятные и очень веские ошибки и потеря оперативной памяти. Об этом вы можете почитать подробнее здесь.ES modules
— чтобы использовать импорт функционала с помощьюimports
, в js-файлах нам нужно указать вpackage.json
тип таким образом{ "type": "module" }
, но такие переменные, как__dirname
и__filename
не существуют вES modules
, а есть возможностьCommonJS
. С этим вы можете ознакомиться по ссылке из официальной документации. Так что найти пути мы можем следующим образом:
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
highWaterMark
— это значение размера внутреннего буфера, то есть количество данных в байтах, которые мы можем прочитать за один раз, то есть одинchunk
данных (по умолчанию он 64kB). Также значениеhighWaterMark
мы можем изменить при создании потокаfs.createReadStream(path, { highWaterMark: 2 })
, теперь мы считываем наш файл по два символа за раз, а также можем узнать его размер следующим образом:readStream.readableHighWaterMark
, значение по умолчанию будет 65536 байтов.
Как работает поток и отправка данных в деталях?
Сначала мы создаем поток по считыванию файла и назначаем его в смену readStream
, после этого используем его в функции pipeline()
, далее chunk
данных передается к потоку res
(т.е. response
, если полностью) и тогда res
его получает и отправляет клиенту с помощью res.write(chunk)
. Каждый раз когда мы читаем и передаем ему наши chunk
данные, то в конце, когда уже нет данных для считывания, вызывается событие end
для каждого потока и функция pipeline()
самостоятельно закрывает их. Что очень важно, в случае res
после последнего вызывается res.end()
и наш запрос к серверу успешно заканчивается.
На этом все, спасибо всем за внимание. Продуктивного вам кодинга 😉
Этот материал – не редакционный, это – личное мнение его автора. Редакция может не разделять это мнение.
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: