«Хочу как Дуров»: пишем простой мессенджер
В последнее время растёт популярность приложений для обмена сообщениями. Пользователи предпочитают мессенджеры, потому что они позволяют взаимодействовать в режиме реального времени. В этой статье мы разберём процесс создания простого приложения для обмена мгновенными сообщениями. Не будем сильно углубляться в нюансы разработки: мы напишем рабочий мессенджер, который в дальнейшем можно будет улучшить.
Статья подойдёт состоявшимся программистам и тем, кто только интересуется, как войти в IT.
Используемые технологии и инструменты
Подготовка
Структура будущего приложения выглядит примерно так:
Установите Node.js и MongoDB. Кроме того, нам понадобится библиотека AngularJS, скачайте её и скопируйте в папку lib каталога Client.
Примечание: созданный мессенджер можно будет интегрировать в качестве виджета в любой проект.
Серверная часть
Шаг 1. Запуск проекта
Перейдите в каталог Server и выполните команду:
Она запустит новый проект.
Укажите все необходимые сведения. В результате будет создан файл package.json примерно следующего вида:
Шаг 2. Установка зависимостей
Выполнение этих команд установит необходимые зависимости и добавит их в package.json :
Выглядеть они будут примерно так:
Шаг 3. Создание сервера
Создадим сервер, который обслуживает порт 3000 и возвращает HTML-файл при вызове. Для инициализации нового соединения сокету нужно передать HTTP-объект. Событие connection будет прослушивать входящие сокеты, каждый сокет будет выпускать событие disconnect, которое будет вызвано при отключении клиента. Мы будем использовать следующие функции:
Создайте сервер с именем server.js . Он должен:
В результате ваш сервер будет выглядеть примерно так:
Клиентская часть
Создайте файлы index.html в каталоге Client, style.css в каталоге CSS и app.js в каталоге js.
Client/index.html
Пусть это будет простой HTML-код, который получает и отображает наши сообщения.
Включите скрипты socket.io-client и angular.js в ваш HTML:
socket.io служит для нас клиентом. Он по умолчанию подключается к хосту, обслуживающему страницу.
В результате index.html должен выглядеть примерно так:
CSS/style.css
Чтобы придать нашей странице внешний вид окна чата, добавим немного стилей. Вы можете использовать любую CSS-библиотеку. Получим следующее:
js/app.js:
Создайте Angular-приложение и инициализируйте соединение сокета. Для этого нужны следующие функции:
Теперь, когда будет набран текст сообщения и нажата кнопка, вызовите функцию отправки сообщения. Когда сокет получит сообщение, отобразите его.
В результате app.js будет выглядеть примерно так:
Запуск приложения
Перейдите в папку с server.js и запустите команду:
Сервер начнет работу на порте 3000. Чтобы в этом убедиться, перейдите по ссылке в браузере:
Ваш собственный мессенджер готов!
Что можно улучшить?
Можно создать базу данных для хранения информации о пользователях и истории сообщений. Лучше, если она будет масштабируемой, потому что в дальнейшем это позволит добавить больше функций.
Установите Mongoose или MongoDB для работы с базами данных Mongo:
Можете ознакомиться с документацией по их использованию: mongoose и mongodb.
Схема должна получиться примерно следующего вида:
Собеседникам могут быть присвоены следующие статусы:
Предположим, что собеседник отклонил запрос на приватную беседу. В таком случае отправитель должен иметь возможность снова направить запрос.
Неплохо было бы реализовать для пользователя функционал сохранения сообщений в дополнительные коллекции. Пусть каждый её объект содержит сообщение, отправителя, получателя и время. Спроектируйте вашу базу данных в соответствии с конкретными потребностями и методами обработки сообщений.
Также вы можете создать REST API для обслуживания клиента. Например, конечную точку, отправляющую домашнюю страницу, из которой пользователи могут выполнять другие запросы.
Некоторые из возможных конечных точек API:
Вот какой мессенджер получился у автора статьи:
Внешний вид приложения
Исходный код приложения можно найти на GitHub.
Создаем чат для сайта: HTML, JS, PHP и AJAX
Когда обычных комментариев становится недостаточно, приходит время создать чат.
С помощью чата пользователи общаются друг с другом, повышая интерес к сайту. Это важный элемент для вебинарных площадок, порталов со службой поддержки и страниц, где необходимо более живое, нефорумное общение. Гайд поможет на практике скомбинировать знания по HTML, JS, PHP и AJAX и создать готовый продукт.
Если знаний ещё недостаточно, обратите внимание на курс «Веб-разработчик c нуля до PRO».
Пишет о программировании, в свободное время создает игры. Мечтает открыть свою студию и выпускать ламповые RPG.
Каким должен быть чат
Удобство для пользователей превыше всего. Позаботьтесь, чтобы чат соответствовал современным требованиям:
Каркас чата на HTML
В первую очередь создаём форму отправки и контейнер для отображения сообщений:
Задаём стили
Первый этап пройден:
В первую очередь уделим внимание главным функциям чата, а после доработаем внешний вид.
Основная часть чата
на JS + PHP
Сообщения будут отправляться и загружаться с помощью AJAX. На JavaScript пишем функции работы с интерфейсом и связи с серверной частью, а на PHP — методы обработки полученных данных и взаимодействия с базой данных.
Создаем переменные на JS
Создаем функцию для запросов
Она получает переменную act, в которой хранится одно из трёх значений: auth (авторизация), load (загрузка) и send (отправка). От них зависит, какая информация будет передана в PHP-файл.
Создаём функцию обновления чата
И укажем для нашей функции интервал выполнения:
После отлавливается событие отправки формы — это поможет отказаться от обновления страницы:
Теперь займёмся самим обработчиком. В первую очередь с помощью функции session_start () запускается сессия, затем подключается база данных:
Создаём функцию авторизации
Создаём функцию загрузки
Создаём функцию отправки сообщений
В функции уже присутствует несложная валидация, но можно добавить и другие меры защиты от инъекций и спама:
Теперь, когда все функции готовы, пропишем их вызов.
Вызываем функции
Со стороны фронтенда мы ещё не реализовали авторизацию, но уже можем проверить чат, потому что в самом начале данные администратора были занесены в сессию. Вот как это выглядит:
Теперь, когда чат работает, пора добавить авторизацию. Для этого можно создать отдельную форму во фронте, но можно обойтись и модальными окнами. В функции send_request () дополним отправку запроса:
Вот как это выглядит:
Дополнительные функции
Минимальные возможности чата у нас есть, и продукт можно запускать в релиз, но добавим ещё несколько полезных штук.
Смайлики
Создадим свой набор смайликов чата. Работать это будет так:
Для начала добавим контейнер со смайликами и кнопку для его открытия:
Добавим скрипт для загрузки смайликов и открытия меню с ними:
А теперь и функцию добавления смайлика в поле:
После этого укажем, когда вызываются функции:
Приступим к загрузке смайликов и их преобразованию на PHP:
Эта функция сканирует папку со смайликами, а потом проверяет расширение файлов. Она очень удобна, потому что отображает в формате PNG все смайлики, которые мы добавили.
Чтобы вызвать её, добавим ещё один case в функцию switch () в конце обработчика:
Теперь с помощью регулярных выражений можно заменять код смайлика на изображение:
Вызывается эта функция при загрузке сообщений:
Вот как это выглядит:
Ответ на сообщения
Чтобы добавить возможность отвечать кому-то конкретному, изменим функцию addEmoji (). При нажатии на ник собеседника будет меняться текст в поле ввода.
Для этого в load () изменим формат сообщений, добавив span к нику:
Пишем саму функцию:
И вызываем функцию:
Заключение
Одной статьи недостаточно, чтобы охватить все возможности PHP и JS для разработки чатов. С помощью этих языков легко добавить:
Научиться делать подобные вещи самому не просто. Придётся перерыть гору литературы или искать готовые решения. На нашем курсе вы получите практические знания, которые помогут разобраться во всех деталях и делать более сложные проекты.
Как я свой мессенджер писал
Одним вечером, после очередного расстраивающего дня, наполненного попытками наладить баланс в своей игре, я решил, что мне срочно требуется отдых. Переключусь на другой проект, быстренько его сделаю, верну на место скатившуюся за время разработки игры самоооценку и с новыми силами возьму игру штурмом! Главное выбрать проект nice and relaxing… Написать свой месседжер? Ха! How hard can it be?
Код можно посмотреть здесь.
Краткая предыстория
До начала работы над мессенджером почти год корпел над мультиплеерной онлайн Line Tower Wars игрой. Программирование шло хорошо, всё остальное (баланс и визуал в особенности) — не очень. Внезапно оказалось, что сделать игру и сделать увлекательную игру (увлекательную для кого-то помимо самого себя) — две разные вещи. После года мытарств мне нужно было отвлечься, поэтому я решил попробовать свои силы в чём-то другом. Выбор пал на мобильную разработку, а именно, Flutter. Слышал множество хороших вещей про Flutter, да и дарт после недолгих экспериментов мне понравился. Решил написать свой собственный мессенджер. Во-первых, хорошая практика по реализации и клиента, и сервера. Во-вторых, будет что-то весомое положить в портфолио для поиска работы, я как раз нахожусь в процессе.
Запланированный функционал
Выбор языка
С выбором языка долго не думал. Сначала был соблазн использовать дарт и для клиента, и для сервера, но более детальная инспекция показала, что доступных драйверов для дарт не очень много, а те что есть не внушают особого доверия. Хотя не поручусь говорить о текущем моменте, возможно ситуация улучшилась. Так что выбор мой пал на C#, с которым я работал в Unity.
Архитектура
Начал с продумывания архитектуры. Конечно, учитывая что моим мессенджером скорее всего будут пользоваться 3 с половиной человека, можно было бы не заморачиваться с архитектурой вообще. Берёшь и делаешь как в бесчисленных туториалах. Вот нода, вот монго, вот вебсокеты. Готово. И Firebase где-то тут. Но так не интересно. Я решил делать мессенджер, способный легко горизонтально скейлиться, будто ожидаю миллионы одновременных клиентов. Однако так как опыта в этой сфере у меня не было никакого, пришлось всё познавать на практике методом ошибок и снова ошибок.
Я не утвержаю, что такая архитектура супер крута и надёжна, но она жизнеспособна и в теории должна выдерживать большие нагрузки и скейлиться горизонтально, но я не очень понимаю, как проверить. И я надеюсь, что не упустил какой-то очевидный момент, который известен всем, кроме меня.
Ниже будет подробное описание отдельных компонентов.
Frontend Server
Ещё до того как я взялся делать игру, меня увлекла концепция асинхронного однопоточного сервера. Эффективно и без потенциальных race’ов — о чем ещё можно просить. С целью разобраться, как такие сервера устроены, я стал копаться в модуле asyncio языка python. Увиденное решение показалось мне очень изящным. Если кратко, то решение на псевдокоде выглядит так.
С помощью такой нехитрой техники мы можем обслуживать большое число сокетов одним потоком. Мы никогда не блокируем поток в ожидании пока байты будут получены или отправлены. Поток всегда занят полезной работой. Concurrency, одним словом.
Frontend сервера реализованы именно так. Они все однопоточные и асинхронные. Поэтому для максимальной производительности нужно запускать столько серверов на одной машине сколько у неё имеется ядер (4 на картинке).
Frontend сервер читает сообщение от клиента и, основываясь на коде сообщения, отправляет его в один из топиков кафки.
Кафка имеет несколько применений, но я использую его как брокер сообщений по типу RabbitMQ. В кафке есть топики. Топик служит в качестве логического представления коллекции, в которую мы можем писать и на которую все заинтересованные клиенты могут подписываться (в моем случае authentication backend сервера подписываются на топик authentication, например). Почему логического? Потому что топик не является каким-то неделимым юнитом, каждый топик состоит из одного или более партишн (partition). Когда мы отправляем сообщение в топик, оно попадает в один из партишн. Мы можем либо явно указать партишн, либо довериться алгоритму, который определит в какой партишн отправить сообщение. Например, отправлять все сообщения с одинаковым ключом в один и тот же партишн (сообщения могут, но не обязаны, иметь ключ, а так же заголовки (headers)).
Зачем такие сложности? Зачем делить топик на партишн? Партишн служит в качестве единицы параллелизации. Несколько потребителей (consumer) могут подписаться на один и тот же топик (образуя группу consumer’ов), и тогда кафка (по умолчанию) распределит все партишн равномерно между ними. Если, скажем, у нас топик с двумя партишн, на который подписано 2 клиента, кафка распределит каждому клиенту по одному партишн. Если партишн 3 — одному из клиентов достанется 2. Кафка умеет детектить добавление новых партишн и новых клиентов и автоматически перераспределять партишн в случае необходимости.
Frontend сервер отправляет сообщение в кафку без ключа (когда нет ключа, кафка просто отправляет сообщения в партишн по очереди). Из топика сообщение вытаскивает один из соответствующих backend серверов. Сервер обрабатывает сообщение и… что дальше? А что дальше зависит от типа сообщения.
Окей, звучит не очень сложно, но для того чтобы отправить сообщение какому-либо клиенту нам нужно: 1) узнать с каким frontend сервером этот клиент соединён (ведь мы не выбираем с каким конкретно сервером клиент соединятся, за нас решает балансировщик); 2) передать сообщение от backend сервера нужному frontend серверу; 3) собственно, отправить сообщение клиенту.
Для реализации пунктов 1 и 2 я решил использовать отдельный топик («frontend servers» топик). Разделение authentication, session и call топиков на партишн служит как механизм параллелизации. Видим что session сервера сильно загружены? Просто добавляем парочку новых партишн и session серверов, и кафка сделает перераспределение нагрузки за нас, разгружая имеющиеся session сервера. Разделение же «frontend servers» топика на партишн служит как механизм маршрутизации.
Каждому frontend серверу соответствует один партишн «frontend servers» топика (с таким же индексом, что и сам сервер). То есть серверу 0 — партишн 0 и тд. Кафка даёт возможность подписаться не только на определённый топик, но и на определённый партишн определённого топика. Все frontend сервера на стартапе подписываются на соответствующий партишн. Таким образом backend сервер получает возможность отправить сообщение конкретному frontend серверу, отправив сообщение в определённый партишн.
Окей, теперь, когда клиент присоединяется, нужно просто сохранять где-то пару UserId — Frontend Server Index. При дисконнекте — удалять. Для этих целей подойдёт любое из многих in-memory key-value бд. Я выбрал редис.
* На самом деле процесс чуть сложнее чем я описал. Можете ознакомиться в исходном коде.
Псевдокод frontend сервера
В коллбэке ожидание ответа отменяется и начинается отправка серверной ошибки. Если же ответ от backend сервера получен, коллбэк ничего не делает.
Использовать await Task.WhenAny(answerReceivedTask, Task.Delay(x)) нет возможности, так как код после Task.Delay выполняется на потоке из пула.
* При написании статьи я вдруг осознал, что мы можем не передавать deliveryHandler в метод Produce или просто игнорировать все ошибки кафки (клиенту всё равно будет отправлена ошибка по таймауту, который я описал ранее) — тогда весь наш код будет однопоточным. Теперь думаю, как лучше сделать.
Backend Server
По сравнению с frontend сервером, интересных моментов здесь практически нет. Все backend сервера работают одинаково. На стартапе сервер подписывается на топик (authentication, session или call в зависимости от роли), и кафка назначает ему один или более партишн. Сервер получает сообщение из кафки, обрабатывает и обычно посылает в ответ одно или более сообщений. Почти реальный код:
Кафка гарантирует at least once delivery, что означает, что сообщения не будут потеряны и будут доставлены в большинстве случаев один раз (иногда возможна повторная доставка сообщения). Это достигается за счёт того, что кафка для каждого партишн каждого топика хранит последний подтвержённый (commited) оффсет. Скажем, один consumer вытащил из назначенного ему партишн сообщение с оффсетом 16, обработал его, закоммитил 16й оффсет, вытащил следующее сообщение, но во время обработки вдруг умер, не сделав коммит. Кафка назначит его партишн какому-то другому consumer’у из той же группы consumer’ов и начнёт доставлять ему сообщения из данного партишн, начиная с оффсета 16 + 1 (последний подтверждённый оффсет + 1). Таким образом сообщение 17 не будет потеряно. Кафка может либо коммитить оффсеты автоматически каждые N миллисекунд, либо полностью передать контроль над коммитами пользователю.
Как видно, мы итерируем по ворк юнитам, находим последний завершённый к данному моменту юнит, после которого нет незавершённых, и коммитим соответствующий ему оффсет. Такой цикл позволяет нам избежать «дырявых» коммитов. Например, если у нас в данный момент 4 ворк юнита ( 0: Finished, 1: Not Finished, 2: Finished, 3: Finished ), мы можем закоммитить только 0й юнит, так как, если закоммитим сразу 3й, это может привести к потенциальной потере 1го, если вдруг сервер умрёт прямо сейчас.
Сервисы довольно тривиальны. Вот, например, какой код выполняется, когда юзер отправляет новое сообщение:
База данных
Большая часть функционала сервисов, вызываемых backend серверами, — это просто добавление новых данных в бд и обработка уже имеющихся. Очевидно, как база данных устроена и как мы ей оперируем играет очень важное значение для мессенджера, и тут мне бы хотелось сказать, что я подошёл к вопросу выбора бд очень тщательно после внимательного изучения всех вариантов, но это не так. Я просто выбрал CockroachDb, потому что он обещает много при минимуме усилий и имеет совместимый с postgres синтаксис (я работал с постгрес раньше). Были мысли использовать Кассандру, но в конце концов решил остановиться на чём-то знакомом. Я никогда раньше не работал ни с кафкой, ни с рэббитом, ни с Flutter и дарт, ни с WebRtc, поэтому решил не тащить ещё и Кассандру, так как боялся утонуть во всём множестве новых для меня технологий.
Из всех частей моего проекта дизайн базы данных — вещь, в которой я сомневаюсь больше всего. Я не уверен, что решения, которые я принял, действительно, хорошие решения. Всё работает, но можно было сделать лучше. Например, есть таблицы ShareRooms (так я называю чаты) и ShareItems (так я называю сообщения). Так вот все юзеры, входящие в какую-то комнату, записаны в jsonb поле этой комнаты. Это удобно, но явно очень медленно, так что скорее всего переделаю на использование внешних ключей. Или, например, таблица ShareItems хранит все сообщения. Что тоже удобно, но так как ShareItems является одной из самых нагруженных таблиц (постоянные select и insert ), возможно стоит создавать новую таблицу для каждой комнаты или что-то в этом роде. Кокроач раскидывает записи по разным нодам, соответственно, нужно тщательно продумывать куда какая запись пойдёт, чтобы добиться максимальной производительности, а я этого не делал. В общем, как можно понять из всего вышесказанного, базы данных не самое моё сильное место. Прямо сейчас я вообще тестирую всё на постгрес, а не кокроач, потому что так меньше нагрузки на мою рабочую машину, она и так бедная от нагрузок скоро взлетит. Благо код для постгрес и кокроач разнится совсем немного, так что переключаться не составляет труда.
Сейчас я нахожусь в процессе изучения, как, собственно, кокроач работает (как происходит mapping между SQL и key-value (кокроач использует RocksDb под капотом), как он распределяет данные между нодами, реплицирует и тд). Стоило, конечно, изучить кокроач перед тем как использовать его, но лучше поздно чем никогда.
Flutter
Долгое время я работал над серверной частью и использовал простые консольные клиенты для теста, так что даже не создавал Flutter проект. А когда создал, думал, что серверная часть была сложной частью, а приложение это так, фигня, за пару дней разберусь. Пока работал над сервером, пару раз создавал Hello World’ы на флаттер, чтобы прочувствовать фреймворк, и, так как мессенджеру не требуется какой-то замысловатый UI, думал, что полностью готов. Так вот UI, действительно, фигня, но реализация функционала доставила мне проблем (и ещё доставит, так как не всё готово).
State management
Самая популярная тема. Есть тысяча способов управлять состоянием, и рекомендуемый подход меняется раз в полгода. Сейчас мэйнстримом является provider. Лично я для себя выбрал 2 способа: bloc и redux. Bloc (Business Logic Component) для управления локальным состоянием и redux для управления глобальным.
По сравнению с некоторыми другими подходами, bloc требует больше бойлерплейта, но, по моему мнению, лучше использовать родные решения без участия сторонних библиотек, если только использование стороннего решения не даёт какие-то существенные преимущества. Чем больше сверху абстракций, тем сложнее понять, в чём ошибка, когда ошибка случается. Преимущества провайдера я для себя не считаю достаточно существенными, чтобы переходить на него. Но опыта у меня в этой сфере немного, так что вполне вероятно я сменю лагерь в будущем.
Ну а про redux и так все всё знают, так что и говорить нечего. Тем более я его вырезал из приложения 🙂 Использовал для управление аккаунтом, но затем, поняв что в данном случае преимуществ над блоком особых нет, вырезал, чтобы не тащить лишнее. Но в целом считаю redux полезной вещью для управления глобальным состоянием.
Самая мучительная часть
Изображения
В данный момент можно отправлять текст, текст + изображения и просто изображения. Отправка видео ещё не реализована. Изображения немного ужимаются и сохраняются в Firebase storage. В самом сообщении передаются ссылки. По получении сообщения клиент скачивает изображения, генерирует миниатюры и сохраняет всё на файловую систему. В базу записываются пути к файлам. Кстати, генерация миниатюр — единственный код, выполняемый на отдельном треде, так как это compute-heavy операция. Я просто запускаю один воркер-поток, скармливаю ему изображение и в ответ получаю миниатюру. Код предельно прост, так как дарт даёт удобные абстракции для работы с потоками.
Также используется Firebase auth, но только для авторизации доступа к Firebase storage (чтобы юзер не мог, скажем, залить профильную картинку кому-то другому). Вся остальная авторизация осуществляется через мои серверы.
Формат сообщений
Здесь вы наверное ужаснётесь, так как я использую обычные массивы байтов. Json отпадает, потому что требуется эффективность, а про protobuf я не знал, когда начинал. Использование массивов требует большой аккуратности, потому что один неправильный индекс и всё пойдёт наперекосяк.
Первые 4 байта — длина сообщения.
Следующий байт — код сообщения.
Следующие 16 байт — идентификатор запроса (uuid).
Следующие 40 байт — токен авторизации.
Остальная часть сообщения.
Длина сообщения требуется, так как я не использую http или вебсокеты, или какой-то другой протокол, который обеспечивает разделение одного сообщения от другого. Мои frontend сервера видят только потоки байтов, и они должны знать, где одно сообщение заканчивается, и начинается другое. Есть несколько способов разделять сообщения (например, использовать какой-то никогда не встречающийся в сообщениях символ в качестве разделителя), но я предпочёл указывать длину, так как этот способ самый простой, хоть он и влечёт за собой оверхед, так как большинству сообщений хватает и одного байта для указания длины.
Идентификатор запроса присутствует в большинстве сообщений, но не во всех. Он выполняет 2 функции: по этому идентификатору клиент устанавливает соответствие между отправленным запросом и полученным ответом (если клиент отправил сообщения А, Б, В в таком порядке, это не означает, что ответы тоже придут по порядку). Вторая функция — избежание дупликатов. Как было сказано ранее, кафка гарантирует at least once delivery. То есть в редких случаях сообщения всё-таки могут быть продублированы. Добавив в нужную таблицу базы данных колонку RequestIdentifier с unique ограничением, мы можем избежать вставки дупликата.
Токен авторизации — это UserId (8 байт) + 32 байта HmacSha256 подпись. Не думаю, что здесь стоит использовать Jwt. Jwt это примерно в 7-8 раз больший размер ради получения чего? У меня юзеры не имеют никаких claims, поэтому простая подпись hmac’ом годится. Авторизации через другие сервисы нет и не планируется.
Аудио и видео звонки
Забавно, что реализацию аудио и видео звонков я сознательно откладывал, так как был уверен, что проблем не оберусь, а на деле это оказалось одной из самых легких в реализации фич. По крайней мере базовый функционал. Вообще просто добавление WebRtc в приложение и получение первого сеанса видеосвязи заняло всего несколько часов, и, о чудо, первый же тест увенчался успехом. До этого я думал, что работающий с первого раза код — это миф. Обычно первый тест новой фичи всегда проваливается из-за какой-нибудь тупой ошибки вроде «добавил сервис, но не зарегистрировал его в DI-контейнере».
WebRtc — это технология, позволяющая установить peer-to-peer соединение между двумя устройствами, и, в случае если peer-to-peer соединение невозможно, помогающая соединить устройства через сервер. Это не какая-то библиотека, где вы вызываете пару функций, и всё настраивается автоматически за вас. Установка соединения является довольно вовлечённым процессом, но при этом не очень сложным.
Сам сеанс связи в большинстве случаев происходит без участия сервера (peer-to-peer), но для того чтобы установить связь, требуются 3 разных сервера (серверы не обязательно должны быть физически разными, подразумеваются 3 разные роли. Один и тот же сервер в теории может выполнять все 3 функции).
Первый и самый простой — stun сервер. Мы отправляем stun серверу сообщение, и его задача — прочитать Source IP и Source Port пакета и отправить эту информацию обратно, но уже в теле пакета. Для чего это требуется? Прямо сейчас вы скорее всего сидите за каким-то роутером. У роутеров есть внутренний и внешний IP адреса. Когда вы отправляете пакет на какой-то сайт, роутер, получив от вас пакет, заменяет его Source IP и Source Port на свой внешний IP и какой-то сгенерированный порт и делает запись в таблицу NAT вида [ Source IP | Source Port | Router External IP | Router Port ]. Когда роутер получает пакет откуда-то снаружи, он сравнивает Dest IP и Dest Port полученного пакета с колонками Router External IP и Router Port таблицы NAT, и, либо находит соответствующие Source IP — Source Port и пересылает пакет нужному устройству, либо отбрасывает пакет. Важно тут то, что, чтобы пакет попал к вам на устройство, он сначала должен пройти через роутер, а, чтобы пройти через роутер, должна быть соответствующая запись в NAT таблице. Уже сам простой факт отправки сообщения stun серверу генерирует запись в NAT таблице. А в ответ от stun сервера мы получаем пару Router External IP — Router Port. Эта пара — публичный адрес нашего устройства. Отправляя пакеты на данный адрес, устройства «извне» смогут пройти через NAT (NAT traversal) благодаря тому, что нужная запись в таблицу NAT была сделана, когда мы отправили запрос stun серверу.
* Некоторые NAT сложнее, и обойти их не так просто. Собственно, если бы всё было просто, то WebRtc бы и не требовался.
Второй сервер — turn. Это сервер, через который происходит передача потоков между клиентами, когда реальный peer-to-peer невозможен. Fallback сервер. Намного сложнее в реализации, и, в теории, не обязателен, но крайне желателен, потому что peer-to-peer соединение возможно далеко не всегда. Есть свободная реализация turn сервера — coturn, но я его ещё не поднимал.
Третий сервер — сигнальный. Без него тоже в теории можно обойтись, но на практике нет. Этот сервер может быть реализован как угодно, нет никакого специального протокола. Его функция — просто передавать разную конфигурирующую информацию от одного устройства другому и обратно. Эта конфигурирующая информация нужна для установки соединения. Без него возможно обойтись, потому что информацию вы можете передать из уст в уста, например, или используя другой мессенджер 🙂 В передаваемых данных нет ничего особенного — числа и строки.
В WebRtc есть 3 типа сигнальных сообщений: offer, answer и candidate. Инициатор звонка отправляет offer другой стороне через сигнальный сервер, получает в ответ answer, и обе стороны отправляют друг другу кандидатов. Кандидатов может быть много, и, по сути, это такой процесс переговоров, где стороны решают какую транспортную конфигурацию использовать. Возможных транспортных конфигураций (маршрутов от одного устройства к другому) может быть несколько, выбирается наилучший.
Сама по себе технология WebRtc устанавливает соединение и занимается передачей потоков туда-обратно, но это не фреймворк для создания полноценных звонков. Под звонком я подразумеваю сеанс связи с возможностью отменить, отклонить и принять вызов, а также положить трубку. Плюс нужно дать знать звонящему, если другая сторона уже занята. А также реализовать мелочи вроде «ждать ответа на вызов N секунд, затем сбросить». Если просто внедрить WebRtc в приложение в голом виде, то при входящем звонке камера и видео будут спонтанно включаться, что, конечно, неприемлемо.
В чистом виде WebRtc обычно подразумевает как можно более скорую отправку кандидатов другой стороне, чтобы переговоры начались как можно быстрее, что логично. В моих тестах кандидаты принимающей стороне вообще всегда начинали приходить ещё даже до того как придёт offer. Такие «ранние» кандидаты нельзя отбрасывать, их нужно запоминать, чтобы потом, когда придёт оффер, и RTCPeerConnection будет создан, добавить их в соединение. Тот факт, что кандидаты могут начать приходить ещё до оффера, а также некоторые другие причины, делают реализацию полноценных звонков нетривиальной задачей. Что делать, если нам звонят сразу несколько юзеров? Нам будут приходить кандидаты от всех, и, хотя мы можем отделить кандидатов одного юзера от другого, становится неясно каких кандидатов отбрасывать, потому что мы не знаем чей оффер придёт раньше. Также будут проблемы, если нам начинают приходить кандидаты и затем оффер в момент, когда мы сами кому-то звоним.
Текущее состояние и дальнейшие планы
Поиск по QR-коду, неожиданно, довольно проблематично реализовать, потому что почти все плагины для скана кода, которые я пробовал, отказываются заводиться либо работают некорректно. Но, думаю, здесь проблемы будут решены. А за реализацию поиска по геолокации я пока ещё не брался. В теории особых проблем быть не должно.
Уведомления в процессе реализации, как и отправка видео.
Что ещё нужно сделать?
И много всего по-мелочи, вроде конкатенации строк вместо использования StringBuilder ‘а, Dispose не везде вызывается, где должен, и тд и тп. В общем, обычное состояние проекта в процессе разработки. Всё вышеперечисленное решаемо, но есть одна фундаментальная проблема, о которой я вообще не думал до последнего момента, потому что вылетело из головы — мессенджер должен работать даже когда приложение не открыто, а мой — не работает. Если честно, решение этой задачи пока не приходит мне в голову. Тут, видимо, не обойтись без нативного кода.
Я бы оценил готовность проекта в 70%.
Итоги
Полгода прошло с момента начала работы над проектом. Совмещал с парт-тайм работой и делал большие перерывы, но всё равно сил и времени ушло прилично. Планирую реализовать все заявленные фичи + добавить что-нибудь необычное вроде крестиков-ноликов или шашек прямо в комнате. Безо всякой причины, просто потому что интересно.
Если есть какие-то вопросы, пишите. Почта есть на гитхаб.