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