Найти тему
Сделай игру

Делаем свою систему обмена сообщениями, мессенджер проще говоря

Оглавление

Довольно давно у меня в голове зрела идея создания своей системы обмена сообщениями. Причина была довольно прозаична: я не то, чтобы сильно доверяю существующим мессенджерам по той причине, что они если чего не обеспечивают, так это приватности: все бравурные заявления про шифрованный трафик и отказа от сотрудничества со спецслужбами - просто маркетинг, который, мягко говоря, не совсем соответствует действительности.

Впрочем, есть ещё одна причина: мне банально любопытно.

Медведь-почтовик
Медведь-почтовик

Немного анонса

В принципе, любой мессенджер, или система обмена сообщениями (СОС) - штука довольно многоуровневая и пользователю видна лишь небольшая часть, эдакая верхушка айсберга. И мне она не интересна. Поэтому, я буду делать систему, состоящую из нескольких блоков, обеспечивающих необходимую мне функциональность, но не более (хотя, может быть потом, что-то поменяю).

Второй момент, который надо упомянуть, это компонентная архитектура приложения: дело в том, что реализация задумки допускается по частям. Сперва я решил сделать механизм формирования и отправки сообщений, а потом добавить всё остальное. Приступим.

Делать буду на Rust: во-первых, мне он нравится, во-вторых, хорошо интегрируется в web.

Примечание

Честно говоря, у меня есть большие сомнения в том, что данная СОС будет работать на телефонах и в web-приложениях исключительно из-за привязки к файлам и файловой системе, которую я планирую использовать на начальном этапе. Впрочем, со временем это можно будет поправить или вообще переделать, так что пока что будем обкатывать технологию.

Фаза первая: подготовка данных

Не секрет, что любая СОС - это всего лишь средство обмена данными. Привычная нам электронная почта - это частный случай. Но всё это, в той или иной степени, является лишь надстройками над существующими механизмами, который активно используются внутри "этих ваших интернетов".

По сути, любые передаваемые данные - это файл (а файл, в терминологии GNU/Linux систем - напомню - это просто некоторая точка входа с данными). Его размер, структура и способ доступа - не важны. Этим я и воспользуюсь.

Упаковка

Изначально будем считать, что в основе передаваемых сообщений может быть один или несколько файлов: написанное текстовое сообщение (созданный файл в памяти), фотографии котиков и щеночков, короткое видео с падением прораба на стройке или pdf-документ с курсовой работой и тому подобное.

Итак, основа передаваемого сообщения - файл. Но как запихнуть в один файл несколько (как в указанном выше случае)? Ответ простой - нам поможет tar!

Tar - это упаковщик файлов, который позволяет взять несколько из них и сплющить в один. А потом распаковать. В добавок, упакованные файлы можно сжать, чтобы они занимали меньше места.

Простые исходники простого сжатия
Простые исходники простого сжатия

Итак, немного кода из справки - и у нас уже готов механизм подготовки и сжатия данных. Если кто не понял, то тут создаётся файл archive.tar.gz и в него записывается директория "../queue_sync/src/" (я взял исходники одного из своих проектов) и она добавляется целиком в файл, попутно сжимаясь gzip. Размер исполняемого файла (неоптимизированного) - почти 9 мегабайт, оптимизированного - 4,8.

Добавим временный файл

Как говорилось, основной файл - это сообщение, которое надо добавить в файл сборки. Тут хорошо бы использовать файл, спроецированный в память, однако существующий механизм умеет работать только с "физическими" файлами, то есть файлы, которые размещены в файловой системе, а, так как, нам нужно сделать результат как можно быстрее - будем сообщение сохранять в текстовый файл, а затем добавлять в файл-обёртку.

Да, ещё надо сказать пару слов про компоновку файлов в упаковке: дело в том, что мы навряд ли будем добавлять целиком директории, а вот несколько разнородных файлов - вполне; более того, важен ещё и порядок. Поэтому будет действовать такая система: каждый новый элемент (сообщение, набор фотографий и прочее) будет добавляться в архив внутри отдельной "директории" (директория - это будет просто префикс в названии файла), названия которых будет - нумерация от 1 и до 255 (будем считать, что больше вложений добавить невозможно); внутри директории будет минимум 1 файл; в случае с сообщением - это будет текстовый файл с произвольным именем и расширением ._message (размер не может быть более 1Мб); прочие же файлы будут рассматриваться как вложение.

Таким образом, если мы добавили сообщение, а затем пару файлов вторым темпом - содержание архива будет выглядеть примерно так:

-rw-rw-r-- 1000/1003 52 2024-02-05 19:07 1/tGuhGRwKHP4SKfys._message
-rw-rw-r-- 1000/1003 129 2024-02-05 19:07 2/test1
-rw-rw-r-- 1000/1003 134 2024-02-05 19:07 2/test2
fQ2abynoSQ4eln5L.tar.gz (END)

То есть 1/ и 2/ - это, вроде как, префиксы, которые можно воспринимать как директории.

Знаю, так себе решение; лучше было бы сделать некоторую систему более внятной идентификации, но если пойти по этому пути, мы никогда не сделаем прототип, погрязнув в реализации деталей.

В общем, я накидал общий интерфейс работы - получилось примерно так:

Ориентировочный исходник будущего упаковщика
Ориентировочный исходник будущего упаковщика

В результате, получилось небольшое приложение, которое объединяет данные в один файл с тем, чтобы его отправить. Исходный код получается такой; первую версию её можно посмотреть тут:

Упаковщик готов, но работает не совсем правильно
Упаковщик готов, но работает не совсем правильно

С горем пополам сообщение упаковалось в один файл. Но этого явно недостаточно. Во-первых, упаковщик не удаляет файл отправки, что явно не комильфо. Во-вторых, данные не шифрованные от слова "совсем", что не комильфо вдвойне. И в-третьих, данные можно добавлять и без копирования файлов.

Ну, запишем это себе в техдолг и двинемся дальше, тем более, что всё вышеперечисленное, относительно легко, можно изменить, но потребует времени.

Отправка данных: в двух словах

Итак, следующий шаг - это отправить файл. А вот тут есть нюанс. Даже два. Нам нужен сервер, который этот файл получит и клиент, которому сообщение будет передано.

Что делает клиент:

  1. Подключается к серверу по таймеру (или по запросу);
  2. Запрашивает, нет ли для него сообщения;
  3. Если нет сообщения - тихо отключается;
  4. Если есть - получает его от сервера, разворачивает и предоставляет пользователю.

Что делает сервер:

  1. Принимает подключение от любого из клиентов;
  2. При получении сообщения, сохраняет его, до передачи;
  3. При подключении второго клиента - отправляет сообщение ему;
  4. Ждёт подключений.

Очевидно, что из списков видно, что потребуются некоторые идентификаторы. Но это так, пустяки.

Итого, что мы имеем:

  1. Клиент - общий, но он запускается с некоторым идентификатором;
  2. Клиент работает по запросу, то есть пока что не запущен всё время;
  3. Сервер один, но работает постоянно, принимая подключения;
  4. При подключении, клиент передаёт свои отправления (если есть) и получает свою корреспонденцию (если есть);
  5. В каждом сообщении содержится идентификатор клиента, а вот идентификатор получателя может отсутствовать (когда нечего отправлять, а получить свои сообщения надо);
  6. Входящие данные сохраняются в папочку клиента, при его подключении - они выбираются оттуда;
  7. Данный алгоритм будет расширяться, обрастая новыми функциями и улучшениями, но это позже.

Выглядит просто, но, зная rust, это будет тот ещё забег по граблям. Хорошо что есть много готовых решений.

Первым делом - сервер

Итак, он должен получать и передавать сообщения. Пока что без всякой защиты. Исходник простейшего сервера можно взять из справочной документации (я вот отсюда взял). По сути, мы просто принимаем подключение и обрабатываем его. Никакого шифрованного трафика. Пока что. Добавим это в технический долг.

Первый сервер
Первый сервер

Код, как видно, получился более чем скромный (оно и понятно). Ради интереса, я проверил подключение через wget (но, т.к. мы никакого ответа не возвращаем - wget слал запросы опять и опять).

Запросы идут, что хорошо.
Запросы идут, что хорошо.

Теперь будем играть по-взрослому: подключение будет слать бинарные данные. Осталось только определиться, какой будет состав сообщения. Наверное, всё будет выглядеть так:

  • 2 байта - версия протокола;
  • 8 байт - идентификатор отправителя;
  • 8 байт - идентификатор получателя (если сообщения нет - то нули);
  • остальное - полезная нагрузка (пакет с данными, возможно - шифрованными).

Правда тут явно не хватает подписи достоверности: как мы вообще узнаем, что сообщение пришло от того пользователя, который его отправил? Думаю, надо будет расширить протокол ещё на 8 байт (перед полезной нагрузкой) - там будет подпись. Нам же хватит 8 байт на какой-нибудь аналог подписи?

Поначалу она будет пустой, нули то есть; запишем необходимости реализации в технический долг. Обновлённая версия протокола:

  • 2 байта - версия протокола;
  • 8 байт - идентификатор отправителя;
  • 8 байт - идентификатор получателя (если сообщения нет - то нули);
  • 8 байт - подпись-подтверждение отправителя (пока что нули);
  • остальное - полезная нагрузка (пакет с данными, возможно - шифрованными).

Как было сказано ранее, обращение клиента к серверу приводит к отправке ему (клиенту то есть) всех его сообщений (тут, конечно, требуется некоторая метка синхронизации, отделяющая уже отправленные сообщения от новых, но это, пока что, техдолг). Если ничего для обратившегося клиента нет - будем слать 0 и закрывать соединение. А если произошла какая-то ошибка - будем слать 1. И тоже закрывать соединение.

Пересылаемые сообщения сворачивается в один пакет и выглядит он так:

  1. 2 байта - версия протокола;
  2. 8 байт - 64-разрядное число-время, по совместительству имя, файла;
  3. 4 байта - размер файла, который пойдёт дальше;
  4. икс байт - файл;
  5. если файлов несколько, то пункты 2, 3 и 4 повторяются столько раз, сколько надо.

Тут, безусловно, появляется вопрос к предельному размеру файла: может выйти весьма приличным. Но мы и это запишем в техдолг. Даже не так: это надо будет обдумать получше; вполне возможно, передача данных по частям (порциями) будет более эффективна. Особенно, когда пакет достаточно большой.

И у нас получился сервер, к которому можно подключиться, передать сообщение (и он его сохранит), а, также, запросить свои сообщения. И он, если они есть, конечно их отдаст. Уже что-то. Исходники можно посмотреть тут.

Первые результаты

Итак, мы смогли отправлять данные на сервер и запрашивать их. Клиент у меня, пока что, не настраивается (привет, техдолг), поэтому я одним запуском отправляю данные, затем меняю ID клиента и, вторым запуском, забираю их обратно. Это тоже пошло, пока что, в техдолг.

По сути, остался последний рывок, в результате которого можно отправить сообщение или запросить свои сообщения. А сервер эти данные, соответственно, примет и предоставит. Если они у него, конечно, есть.

Клиент: чтение сообщений

Следующим этапом, после того, как мы получили сообщения, это их надо прочитать. Потому что какой смысл в сообщениях, если их можно только передать?

Я пойду, опять же, по пути наименьшего сопротивления. Пришедшие с сервера данные будут разбираться и сохраняться в /tmp директорию под временными именами (потом будут удалены); затем, эти файлы (а это, как мы помним, tar архивы) будут разворачиваться и сохраняться в папку сообщений клиента. Тут будет такой порядок: у каждого клиента есть папочка с его идентификатором (это для тестовых нужд; это позволит рядом держать несколько клиентов), внутри папочки - папочки с отправителями, т.е. теми, кто отправляет клиенту сообщения. Внутри папочки отправителей - папочки с сообщениями, каждая папка-сообщение с именем-датой-временем, а внутри папки-сообщения - файлы сообщения. Запутано? Не очень, вот так это выглядит

Структура хранения сообщений
Структура хранения сообщений

Как не трудно заметить, всю эту плеяду папок создавать та ещё морока; надо проверять, есть ли они, создались ли... чтобы всего этого избежать - мы пойдём по пути использования оболочки: будем вызывать команду mkdir с параметром -p.

Говоря по правде, я уже использовал вызов команд ранее и исходники есть даже в тех ссылках, которые упоминались, но, на всякий случай, продублирую ещё раз:

Вызов произвольной команды сильно облегчит жизнь при работе с файловой структурой
Вызов произвольной команды сильно облегчит жизнь при работе с файловой структурой

Всё шло отлично ровно до того момента, пока я не попытался развернуть полученный набор данных. И вдруг обнаружилось, что, несмотря на все эти многочисленные идентификаторы, мы совершенно не отслеживаем отправителя сообщения. То есть, при получении сообщения, нигде нет информации о том, кто их отправил.

По сути, в данном случае есть 2 выхода. Первый, добавлять в каждый отправляемый пакет файл с информацией о том, кто эти данные отправил. Второй - в сохраняемых именах сообщений использовать идентификатор отправителя (<время>_<отправитель>).

Второй вариант мне видится более правильным, т.к. теоретически возможна временная коллизия, когда сразу 2 отправителя пришлют в одно и то же время сообщение клиенту и одно их них потеряется. На практике, вероятность такого события - ничтожна, а реализация защитных мер потребует дополнительных действий, поэтому запишем это в техдолг, а реализуем первый подход.

Поэтому дополним пакет данных файлом с идентификатором, который будет иметь расширение *._sender.

Однако, по мере того, как реализация продвигалась, возникла другая проблема и она носила чисто технический характер. Ранее, мы договорились, что упаковываемые в архив файлы будут иметь вид "<номер>/<имя файла>", но при попытке развернуть такой файл из архива, возникала ошибка. Поэтому я внёс изменения в упаковщик, разделив порядковый номер с именем файла двойным подчёркиванием. Получилось так: "<номер>__<имя файла>". Тут, правда, появилась потенциальная проблема с сортировкой порядка сообщений (1, 10, 100, 2, 20, 200 вместо 1, 2, 10, 20, 100, 200). Но это запишем в техдолг.

Итого

В конечном итоге, получился первый прототип, который вполне себе работает (да, много чего надо бы ещё сделать, но и этого, пока что, хватит). Выглядит всё примерно так:

Получили сообщения и отобразили их
Получили сообщения и отобразили их

Исходный код сервера можно посмотреть тут, а клиента - тут. Ну и, конечно, вот он, список технического долга, без которого эту поделку, по большому счёту, системой обмена сообщений называть нельзя:

  1. Улучшить упаковку во временный файл - это можно делать, кажется, прямо в памяти: надо почитать документацию;
  2. Добавить возможность шифрования данных произвольным шифром с заданным ключом;
  3. Надо бы проверить, что будет, если отправить достаточно большой файл (несколько мегабайт) - он будет прочитан или чтение сорвётся?
  4. Если подключение изначально ложное - надо это прояснять на этапе анализа заголовков и обрывать его;
  5. Зашифровать пересылаемый трафик между клиентом и сервером (пока не ясен инструмент);
  6. Надо проверять, что сообщение пришло от настоящего пользователя - подпись пользователя;
  7. Надо слать клиенту не все сообщения, а лишь те, которые не были им прочитаны с прошлого обращения;
  8. Размер файлов для передачи (набор данных как на передачу со стороны сервера, так и на сервер) надо бы ограничить;
  9. Клиент должен запускаться с ключами и параметрами, позволяющими отправлять и получать сообщения;
  10. Некоторые функции используются в клиентском и серверном приложениях - надо вынести их в общую библиотеку (транспорт, протоколы, чтение файлов и данных);
  11. Идентификатор отправителя более не записывается в пакет отправляения, а папка со входящими сообщениями содержит в имени не только время, но и идентификатор отправителя;
  12. Сохраняемые в архив сообщения должны иметь префикс с двумя нулями (не 1, а 001);
  13. Отправленные с сервера сообщения, вероятно, надо удалять;
  14. Не надо загружать одни и те же сообщения по нескольку раз;
  15. Надо сохранять ещё и свои сообщения.

Короче говоря, работы - непочатый край. Но это уже совсем другая история.