Как мы вдвоем писали кроссплатформенный мессенджер: Compose Multiplatform, SFU на Go и сжатие трафика

Идея написать собственный мессенджер появилась не от хорошей жизни. У нас была конкретная прикладная потребность: получить полностью независимый инструмент связи, который не завязан на сторонние облачные сервисы, нормально поднимается на скромных серверах и адекватно ведет себя при перебоях со связью.
Мы разрабатывали другой проект, и поначалу для него был написан минимальный чат на CodeIgniter 4 с Vanilla JS и CSS плюс WebSocket‑сервер на Go. Затем стало очевидно, что такой чат слабоват, а для проекта, ориентированного на бизнес‑среду (B2B), еще и недостаточно безопасен. Тогда мы начали внедрять шифрование. Постепенно улучшали код, дорабатывали архитектуру и в какой‑то момент решили выделить мессенджер в самостоятельный проект — заодно хотелось получить реальный опыт в мобильной разработке. А потом просто увлеклись.
Клиентская часть: набиваем шишки с Compose Multiplatform
Хотелось получить производительный и надежный клиент под мобильные платформы и десктоп. Сравнивали React Native, Flutter и другие решения, но остановились на Compose Multiplatform: подкупили обещания высокой производительности, общая кодовая база и живые (хорошие) примеры приложений. Сам Kotlin выглядел понятным и лаконичным, а по духу и строгости чем‑то напоминал Go.
Мы сторонники минимализма, поэтому сознательно избегали тяжелых комбайнов: использовали только необходимый минимум, исключительно open‑source. Для локального хранилища взяли связку Room с нативным SQLite. Для загрузки и кэширования картинок — Coil. Сетевой слой закрыли через Ktor (для CMP или Kotlin Multiplatform это фактически стандарт).
Для мобильной разработки все рекомендуют Dependency Injection фреймворк. Сначала был испробован Koin. Инструмент «не зашел» — то ли мы не до конца прониклись его концепцией, то ли он оказался избыточным для наших задач, а может просто у нас не получилось. В итоге выбросили Koin и написали обычный бойлерплейт с явной передачей зависимостей, которым так любят пугать на разных видео‑курсах. Внезапно все полетело (в хорошем смысле), а отлаживать код и отлавливать ошибки стало в разы проще. В итоге мы так и не подобрали никакой DI.
Придерживаясь принципа писать по максимуму своими руками, мы смогли жестко оптимизировать размер приложения. Реальный вес загрузки из Google Play для пользователя уложился примерно в 10 МБ (хотя «толстый» универсальный APK со всеми ABI весит около 52 МБ).
Сборки под остальные платформы получились более увесистыми, но вполне адекватными:
iOS: около 66 МБ;
Windows: около 74 МБ;
Linux: около 77 МБ;
macOS: около 95 МБ.
Веб‑версия: CodeIgniter 4 + Vanilla JS + Vanilla CSS
По веб‑части у нас уже был практический опыт поддержки нагруженных сервисов на PHP‑FPM + CodeIgniter 4, поэтому велосипед изобретать не стали.
Интерфейс чата на модульном чистом JavaScript. Браузерная версия открывается мгновенно, не требуя времени на распаковку и инициализацию увесистых бандлов сборщика. Да и так намного проще и человеку, и ИИ разобраться во всём. Служебный бэкенд и админку вокруг сайта развернули на CodeIgniter 4 — это позволило быстро закрыть типовые задачи. Браузерный чат работает через тот же WebSocket‑сервер на Go, что и мобильные клиенты.
Сеть и синхронизация: экономим каждый байт на Go
Серверную часть написали на Go с базой данных PostgreSQL. Главная задача — держать стабильные WebSocket‑сессии с минимальным потреблением ресурсов, так как вся инфраструктура развернута на трех небольших VPS:
Сервер 1 — это мозг. Сообщения, уведомления, картинки, загрузки, статусы и так далее
Сервер 2 — STUN/TURN‑сервер — антенна для звонков 1-на-1
Сервер 3 — SFU — для групповых звонков
Синхронизацию диалогов после обрыва сети сделали инкрементальной (Delta Sync): клиент запрашивает не историю целиком, а только срез изменений с момента последнего полученного пакета.
Звонки и видео: почему отказались от готовых комбайнов
С видеосвязью и голосом мы провели больше всего бессонных ночей. Сначала смотрели в сторону готовых решений вроде LiveKit, но быстро поняли: если ты берешь большой монолитный комбайн, ты либо принимаешь его архитектуру целиком со всеми накладными расходами, либо тратишь недели на попытки заставить его экономить память на слабом железе.
В итоге пошли по пути сборки собственного медиаузла:
Для обычных звонков тет‑а-тет используем прямое P2P‑соединение через наш STUN/TURN‑сервер — так что это своего рода антенна.
Для групповых созвонов написали свой легковесный SFU‑сервер на базе библиотеки pion/webrtc на Go (потому что рассматриваемый ранее LiveKit тоже на нем написан).
Под SFU выделили отдельный bare‑metal сервер, чтобы напрямую управлять сокетами и UDP‑портами в Linux. Чтобы видео не превращалось в слайд‑шоу при потерях пакетов, прикрутили динамическую адаптацию битрейта (Simulcast) на основе отчетов RTCP.
Вот тут реально было тяжело. Несмотря на универсальность Compose Multiplatform, все равно пришлось писать и возиться с прихотями каждой системы. Мы, конечно, использовали открытые WebRTC‑проекты — но это были простые библиотеки, а не фреймворки, поэтому пришлось разбираться с ICE candidates для P2P (звонки 1 на 1) и ICE candidates для SFU (групповые звонки). И надо было, чтобы все это работало в одном приложении и чтобы интерфейс не менялся. Можно сказать, мы писали своего рода свойский LiveKit — только чтобы он еще работал с P2P, так как это самый простой и надежный вид связи, когда звонок 1 на 1.
Больше всего времени ушло на борьбу с гонками потоков (race conditions) в приложении. Звонки отваливались при плохом соединении. Невозможно было дозвониться, когда тебе уже звонят. Нужно было убедиться, что все процессы «потухли», когда человек кладет трубку, и одновременно чтобы все было в боевой готовности. Очень много возни прошло с уведомлениями, особенно с Apple CallKit. Он упорно вызывал вылеты, и пришлось возиться в Swift, просто чтобы задать ему какие‑то искусственные временные паузы, чтобы он собрался и позволил сначала сделать уведомление, затем позвонить, затем взять трубку.
Чтобы выявить все проблемы, мы написали свойскую библиотеку логирования — и просто смотрели по логам: «Работает А», «Работает Б» и так далее
Также были race conditions на SFU‑сервере. Тестировали это просто: часами гоняли звонки внутри тестовой сети с искусственно накрученными потерями пакетов до 30–40% и смотрели, где падают горутины.
Хранение данных: как изолировали ключи
Чтобы защитить переписку в базе на уровне дисковых снапшотов и бэкапов, мы реализовали конвертное шифрование (Data‑at‑Rest) алгоритмом XChaCha20-Poly1305.
Схема многоуровневая: каждое сообщение шифруется своим одноразовым ключом (DEK). Этот ключ заворачивается в ключ беседы (convDEK), а доступ к беседе закрывается персональными ключами пользователей (userDEK). На самом верхнем уровне все это контролируется серверным мастер‑ключом, для которого мы сделали механизм ротации без необходимости переписывать всю базу заново.
На чем мы сейчас
Проект живет, клиенты собраны под все целевые платформы (Android, iOS, Windows, macOS, Linux и веб). Мы продолжаем оптимизировать сетевой код и вычищать баги.
Разрабатывать такой комбайн вдвоем тяжело, поэтому мы будем искренне признательны за любую конструктивную критику и отзывы. Если вам интересно потестировать приложение в боевых условиях на разных устройствах — заглядывайте на наш сайт interchat.site. Будем рады любым отчетам об ошибках!
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.