мессенджер на java код
«Хочу как Дуров»: пишем простой мессенджер
В последнее время растёт популярность приложений для обмена сообщениями. Пользователи предпочитают мессенджеры, потому что они позволяют взаимодействовать в режиме реального времени. В этой статье мы разберём процесс создания простого приложения для обмена мгновенными сообщениями. Не будем сильно углубляться в нюансы разработки: мы напишем рабочий мессенджер, который в дальнейшем можно будет улучшить.
Статья подойдёт состоявшимся программистам и тем, кто только интересуется, как войти в IT.
Используемые технологии и инструменты
Подготовка
Структура будущего приложения выглядит примерно так:
Установите Node.js и MongoDB. Кроме того, нам понадобится библиотека AngularJS, скачайте её и скопируйте в папку lib каталога Client.
Примечание: созданный мессенджер можно будет интегрировать в качестве виджета в любой проект.
Серверная часть
Шаг 1. Запуск проекта
Перейдите в каталог Server и выполните команду:
Она запустит новый проект.
9–10 октября, Москва и онлайн, Беcплатно
Укажите все необходимые сведения. В результате будет создан файл 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.
Хинт для программистов: если зарегистрируетесь на соревнования Huawei Cup, то бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.
Перейти к регистрации
Простой клиент-сервер на Android (интернет-мессенджер)
Важно. Все написанное ниже не представляет собой какой либо ценности для профессионалов, но может служит полезным примером для начинающих Android разработчиков! В коде старался все действия комментировать и логировать.
Поехали. Многие мобильные приложения (и не только) используют архитектуру клиент-сервер. Общая схема, думаю, понятна.
Уделим внимание каждому элементу и отметим:
Клиент, установленный на устройстве А, посылает сообщение для клиента, установленного на устройстве Б. И наоборот. Сервер играет роль связующего звена между устройством А и Б… С, Д… и т.д. Также он играет роль «накопителя» сообщений, для их восстановления, на случай удаления на одном из клиентских устройств.
Для хранения сообщений используем SQL БД как на сервере, так и на устройствах-клиентах (в принципе, вся работа клиентов интернет-мессенджеров и сводится к постоянной синхронизации локальной и удаленной БД с сообщениями). Дополнительно, наш интернет-чат будет уметь стартовать вместе с запуском устройства и работать в фоне. Взаимодействие будет происходить путем HTTP запросов и JSON ответов.
Более логично, если синхронизация происходит через порт/сокет, это с одной стороны упрощает задачу (не нужно циклично слать HTTP запросы на проверку новых сообщений, достаточно проверять состояние прослушиваемого сокета), но с другой стороны, это усложняет создание серверной части приложения.
Делаем сервер
Для реализации «сервера», нам нужно зарегистрироваться на любом хостинге, который дает возможность работы с SQL и PHP.
Создаем пустую SQL БД, в ней создаем таблицу.
Структура запросов к api:
Клиентская часть
Теперь структура Android приложения:
В фоне работает FoneService.java, который, в отдельном потоке, каждые 15 секунд делает запрос на сервер. Если ответ сервера содержит новые сообщения, FoneService.java записывает их в локальную БД и отправляет сообщение ChatActivity.java о необходимости обновить ListView, с сообщениями. ChatActivity.java (если она в этот момент открыта) получает сообщение и обновляет содержимое ListView из локальной БД.
Отправка нового сообщения из ChatActivity.java происходит сразу на сервер, минуя FoneService.java. При этом наше сообщение НЕ записывается в локальную БД! Там оно появится только после получения его назад в виде ответа сервера. Такую реализацию я использовал в связи с важным нюансом работы любого интернет-чата — обязательной группировкой сообщений по времени. Если не использовать группировку по времени, будет нарушена последовательность сообщений. Учитывая, что клиентские приложения просто физически не могут быть синхронизированы с точностью до миллисекунд, а возможно будут работать даже в разных часовых поясах, логичнее всего будет использовать время сервера. Так мы и делаем.
Создавая новое сообщение, мы передаем запросом на сервер: имя автора сообщения, имя получателя сообщения, текст сообщения. Получая эту запись назад, в виде ответа сервера, мы получаем то, что отправляли + четвертый параметр: время получения сообщения сервером.
Пишем мессенджер с открытым исходным кодом
Давным-давно в одной далекой стране была компания America Online. И был у нее удивительный частный Интернет за заборчиком, где вместо URL-ов были «keywords»: что-то среднее между адресом веб страницы и купленным ключевым словом в рекламе. Компании боролись за интересные ключевые слова, как сейчас борются за домены, а реклама выглядела так: «посетите нас во всемирной сети по адресу www.example.com, или наберите AOL Keyword: ‘banking'».
История имеет свойство повторяться. Сейчас роль Америки Онлайн играют основные мессенджеры: все они за заборчиками, несовместимы друг с другом, все изобретают свои keywords, желают схватить пользователя и уже никогда не отпускать. Компании не заинтересованы в открытости: более крупные игроки не желают делиться пользователями с более мелкими и уж тем более становиться открытыми. В результате невозможно послать сообщение даже из WhatsApp в Facebook Messenger, несмотря на то, что оба принадлежат одной компании. Да и пользователи ценят надежность и удобство выше абстрактной открытости, хотя многих раздражает, что часть друзей, например, в Telegram, часть в WhatsApp, а родители в Skype.
А вот роль открытого интернета, к сожалению, сегодня не играет никто. Ситуацию хочется изменить. Если XMPP не справился, может быть кто-то другой сможет? И тут рассказ про Tinode.
Что такое Tinode
Tinode — мессенджер с полностью открытым исходным кодом на Github. Все клиентские приложения (ReactJS и Андроид) лицензированы под Apache 2.0, для того, чтобы упростить создание коммерческих приложений на основе Tinode, сервер под GPL 3.0. Цель проекта — создать федерированный мессенджер, который прост и удобен как для пользователей, так и для операторов. Поставил — и все работает, как MySQL или Nginx. В долгосрочной перспективе цель проекта – создать открытую альтернативу существующим проприетарным мессенджерам, повторить в отношении мессенджеров то, что сделал Android в отношении операционных систем для мобильных телефонов.
Что он умеет
Поддержка множественных устройств.
У всех есть смартфон, иногда не один, плюс часто удобно использовать web-приложение с основного компьютера. Поэтому поддержка множественных устройств была одним из главных требований к проекту, что определило основные архитектурные решения. Если пользователь авторизуется с нового устройства, то не хочется, чтобы он начинал с чистого листа как в WeChat. А это означает, что нужно и адресную книгу, и сообщения хранить на сервере, что и было реализовано.
Очевидно, что хранение информации пользователя на сервере подходит не всем, так как создает риски нежелательного доступа: чем больше копий данных хранится в разных местах, тем выше вероятность, что что-то пойдет не так. Для этого предусмотрена возможность эфемерных сообщений и сообщений, которые удаляются с сервера после доставки клиенту. Технически, существует и возможность не хранить контакты на сервере постоянно — клиент отправляет их на сервер в момент подключения (login), затем они удаляются после logout. Однако, авторы посчитали это непрактично сложным и не стали делать.
Онлайн статус
Трансляция онлайн/оффлайн статуса пользователя в мессенджерах воспринимается как что-то само собой разумеющееся, однако, это весьма тяжелая в реализации фича. Нужно, чтобы она «просто работала», предсказуемо и надежно. Надежность работы исключила генерацию статуса на клиенте, как это реализовано в некоторых XMPP приложениях. В случае Tinode, сервер генерирует онлайн статус и рассылает по адресной книге, что, опять же, требует хранения контактов на сервере и их синхронизацию с клиентскими приложениями.
Простота протокола
Протокол хотелось сделать таким, чтобы кривая обучаемости была пологой – не нужно знать всего, чтобы начать. Спецификация получилась очень компактной: 10 запросов клиента, 5 ответов сервера. Например, по сравнению с 200+ страницами только core XMPP, не считая extensions, это почти записка на салфетке.
Представление данных отделено от сетевого протокола. Протокол лишь требует определенную структуру данных, но не требует, чтобы они передавались по сети каким-то определенным образом. Сейчас сервер поддерживает JSON по websocket и long polling, c TLS и без, плюс gRPC по TCP. Поддержка gRPC была реализована одним разработчиком за две недели, включая написание текстового клиента на Питоне. Добавление поддержки иных форматов данных и протоколов, например, MessagePack или Noise, вряд ли займет намного больше.
Расширяемость
С одной стороны, хочется, чтобы все работало сразу, например, чтобы основной функционал был сравним с WhatsApp и Telegram прямо из коробки. С другой, потребности у людей разные и нужно иметь возможность расширять функционал. Поиск баланса похож на выбор между монолитной архитектурой и микросервисами: нежелательно иметь неизменяемый монолит, и, аналогично, плохо получить получить зоопарк микросервисов, управление которыми превращается в отдельную задачу.
Было принято решение разделить функционал на три части — основной, сетевой и вспомогательный. Основной — это то, что позволяет Tinode выполнять основную функцию — пересылать сообщения. Сетевой — функционал взаимодействия в серверами, как формат передаваемых данных и сетевой протокол. Вспомогательный — то, что решает чью-то локальную задачу, например, поддержка конкретной базы данных в качестве бэкенда или какой-то метод авторизации, но никак не влияет на другие сервера или пользовательские приложения. Основной функционал реализован в основном коде. Сетевой функционал выделен, но также хранится в основном репозитории для того, чтобы по возможности избежать создания несовместимых серверов. Вспомогательный реализован в виде плагинов — компилируемых Go интерфейсов (поддержка разных баз данных, разных авторизаторов, пуш нотификации, валидаторы по емейл или телефону, поддержка каптчи и т.п.) и gRPC endpoints (чатбот и поисковый интерфейс).
Прочее
Почему Go?
Сервер для мессенджера по сути роутер: получает сообщение из одного канала, как-то его обрабатывает, затем передает в другой канал или каналы. Go (как и Erlang, но это уже другая история) идеально подходит для создания такого функционала т.к. содержит примитивы goroutine и chan, делающие организацию потоков и обмен данными между ними эффективным и простым.
Безусловно, роутер можно написать и на C/C++, и на Java. Однако, при прочих равных, код скорее всего получится более сложным и потребует больших усилий для избежания дедлоков.
А что потом?
Федерация
Одна из основных задач для Tinode на ближайший год — создание платформы для федерации. Так, чтобы любой желающий мог запустить свой Tinode сервер, который бы мог обмениваться сообщениями с любым другим сервером, точно так, как это возможно с емейлом. Уже сейчас возможна кластеризация серверов. Сетевой обмен между сервером и клиентами идет по TLS websocket, что для внешного наблюдателя мало отличимо от простого HTTPS трафика.
Публичный DNS, вероятно, будет использоваться, по крайней мере первоначально. Однако, в будущем поиск чат-серверов будет осуществляться также, как это сделано в Bittorrent — при помощи DHT, распределенной хеш таблицы.
Хочется также избежать и проблем, за которые часто критикуют XMPP. Например, XMPP сервера очень разговорчивы. До половины сообщений является дублирующими, когда XMPP-клиент рассылает онлайн уведомления индивидуально каждому контакту из адресной книги.
Репутация и распределенное принятие решений
Системы обмена сообщениями больше всего похожи на емейл. Как известно, значительная часть электронной почты — спам. Не хочется повторять чужие ошибки, поэтому механизмы, ограничивающие спам, должны быть сразу встроены в систему. Полностью задача пока не решена, но есть общее направление:
Если посмотреть на распределенную систему принятия решения и учета репутации с высоты птичьего полета, то станет заметно сходство с blockchain. Возможно, blockchain (но не криптовалюта) может быть использован в качестве основы для построения распределенной системы репутации, хотя пока и не очевидно каким образом.
Шифрование
Ну а как же в наши дни без шифрования сообщений? Чаты между двумя людьми, вероятно, будут шифроваться OTR. С групповыми чатами пока непонятно. Все известные схемы шифрования групповых чатов либо имеют значительный недостатки, либо тяжеловесны и сложны в реализации. Также, не очевидно, насколько важно шифрование групповых чатов: «Если тайну знают двое – это уже не тайна, а если трое – это уже базар.»
Как я свой мессенджер писал
Одним вечером, после очередного расстраивающего дня, наполненного попытками наладить баланс в своей игре, я решил, что мне срочно требуется отдых. Переключусь на другой проект, быстренько его сделаю, верну на место скатившуюся за время разработки игры самоооценку и с новыми силами возьму игру штурмом! Главное выбрать проект 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%.
Итоги
Полгода прошло с момента начала работы над проектом. Совмещал с парт-тайм работой и делал большие перерывы, но всё равно сил и времени ушло прилично. Планирую реализовать все заявленные фичи + добавить что-нибудь необычное вроде крестиков-ноликов или шашек прямо в комнате. Безо всякой причины, просто потому что интересно.
Если есть какие-то вопросы, пишите. Почта есть на гитхаб.