понедельник, 28 января 2013 г.

Реализация простого видеочата на ASP.NET MVC

Но для начала предыстория. Мы запускаем сервис видеоконсультаций с врачом через интернет. О нём обязательно будет отдельная статья, а сейчас хотим выяснить, насколько большую нагрузку смогут выдержать сервера и каналы.
Для этого мы написали небольшое веб-приложение, исходным кодами и описанием которого рад с вами поделиться. 
Основная идея позаимствована у чатрулетки: заходишь в общий чат, выбираешь любого собеседника и общаешься по видео. 
Исходный код проекта опубликован на codeplex.com под свободной лицензией, буду рад комментариям/замечаниям/предложениям. 

Итак. В качестве протокола я выбрал RTMP как наиболее распространённый. Почему не RTMFP? Просто используя RTMFP сложно добиться устойчивого соединения между клиентами, что необходимо для оказания платных видеоконсультаций, да и серверная реализация раздачи айдишников недоступна для стабильного использования. В качестве сервера – Wowza Media Server, т.к. в отличие от бесплатного Red5 (да простят меня его сторонники) у него внятная документация с примерами, и в отличие от FMS пробный период в 30 дней и приемлемая ценовая политика. А качество работы проверим на практике, насколько я представляю, сильной разницы между всеми тремя по производительности нет. Как альтернативу мы рассматриваемerlyvideo, но подробно посмотреть и попробовать его пока возможности не было.

Пишется всё под ASP.NET MVC 4. И для реализации текстового чата и общения между клиентами используется библиотека SignalR.

Далее по пунктам.

Реализация чата.
Основное здесь – два класса ChatMessage и Chat.
Класс Chat наследован от SignalR.Hubs.Hub и реализует основные методы работы с клиентами:

// вызывается клиентом для подключения к комнате.
public void JoinRoom(string roomKey, string userName)
{
    // Сохраняем описание пользователя только если он в основном чате
    if (roomKey == C.MAIN_CHAT_GROUP)
       Store.Add(new User(Context.ConnectionId, userName));
    // Возвращаем клиенту его id
    Clients[Context.ConnectionId].OnJoinRoom(Context.ConnectionId);
    // Добавляем пользователя в комнату
    Groups.Add(Context.ConnectionId, roomKey);
    // Опопвещаем клиентов о изменении списка пользователей
    UpdateUsers();
}

// вызывается клиентом для отправки сообщения
public void Send(ChatMessage message)
{
    // Что-то делаем только если сообщение не пустое
    if (message.Content.Length > 0)
    {
       //проставляем дату отправки
       message.Date = DateTime.Now;
       // идентификатор отправителя
       message.SenderKey = Context.ConnectionId;
       // экранируем пришедший текст во избежание хулиганства
       message.Content = HttpUtility.HtmlEncode(message.Content);
       message.SenderName = HttpUtility.HtmlEncode(message.SenderName);
       // Оповещаем клиентов о новом сообщении
       Clients[message.RoomKey].OnSend(message);

       Store.SaveMessage(message);
    }
}


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

На клиенте создаём соответствующие методы. Для краткости я скрыл конкретную реализацию

var CHAT = {};
var OPTIONS = {};

function Start(data)
{
   // Инициализируме переменные, подключение
   OPTIONS.SenderName = data.name;
   OPTIONS.RoomKey = 'MAIN';
   CHAT = $.connection.chat;

   // Присваиваем обработчики методов, вызываемых с сервера
   CHAT.OnSend = OnSend;
   CHAT.OnJoinRoom = OnJoinRoom;
}
// Вызывается с сервера после подключения клиента
function OnJoinRoom(key) {
   OPTIONS.SenderKey = key;
}
// Вызывается с сервера для обновления списка пользователей онлайн
function OnUpdateUsers(data) {
   /* ...Обновляем пользователей, в data коллекция прокси-объектов User, по интерфейсу идентичная интерфейсу IUser */
}
// Функция для отправки сообщения, вызывает серверный Chat.Send
function Send() {
    var messageInput = $("#msg"),
       // Создаём объект, нименования полей которого соответствуют полям ChatMessage
       msg = {
           'SenderName': OPTIONS.MyName,
           'RoomKey': OPTIONS.RoomKey,
           'Content': messageInput.val()
       };
    CHAT.send(msg); // Важный момент: серверные методы в прокси-объекте начинаются с прописной буквы
    messageInput.val("");
    messageInput.focus();
}
// Метод, вызываемый с сервера для публикации сообщения
function OnSend(msg) {
   var chatContent = $(".chat_content"),
           msgClass = 'chat_message';
   /* ...Добавляем полученное сообщение на страницу,
в msg - объект, поля которого соответствуют полям ChatMessage */
}


Далее необходимо обеспечить функционал «звонков». Для этого в Chat добавляем методы, обрабатывающие начало звонка, отклонение и принятие звонка.

// Метод звонка (вызов абонента)
public void Call(string recieverKey, string senderKey, string senderName)
{
   Clients[recieverKey].OnCall(senderKey, senderName);
}

// Метод отклонения звонка
public void RejectCall(string senderKey, string recieverKey, string recieverName)
{
   Clients[senderKey].OnRejectCall(recieverKey, recieverName);
}

// Принятие звонка
public void AcceptCall(string calleePulicKey, string calleeName, string myName)
{
    string myKey = Guid.NewGuid().ToString().Replace("-", "");
    string calleeKey = Guid.NewGuid().ToString().Replace("-", "");
    string roomKey = Guid.NewGuid().ToString().Replace("-", "");

    var model = new RoomModel
                    {
                        MyPublicKey = Context.ConnectionId,
                        MyKey = myKey,
                        MyName = myName,
                        CalleePublicKey = calleePulicKey,
                        CalleeKey = calleeKey,
                        CalleeName = calleeName,
                        RoomKey = roomKey
                    };

    // Сохраняем информацию о начинающемся сеансе
    Store.SaveRoomInfo(model);
    // Рассылаем уведомления
    Clients[calleePulicKey].OnAcceptCall(false, roomKey);
    Clients[Context.ConnectionId].OnAcceptCall(true, roomKey);
}


Схема работы следующая: когда один абонент (допустим, Ангелина) хочет позвонить другому (к примеру, Пете), Ангелина вызывает метод Call и передаёт ему ключ Пети, свой ключ и своё имя. Пете мы высылаем уведомление OnCall, на клиенте его обрабатываем и отображаем сообщение о звонке от Ангелины. В случае, если Петя решит отклонить звонок, он вызывает метод RejectCall и возвращает ключ звонящего, свой ключ и своё имя. Мы отправляем Ангелине уведомление OnRejectCall, в обработчике которого отображаем Ангелине уведомление об отклонении звонка.
Если же Петя принимает звонок, он вызывает метод AcceptCall, в котором мы генерируем для обоих абонентов новые идентификаторы и ключ для комнаты личного чата. После чего отправляем обоим уведомления OnAcceptCall, передавая с ними необходимые ключи. На клиенте в обработчике уведомления перенаправляем и Петю и Ангелину на страницу личного чата:

function OnAcceptCall(isMy, roomKey) {
    document.location = '@Url.Action("Room", "Home")' + '?isMy=' + isMy + '&roomKey=' + roomKey;
}


На странице личного чата с помощью переданных ключей инициализируем флешку и текстовый чат. Для текстового чата на странице Room используем тот же объект Chat, просто не обрабатывая на клиенте события обновления списка пользователей и звонков.

Далее переходим к флешке.
Для организации общения мы должны создать поток, который будем «публиковать» на сервер и подписаться на поток, публикуемый собеседником. Потоки на сервере идентифицируются посредством ключей, передаваемых на сервер при начале публикации.
При инициализации флешки мы получаем ключи со страницы, сохраняем их в локальные переменные и запускаем таймер, который будет следить за началом и ходом сеанса связи. Работу по созданию подключения к серверу, публикации и подписке на поток осуществляют три метода:

private function Connect():void
{
      if (!isConnected && rtmpConnection == null)
      {
            // Создаём подключение
            rtmpConnection = new NetConnection();
            rtmpConnection.connect(connectStr);
           
            // Добавляем обработчик события изменения состояния подключения
            rtmpConnection.addEventListener(NetStatusEvent.NET_STATUS, onNetStatus_rtmpConnection);
      }
      isConnected = true;
}

private function StartPublish():void
{
      // Создаём поток для публикации
      nsPublish = new NetStream(rtmpConnection);
     
      nsPublish.addEventListener(NetStatusEvent.NET_STATUS, onNetStatus_nsPublish);
     
      // устанавливаем буфер потока в 0
      nsPublish.bufferTime = 0;
     
      // Публикуем
      nsPublish.publish(publishName);
                                      
      // Подсоединяем камеру и микрофон
      nsPublish.attachCamera(camera);
      nsPublish.attachAudio(microphone);
     
      isPublish = true;
}
private function StartSubscribe():void
{                                
      // Cоздаём поток для подписки на трансляцию собеседника
      nsSubscribe = new NetStream(rtmpConnection);
     
      // Добавляем обработчик событий потока
      nsSubscribe.addEventListener(NetStatusEvent.NET_STATUS, onNetStatus_nsSubscribe);
     
      // Устанавливаем буфер потока в 0
      nsSubscribe.bufferTime = 0;
     
      // Устанавливаем громкость потока
      var volume:Number = sldrVolume.value / 100;
      var st:SoundTransform = new SoundTransform(volume);
      nsSubscribe.soundTransform = st;
     
      // Подключаемся к потоку
      nsSubscribe.play(subscribeName);
     
      // Подключаем к потоку камеру
      videoRemote.attachNetStream(nsSubscribe);     
     
      isSubscribe = true;
}


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

private function onTick_Timer(event:TimerEvent):void
{     
      if(!isConnected)//Проверяем состояние подключения
      {
            lblEndTime.text = "Подключение...";
            Connect();
            startTime = new Date();
      }
      else
      {
            if(!isPublish && needPublish)//Проверяем состояние публикации
            {
                   lblEndTime.text = "Публикация...";
                   StartPublish();
            }
           
            if(!isSubscribe)// Проверяем состояние подписки
            {
                   lblEndTime.text = "Подписка...";
                   StartSubscribe();  
            }
           
            if(isPublish && isSubscribe)// Если всё ОК, пишем время разговора
            {
                   var now:Date = new Date();
                   var toStart:TimeSpan = new TimeSpan(now.getTime() - startTime.getTime());
                   lblEndTime.text = toStart.getTotalMinutes() + ':' + toStart.getSeconds();
            }
      }
}


На этом практически всё. 

Последний компонент — Медиасервер.
Wowza Media Server особых сложностей в установке и настройке не вызвал. Загружаете дистрибутив с официального сайта, ставите, открываете на машине 1935-й порт и прописываете в флешку адрес сервера. При желании можно воспользоваться любым другим сервером, поддерживающим RTMP: Red5, Adobe FlashMediaServer, erlyvideo. Клиентская реализация никак не зависит от серверной.

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

UPD: Тестирование закончилось, ссылки на онлайн-демку из поста убрал. 
По итогам должен сказать, что хабраэффект прошёл мимо. Сервер работал максимум в половину нагрузки.
Немного цифр:
1. Сколько максимально находилось в видео-чате в единицу времени — 5 сеансов началось в одно время с точностью до минуты из них 4 продлилось больше минуты
2. Всего попыток звонков — 361
1) Из них попыток, длившихся более 30-ти секунд — 174
2) Длившихся более 2-х минут — 38
3) Некорректно завершённых (без простановки времени завершения) — 62
3. Всего сообщений в чате — 12347
1) Из них в главном — 11256
2) В личных — 1125

Благодарю всех, кто принял участие в нагрузочном тестировании!

Комментариев нет:

Отправить комментарий