понедельник, 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

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

2 комментария:

  1. Hard Rock Hotel & Casino Pittsburgh - MapyRO
    Find 나주 출장안마 your way around the casino, find where everything is located 논산 출장안마 with these helpful tools. Use 군산 출장안마 these 안성 출장마사지 tools to find hotels near you and connect to 울산광역 출장안마 other

    ОтветитьУдалить
  2. If would possibly be} betting chips of various denominations, stack them with the smallest denomination on prime. If you put a bigger denomination on prime, the supplier will rearrange them earlier than happening with the hand. It's a technique the casino guards in opposition to somebody 카지노 사이트 trying to add a large-denomination chip to their wager after finish result} is known. Now that you know how how|you know the way} to play, let's discover a few of the the} finer factors of the sport.

    ОтветитьУдалить