
Доброго времени суток, господа хабраюзеры!
В данном топике я расскажу, как можно сделать простой видео-чат на 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
Благодарю всех, кто принял участие в нагрузочном тестировании!
