Оптимизация псевдостриминга FLV-видео

в 9:41, , рубрики: actionscript, Flash-платформа, flv, streaming, метки: , ,

Один из проектов нашей компании — это сервис online-видео, аналогичный youtube. Для вещания и реализации возможностей стриминга используется замечательный веб-сервер nginx с модулем ngx_http_flv_module.

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

Проблемы с дисковой подсистемой решили просто установкой 10-ого рейда. Но с сетью надо было что-то делать и появилась идея загружать файлы для просмотра не одним потоком, а дискретно, кусками одинакового размера. Благо во flash’e было реализовано все, что нужно. Согласно документации в объекте NetStream есть метод appendBytes() с нужным функционалом. Вот выдержка из официальной документации:

“Передает объект ByteArray в экземпляр NetStream для воспроизведения. Вызовите этот метод для объекта NetStream в режиме создания данных. Чтобы перевести объект NetStream в режим создания данных, вызовите метод NetStream.play(null) для экземпляра NetStream, созданного для объекта NetConnection, который подключен к null. Метод appendBytes() нельзя вызывать для объекта NetStream, который не находится в режиме создания данных, в противном случае выдается исключение.”

Казалось бы, берем класс Loader, прописываем заголовок Range, но нас тут поджидает мина от разработчиков Adobe. В стандартных классах Action Script, которые работают с протоколом HTTP, недоступно использование заголовка Range. Опять же, из официальных источников становится известно, что заголовок Range заблокирован в коде классов, работающих в протоколом HTTP, из соображений безопасности еще в версии плеера 9. Но радовало одно: что блокировка не нативная, а в самих классах, из чего следовало, что можно написать свой HTTP-клиент, используя базовый класс сокета Soсket. Благо в закромах google нашелся уже готовый класс HTTPClientLib. Приведенный ниже код загружает первый мегабайт видеофайла и воспроизводит его:

private var ns:NetStream;
private var video:Video;
private var meta:Object;
private var client:HttpClient;
private var filesize:Number = 0;
private var loadedBytes:Number = 0;
private var data:ByteArray = new ByteArray();
private var datadelta:Number = 1024*1024;

//Переменная содержит ссылку на видео-файл
private var file:String = %ссылка на видео файл%;

private function init():void{
   //Инициируем объект, который получит метаданные загруженного видео
   var nsClient: Object = {};
  //Собственно функция обработчик события на получение метаданных
   nsClient.onMetaData = metaDataHandler;                 
   //Объект соединения с сервером; в нашем варианте он создается с ссылкой на объект null так как мы не работаем с медиа-сервером
   var nc:NetConnection = new NetConnection();
   nc.connect(null);

   //Собственно объект, который будет управлять получением данных и воспроизведением самого видео
   ns = new NetStream(nc);
   ns.client = nsClient;
   //Событие на обработки изменения статуса
   ns.addEventListener(NetStatusEvent.NET_STATUS,netStatusHandler);
   //Обработчик ошибок сети
   ns.addEventListener(IOErrorEvent.IO_ERROR,nsIOErrorHandler);
   //Визуальный компонент который будет показывать видео
   video = new Video();
   video.attachNetStream(ns);
   // Сглаживание картинки. Необязательный параметр, но на низком качестве видео пригодится
   video.smoothing = true;
   uic.addChild(video);

   //Наш объект, который будет грузить данные вместо NetStream
   client = new HttpClient();    
   //Старт загрузки первого мегабайта видео
   loadData();
   //Включаем режим создания данных объекта NetStream
   ns.play(null);

}

private function loadData():void{
   //Объект с ссылкой на видео
   var uri:URI = new URI(file);
   //Объект, в котором можно объявить заголовки
   var request:HttpRequest = new Get();
   //maxdata показывает конечную границу диапазона загрузки
   //loadedBytes - это начальная граница диапазона
   var maxdata:Number = loadedBytes+datadelta;
   //Формируем заголовок с учетом того, что размер файла может быть не кратен нашей дельте загрузки
   if (maxdata>=filesize and filesize>0){
       request.addHeader('Range','bytes='+loadedBytes+'-');    
   } else {
       request.addHeader('Range','bytes='+loadedBytes+'-'+maxdata);
   }
   //Обработчик события на получение данных, по мере загрузки сохраняем в буфер data
   //В данном случае загружаемые данные лучше сохранять в
   //временный буфер потому что при прямой отправке в объект NetStream воспроизведение
   //начинает прыгать по временной шкале
   client.listener.onData = function(e:HttpDataEvent):void {
       var bytes:ByteArray = new ByteArray();
       bytes = e.bytes;
       bytes.position = 0;
       data.writeBytes(bytes);                    
   };

   //Обработчик события на конец загрузки файла
   client.listener.onComplete = function(e:HttpResponseEvent ):void{
       //Увеличиваем нижнюю границу на длину загруженных данных
       loadedBytes+=data.length;
       Получаем размер файла из заголовка ответа
       filesize = Number(e.response.header.getValue('Content-Length'))/1024;
       //Добавляем загруженные данные в воспроизведение
       ns.appendBytes(data);
       //Очищаем буфер
       data.clear();
      //Тригер состояния загрузки. О нем будет сказано ниже   
      inLoaded = false;
   };
   //Отправляем запрос на данные            
   client.request(uri,request);
}

Один важный момент для работы кода: объект NetStram должен быть в режиме создания данных, который включается так NetStream.play(null). Это должно быть сделано до того как поступят первые видеоданные.

Дальше по мере надобности следует подгружать оставшиеся части файла такими же кусками по 1 мегабайту. Размер в 1 мб (равен примерно 15 секундам видео) был получен экспериментально в ходе большого количества тестов и для нашей системы. Подгрузкой управляет таймер, который выполняет следующий код по событию.

if ((!inLoaded) && (ns.bufferLength <= Math.ceil(loadtime+timeDelta)) && (loadedBytes < filesize)){
       inLoaded = true;
       loadData();
}

Загрузку будем запускать, при соблюдении следующих условий:
inLoaded = false — индикатор состояния, потока true — грузятся данные, false — нет;
ns.bufferLength <= Math.ceil(loadtime+timeDelta) — в буфере воспроизведения осталось времени меньше или равно времени предыдущей загрузки(loadtime) плюс дельта для запаса (timeDelta);
loadedBytes < filesize — не достигнут конец воспроизводимого файла;

Важный момент в режиме создания данных не генерируются события NetStream.Play.Start, NetStream.Play.Stop. Поэтому надо следить за объемом загруженных данных или проигранным временем видео-ролика.

Переменная timeDelta позволяет подстроиться к качеству канала пользователя: в случае опустошения буфера воспроизведения раньше, чем закончится загрузка следующего фрагмента данных, дельта увеличивается.

Немного о перемотке

В режиме создания данных некоторые методы класса NetStream меняют свой обычный функционал. Это относится и к методу seek() — он осуществляет поиск ключевого кадра (так называемого I-кадра), расположенного ближе всего к указанной точке. В режиме создания данных после вызова метода seek класс начинает игнорировать все передаваемые методом appendBytes() данные, до вызова метода appendBytesAction(). Аргумент метода может иметь следующие значения NetStreamAppendBytesAction.RESET_BEGIN или NetStreamAppendBytesAction.RESET_SEEK, первый подразумевает наличие в свежих данных нового заголовка flv файла и обнуляет счетчики воспроизведения в классе. Вот пример кода перематывающего видео:

//Ищем в массиве навигационных точек, полученном из мета-данных видео-файла, смещения в байтах от начала файла по времени на которое кликнули на прогрессбаре воспроизведения
for (var i:Number=0;i<positions.length;i++){
          if ((positions[i]<=seekpositions)&&(positions[i+1]>=seektpositions)){
        //Смещаем наш счетчик загруженных данных на новое значение и новая загрузка начнется именно с этого места
               loadedBytes = positions[i];
               break;
           }
}
//Метод seek сбрасывает и игнорирует уже имеющиеся данные
ns.seek(seektime);
//Cообщаем классу NetStream том что мы просто переходим на новое место файла, а не начинаем проигрывать новый
ns.appendBytesAction(NetStreamAppendBytesAction.RESET_SEEK);
//Старт загрузки новых данных
loadData();

times — массив смещения по времени навигационных точек;
positions — массив смещения по байтам навигационных точек;

Пара замечаний

Одной особенностью библиотеки HTTPClientLib является то, что файл с политикой безопасности сокетов (crossdomain.xml) при кросс доменной работе запрашивается с 843 порта сервера, в отличии от стандартных объектов, которые могут подцепить его и с 80-го. Поэтому в конфигурации сервера nginx была добавлена следующая запись:

server {
       listen 843;
       server_name  localhost;
       location / {
              rewrite ^(.*)$ /crossdomain.xml;
       }
       error_page 400 /crossdomain.xml;
       location = /crossdomain.xml {
           root  /home/www-root;
           default_type  text/x-cross-domain-policy;
       }
}
Итоги

Применение плеера, который загружает видео по данной методике, позволило значительно сократить объем “лишнего — паразитного” трафика, и снизить нагрузку на потоковые сервера. В среднем на 200 000 просмотров трафик снизился на 30%. Однако, вышеизложенная методика имеет и “негативную” сторону, а именно: наличие у пользователя минимум 10.1 версии Flash Player.

Полный пример кода

Автор: Mu57Di3

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js