Как написать мессенджер для андроид

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

Статья подойдёт состоявшимся программистам и тем, кто только интересуется, как войти в IT.

Используемые технологии и инструменты

  1. Стек MEAN (Mongo, Express, Angular, Node).
  2. Сокеты для прямого обмена сообщениями.
  3. AJAX для регистрации и входа.

Подготовка

Структура будущего приложения выглядит примерно так:

мессенджер

Установите Node.js и MongoDB. Кроме того, нам понадобится библиотека AngularJS, скачайте её и скопируйте в папку lib каталога Client.

Чтобы сделать пользовательский интерфейс приложения привлекательнее, вы можете воспользоваться любой CSS-библиотекой. Скачайте её и скопируйте в lib.

Примечание: созданный мессенджер можно будет интегрировать в качестве виджета в любой проект.

Серверная часть

Шаг 1. Запуск проекта

Перейдите в каталог Server и выполните команду:

npm init

Она запустит новый проект.

Укажите все необходимые сведения. В результате будет создан файл package.json примерно следующего вида:

{
  "name": "chat",
  "version": "1.0.0",
  "description": "Chat application",
  "main": "server.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "Your name",
  "license": "ISC"
}

Шаг 2. Установка зависимостей

  • socket.io — JavaScript-библиотека, которая предоставляет двустороннюю связь клиента и сервера в режиме реального времени;
  • express — фреймворк Node.js, предоставляющий набор функций для разработки мобильных и веб-приложений. Позволяет отвечать на HTTP-запросы, используя промежуточное ПО, а также отображать HTML-страницы.

Выполнение этих команд установит необходимые зависимости и добавит их в package.json:

npm install --save socket.io
npm install --save express

Выглядеть они будут примерно так:

"dependencies": {
    "express": "^4.14.0",
    "socket.io": "^1.4.8"
}

Шаг 3. Создание сервера

Создадим сервер, который обслуживает порт 3000 и возвращает HTML-файл при вызове. Для инициализации нового соединения сокету нужно передать HTTP-объект. Событие connection будет прослушивать входящие сокеты, каждый сокет будет выпускать событие disconnect, которое будет вызвано при отключении клиента. Мы будем использовать следующие функции:

  • socket.on(...) — ожидает событие, и когда оно происходит, то выполняет функцию обратного вызова.
  • io.emit(...) — используется для отправки сообщения всем подключенным сокетам.

Синтаксис следующий:

socket.on('event', function(msg){})
io.emit('event', 'message')

Создайте сервер с именем server.js. Он должен:

  • Выводить сообщение в консоль при подключении пользователя.
  • Слушать событие chat message и транслировать полученное сообщение на все подключенные сокеты.
  • Когда пользователь отключается, выводить сообщение в консоль.

В результате ваш сервер будет выглядеть примерно так:

var app = require('express')();
var http = require('http').Server(app);
var io = require('socket.io')(http);

app.get('/', function(req, res){
  res.sendfile('index.html');
});

io.on('connection', function(socket){
  console.log('user connected');
  socket.on('chat message', function(msg){
    io.emit('chat message', msg);
  });
  socket.on('disconnect', function(){
    console.log('user disconnected');
  });
});

http.listen(3000, function(){
  console.log('listening on *:3000');
});

Клиентская часть

Создайте файлы index.html в каталоге Client, style.css в каталоге CSS и app.js в каталоге js.

Client/index.html

Пусть это будет простой HTML-код, который получает и отображает наши сообщения.

Включите скрипты socket.io-client и angular.js в ваш HTML:

<script src="/path/to/angular.x16217.js"></script>
<script src="/socket.io/socket.io.x16217.js"></script>

socket.io служит для нас клиентом. Он по умолчанию подключается к хосту, обслуживающему страницу.

В результате index.html должен выглядеть примерно так:

<!doctype html>
<html ng-app="myApp">
  <head>
    <title>Socket.IO chat</title>
    <link rel="stylesheet" href="/css/style.x16217.css">
    <script src="/lib/angular/angular.x16217.js"></script>
    <script src="/socket.io/socket.io.x16217.js"></script>
    <script src="http://code.jquery.com/jquery-1.11.1.js"></script>
    <script src="/js/app.x16217.js"></script>
  </head>
  <body ng-controller="mainController">
    <ul id="messages"></ul>
    <div>
      <input id="m" ng-model="message" autocomplete="off" />
      <button ng-click="send()">Send</button>
    </div>
  </body>
</html>

CSS/style.css

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

* {
  margin: 0; 
  padding: 0; 
  box-sizing: border-box; 
}
body { 
  font: 13px Helvetica, Arial;
}
div {
  background: #000; 
  padding: 3px; 
  position: fixed; 
  bottom: 0; 
  width: 100%; 
}
div input { 
  border: 0; 
  padding: 10px; 
  width: 90%; 
  margin-right: .5%; 
}
div button { 
  width: 9%; 
  background: rgb(130, 224, 255); 
  border: none; 
  padding: 10px; 
}
#messages { 
  list-style-type: none; 
  margin: 0; 
  padding: 0; 
}
#messages li { 
  padding: 5px 10px; 
}
#messages li:nth-child(odd) { 
  background: #eee; 
}

js/app.js:

Создайте Angular-приложение и инициализируйте соединение сокета. Для этого нужны следующие функции:

  • socket.on(...) — слушает определенное событие, и, когда оно происходит, выполняет функцию обратного вызова.
  • socket.emit(...) — используется для отправки сообщения конкретному событию.

Синтаксис следующий:

socket.on('event name', function(msg){});
socket.emit('event name', message);

Теперь, когда будет набран текст сообщения и нажата кнопка, вызовите функцию отправки сообщения. Когда сокет получит сообщение, отобразите его.

В результате app.js будет выглядеть примерно так:

var app=angular.module('myApp',[]);

app.controller('mainController',['$scope',function($scope){
  var socket = io.connect();
  $scope.send = function(){
    socket.emit('chat message', $scope.message);
    $scope.message="";
  }
  socket.on('chat message', function(msg){
    var li=document.createElement("li");
    li.appendChild(document.createTextNode(msg));
    document.getElementById("messages").appendChild(li);
  });
}]);

Запуск приложения

Перейдите в папку с server.js и запустите команду:

node server.js

Сервер начнет работу на порте 3000. Чтобы в этом убедиться, перейдите по ссылке в браузере:

http://localhost:3000

Ваш собственный мессенджер готов!

Что можно улучшить?

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

Установите Mongoose или MongoDB для работы с базами данных Mongo:

npm install --save mongoose

или:

npm install --save mongodb

Можете ознакомиться с документацией по их использованию: mongoose и mongodb.

Схема должна получиться примерно следующего вида:

{
 "_id" : ObjectId("5809171b71e640556be904ef"),
 "name" : "Monkey proger",
 "handle" : "mkproger",
 "password" : "proger228",
 "phone" : "8888888888",
 "email" : "dontwritemepleez@gmail.com",
 "friends" : [
    {
      "name" : "habrick",
      "status" : "Friend"
    },
    {
      "name" : "javaman",
      "status" : "Friend"
    }
 ],
 "__v" : 0
}

Собеседникам могут быть присвоены следующие статусы:

  • Friend — собеседник является другом.
  • Pending — собеседник пока не принял запрос.
  • Blocked — собеседник заблокирован.

Предположим, что собеседник отклонил запрос на приватную беседу. В таком случае отправитель должен иметь возможность снова направить запрос.

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

Также вы можете создать REST API для обслуживания клиента. Например, конечную точку, отправляющую домашнюю страницу, из которой пользователи могут выполнять другие запросы.

Некоторые из возможных конечных точек API:

app.post('/register', function(req,res){})

app.post('/login', function(req,res){})

app.post('/friend_request', function(req,res){})

app.post('/friend_request/confirmed', function(req,res){})

Вот какой мессенджер получился у автора статьи:

мессенджер

Окно для входа

мессенджер

Внешний вид приложения

Исходный код приложения можно найти на GitHub.

Адаптированный перевод статьи «How to build your own real-time chat app»

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

Если вы сомневаетесь нужно в ли ступать на территорию разработки мессенджеров, просто взгляните на последние цифры. В 2021 году приложением Facebook Messenger пользуются 1,3 миллиарда человек во всем мире, а WhatsApp есть на телефоне у 2 миллиардов пользователей (¼ часть населения земли, на секунду). 

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

В этой статье мы подробно расскажем о том, как разработать и монетизировать приложение для обмена сообщениями. Тех, кто дочитает до конца, ждет сюрприз — бесплатный расчет стоимости приложения.

Ключевые функции мессенджеров

Давайте сразу к делу — существует список функций, которые нельзя игнорировать при разработке мессенджера. В Purrweb мы считаем, что приложение должно быть, в первую очередь, удобным для пользователя, учитывать боли и запросы клиентов. Поэтому мы всегда советуем начать с командного мозгового штурма — это поможет определить нишу, целевую аудиторию, а также выбрать то, что будет отличать вас от конкурентов. После это можно думать, как создать свой мессенджер, и выбирать, какие функции нужны вашему приложению. Основываясь на нашем опыте, мы составили 2 списка — must-have и nice-to have функций — для по-настоящему классного приложения-мессенджера.

Must-have функции

Авторизация

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

Доступ к контактам

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

как создать приложение-мессенджер

Обмен сообщениями

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

Если вы хотите создать приложение-мессенджер, то подумайте над тем, чтобы добавить голосовые и видеосообщения. Мы знаем, что количество элементов может испугать. Как их всех расположить, чтобы страница не выглядела перегруженной? Вам на помощь придет опытный UX-дизайнер, который спроектирует по-настоящему удобный интерфейс.

как создать приложение-мессенджер

Обмен файлами

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

Push-уведомления

Как создать свой мессенджер без функции уведомлений? Это невозможно! В эпоху, когда мы так боимся пропустить что-то важное, пользователи обязаны немедленно знать, что они получили новое сообщение. Наверняка, каждый из нас хоть раз сходил с ума, ожидая обратную связь от потенциального работодателя — не нам вас убеждать в том, насколько важны push-уведомления в чатах 🙂

Защита данных

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

Nice-to-have функции

Звонки

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

Чат-боты

Эта функция привлечет бизнес-клиентов в ваше приложение-чат. Не пугайтесь слова «Боты».  Чат-бот — это всего лишь  автоматическое программное обеспечение, которое поддерживает онлайн-связь с клиентами, отправляет автоматические сообщения и помогает с ответами на часто задаваемые вопросы. Они также могут позвать человека, если вопрос пользователя пришелся роботу «не по зубам».

как создать приложение-мессенджер

Как создать свой мессенджер, который принесет прибыль?

Вопрос о стратегиях монетизации неизбежно возникает у всех, кто хочет разработать приложение для обмена сообщениями. Вы спросите: «Мессенджеры вообще зарабатывают?!» Короткий ответ – да.

Вот несколько стратегий монетизации для подобной бизнес-идеи:

  • Реклама в приложении;
  • Технология переадресации звонков. Например, звонит вам кто-то из-за границы — звонок не идет через оператора, а переносится в Viber, который получает за это деньги;
  • Брендированные стикеры, созданные в коллаборации с брендами;
  • Пожертвования от пользователей. Да, это тоже вариант, и именно так живет и здравствует Telegram);

Как найти надежного разработчика?

Проверить команду разработчиков «на прочность»  можно в два шага:

Во-первых, просто погуглите название компании. Кто-то скажет: «Пфф, банально» , но поверьте, даже самый быстрый поиск в Google даст какое-никакое представление о ценностях команды – совпадаете ли вы по взглядам, получится ли у вас делать что-то вместе. Во-вторых, сходите на профессиональные площадки – Dribbble и Behance —  и изучите дизайн-портфолио ваших потенциальных подрядчиков. За отзывами клиентов можно отправиться в Clutch и UpWork. Ну и не забудьте про сайт компании — например, мы размещаем отзывы клиентов на главной странице, а результатами работ делимся во вкладке «Проекты».

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

React Native — это фреймворк, который был создан Facebook 5 лет назад. Код приложений пишется на JavaScript – одном из самых популярных языков программирования во всем мире. Есть 3 основных преимущества работы с компанией, которая работает на React Native.

JavaScript. Этот язык — один из самых распространенных среди разработчиков, поэтому вы сможете найти подрядчика для разработки вашего приложения быстро и безболезненно.

Единая общая кодовая база. При разработке двух отдельных версий приложения для Android и iOS код в них будет совпадать примерно на 65-70%. Что это значит для вашего бизнеса? Во-первых, это значительно сокращает время разработки (примерно в 2 раза). Во-вторых, не нужно нанимать и платить (!) двум отдельным командам — это осталось в прошлом. Не нужно будет думать, как создать мессенджер на iOS? Как создать мессенджер на Android?  Кто за это возьмется? Достаточно будет найти ту самую команду, которая работает с фреймворком React Native.

Нативные UI-элементы. Компании Facebook принадлежат бесконечные библиотеки нативных UI-элементов для интерфейса. Как это поможет вашей бизнес-идее? Очень просто — это означает, что ваше будущее приложение функционировать как нативное. Производительность мессенджера будет такой же, как если бы вы разработали его на Java или Swift.

Мы работаем с React Native последние 4 года и еще ни разу не разочаровались. Фреймворк позволяет нам создавать MVP (минимально-жизнеспособный продукт)для наших клиентов за 3 месяца. Это означает, что вы придете к нам с идеей, и через 90 дней у вас будет рабочая версия продукта, которую можно тестировать, собирать обратную связь и показывать потенциальным инвесторам.

Сколько стоит создать приложение-мессенджер?

Вот мы и дошли до самой важной части статьи — той, которая про стоимость и сроки. Разработка мессенджера – сложный процесс, в котором участвует команда разработчиков, дизайнеров, проектных менеджеров и QA-специалистов. Выбросить кого-то из команды и при этом сделать крутой продукт нельзя 🙂 

Мы предлагаем полный цикл разработки приложения на React Native — включая UI / UX  дизайн, API, услуги разработки Frontend и тестирование — и все, что нужно, чтобы ваше приложение попало в топ AppStore и Google Play. Наша философия  – быть максимально открытыми и прозрачными для клиентов, поэтому мы предпочитаем обсуждать вопросы сроков и стоимости на берегу. Кроме того, в историях с разработкой мессенджеров, мы отдельно обсуждаем безопасность данных, протоколы шифрования и соединение с серверами. В это время наши дизайнеры смогут углубиться в изучение ЦА приложения, спроектируют логику будущего сервиса, продумают визуальную составляющую. 

Мы подробно рассказали о том, как создать свой мессенджер и как его можно монетизировать. Теперь давайте посчитаем, сколько это стоит!

как создать приложение-мессенджер

Введите адрес своей электронной почты, чтобы получить разбивку по стоимости со всеми деталями

Спасибо! С вами свяжутся в ближайшее время

Итого

Над разработкой мессенджера, будет работать команда из 6 человек. Если судить по похожим проектам, над которыми мы работали, итоговая стоимость мессенджера вроде Telegram, составляет от $80.000 до $100.000: с готовым прототипом, UI/UX дизайном, iOS и Android версиями, тестированием и менеджментом проекта. Разработка своего мессенджера с Purrweb займет 5 месяцев.

Чтобы создать успешный сервис, используйте нашу пошаговую инструкцию, как разработать приложение для обмена сообщениями. Вот краткое изложение: первым делом нужно провести брейншторм и определиться с нишей, в которую вы планируете зайти. Кого вы хотите привлечь в приложение и зачем? Где будете искать свою аудиторию? Затем сориентируйтесь по ключевым фичам, которые будут необходимы пользователям – можно опираться на наш список must-have и nice-to-have функций.

Самый же быстрый и простой способ узнать подробности — написать нам напрямую и детально обсудить ваш будущий проект. Мы ответим на все ваши вопросы о том, как создать свой мессенджер, поделимся советами и секретами реализации бизнес-идей, проведем за руку и расскажем о процессах и результатах на каждом этапе создания digital-продукта.

Смотрите наше дизайн-портфолиоhttps://www.behance.net/PURRWEB и читайте отзывы клиентовhttps://clutch.co/profile/purrweb . Мы также всегда на связи в самых популярных мессенджерах – в Telegram и WhatsApp.

Предисловие

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

HLM (Hidden Lake Messenger) — анонимный мессенджер, построенный на ядре анонимной сети HLS (Hidden Lake Service). Данная статья приводит создание HLS и HLM с нуля, показывая их примитивность и простоту. Код HLS, HLM.

Введение

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

Под сокрытием пользовательской активности мы будем понимать именно критерий ненаблюдаемости. Помимо критерия ненаблюдаемости, также существует критерий несвязываемости, который лишь «разрывает» связь между абонентами, не позволяя связать источника (отправителя) и получателя. Критерий несвязываемости уже присутствует в критерии ненаблюдаемости, а потому является более слабым выражением анонимности.

Доказательство

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

Описание абстрактно-планируемого мессенджера

«Анонимность» представляет собой сложный, комплексный термин. В работе «Теория строения скрытых систем» было представлено развитие анонимности в сетевых коммуникациях, были выявлены критерии анонимности, базовое определение анонимности, основные методы её достижения (конструкты), а также противоречия между безопасностью и анонимностью. Всё это говорит о том, что при разработке мы должны чётко обозначить границы того, к чему мы движемся.

  1. Мессенджер будет базироваться на теоретически доказуемой анонимности.

Теоретически доказуемая анонимность

Скрытыми сетями с теоретически доказуемой анонимностью принято считать замкнутые (полностью прослушиваемые) системы, в которых становится невозможным осуществление любых пассивных атак (в том числе и при существовании глобального наблюдателя) направленных на деанонимизацию отправителя и получателя с минимальными условностями по количеству узлов неподчинённых сговору. Говоря иначе, с точки зрения пассивного атакующего, апостериорные знания (полученные вследствие наблюдений) должны оставаться равными априорным (до наблюдений), тем самым сохраняя равновероятность деанонимизации по N-ому множеству субъектов сети.

  1. Мессенджер будет связывать абонентов информации между собой.

Анонимность между абонентами

Существует несколько видов анонимизации трафика между отправителем и получателем:

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

2. Система связывает абонентов информации. В такой концепции отправитель и получатель известны друг к другу. Системы построенные на данном пункте часто ограничены в своём применении, но, так или иначе, остаются способными представлять анонимность субъектов, в том числе и на уровне критерия ненаблюдаемости.

Второй пункт является наименее привлекательным со стороны любого представленного случая из первого пункта. Тем не менее, такое действие становится оправданным со стороны простоты программной реализации и простоты теоретической доказуемости.

  1. Мессенджер будет представлять сквозное (end-to-end) шифрование

Клиент-безопасные приложения

Клиент-безопасные приложения или приложения базируемые на безопасной линии связи «клиент-клиент» представляют собой абстрагирование передаваемых / хранимых объектов от промежуточных субъектов, тем самым приводя мощность доверия |T| к своему теоретически минимально заданному значению. Частным случаем связи «клиент-клиент» становится сквозное (end-to-end или E2E) шифрование.

Мощность доверия

Мощность доверия — количество узлов, участвующих в хранении или передаче информации, представленной дли них в открытом описании. Иными словами, такие узлы способны читать, подменять и видоизменять информацию, т.к. для них она находится в предельно чистом, прозрачном, транспарентном состоянии. Чем больше мощность доверия, тем выше предполагаемый шанс компрометации отдельных узлов, а следовательно, и хранимой на них информации. Принято считать одним из узлов получателя. Таким образом, нулевая мощность доверия |T| = 0 будет возникать лишь в моменты отсутствия каких-либо связей и соединений. Если |T| = 1, это говорит о том, что связь защищена, иными словами, никто кроме отправителя и получателя информацией не владеют. Во всех других случаях |T| > 1, что говорит о групповой связи (то-есть, о существовании нескольких получателей), либо о промежуточных узлах, способных читать информацию в открытом виде.

  1. Мессенджер будет базироваться на одноранговой (peer-to-peer) децентрализованной архитектуре сети.

Peer-to-peer сети

Существует несколько видов одноранговых сетей, а именно:

  1. Централизованная одноранговая архитектура. Представляет собой существование двух ролей — клиентов и ретрансляторов. Клиент в такой системе генерируют и принимают всю информацию. Ретрансляторы служат исключительно для перенаправления клиентской информации, не содержа в себе никакой дополнительной логики.

  2. Децентрализованная одноранговая архитектура. Представляет собой «сращивание» воедино клиентов и ретрансляторов, в следствие чего каждый клиент теперь становится способным осуществлять перенаправления поступаемой ему клиентской информации извне.

  3. Распределённая одноранговая архитектура. Подвид децентрализованной одноранговой архитектуры. Зародилась в следствии «коррозии» децентрализованных форм централизованными. Иными словами, в децентрализованных архитектурах существует проблема, когда клиенты начинают выбирать малое количество стабильных клиентов для последующей ретрансляции, тем самым приводят систему к неявному виду централизации. Распределённая одноранговая архитектура переводит качество соединений на их количество.

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

  1. Мессенджер будет связывать абонентов посредством доверительных (friend-to-friend) связей.

Friend-to-friend сети

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

  1. Мессенджер будет представлять реализацию абстрактной анонимной сети.

Абстрактная анонимная сеть

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

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

Любые комбинации связей возможны без вреда анонимности

Любые комбинации связей возможны без вреда анонимности

За счёт данной «абстрактности» комбинации и вариации расположения и связей узлов между собой никак не влияют на уровень безопасности и анонимности конечных клиентов.

Краткое описание HLS

Вкратце суть HLS сводится к следующему: предположим, что существует три участника {A, B, C}. Каждый из них соединён друг к другу (что в сравнении с DC-сетями не является обязательным критерием, но данный случай я привёл исключительно для упрощения) (P.S. имеются реализации которые позволяют в DC-сетях не соединяться друг к другу, но данные способы скорее являются хаками, нежели вариативностью). Каждый субъект устанавливает время генерации информации = T. У каждого участника имеется своё внутренее хранилище по типу FIFO (первый пришёл — первый ушёл), можно сказать имеется структура «очередь».

DC-сети (проблема обедающих криптографов)

Предположим, что участник A хочет отправить информацию по сети так, чтобы {B, C} эту информацию получили, но не смогли узнать, кто действительно является отправителем. Иными словами, для B это может быть {A, C}, а для C это {A, B} с вероятностью 50/50. Все участники начинают согласовывать общий бит со своими соседями в момент времени T. Предположим, что участники {A, B} согласовали бит = 1, {B, C} = 1, {C, A} = 0.

Далее каждый участник сети XOR’ит (операция исключающее ИЛИ) биты со всех своих соединений: A = 1 xor 0 = 1; B = 1 xor 1 = 0; C = 0 xor 1 = 1. Данные результаты обмениваются по всей сети и XOR’ятся каждым её участником: 0 xor 1 xor 1 = 0. Это говорит о том, что участник A передал бит информации = 0. Чтобы субъект A мог передать бит = 1, ему необходимо добавить операцию НЕ в своём вычислении, то есть A = НЕ(1 xor 0) = 0. В итоге, все вычисления прийдут к такому результату: 0 xor 0 xor 1 = 1. Таким образом, становится возможным передать 1 бит информации полностью анонимно (конечно же со стороны определения теоретически доказуемой анонимности).

Проблема обедающих криптографов

Проблема обедающих криптографов

Предположим, что один из участников, либо B, либо C захочет деанонимизировать либо оставшуются сеть {A, C}, либо {B, C} соответственно (то есть узнать, кто является отправителем информации). Тогда ему потребуется узнать согласованный секрет со стороны другой линии связи, что является сложной задачей (если конечно не был произведён сговор нескольких участников). Таким образом, атака со стороны внутреннего пассивного наблюдателя становится безрезультатной. Со стороны внешнего глобального наблюдателя такая же ситуация, потому как он видит лишь переадресации зашифрованных битов (потому как используется безопасный канал связи) в один момент времени T всеми участниками сети.

Предположим, что участник A хочет отправить некую информацию одному из участников {B, C}, так, чтобы другой участник (или внешний наблюдатель) не знал что существует какой-либо факт отправления. Каждый участник в определённый индивидуальный период T генерирует сообщение. Такое сообщение может быть либо ложным (не имеющее никакого фактического содержания и никому по факту не отправляется, заполняясь случайными битами), либо истинным (запрос или ответ). Отправить раньше или позже положенного времени T никакой участник не может. Если скопилось несколько запросов одному и тому же участнику, тогда он их ложит в свою очередь сообщений и после периода T достаёт из очереди и отсылает в сеть. Таким образом, сама структура HLS есть множество последовательно выстроенных очередей.

Система на базе очередей

Система на базе очередей

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

Bitmessage

Bitmessage — клиент-безопасное приложение, мессенджер. Сам по себе не представляет анонимность пользователей, но его уникальной особенностью является маршрутизация отправляемых данных.

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

  2. Зашифрованная информация отправляется всем участникам сети (на практике конечно же текущим соединеням).

  3. Каждый пользователь, при получении шифрованной информации извне, пытается её расшифровать своим приватным ключом.

  4. Если пользователь смог расшифровать информацию, значит он является истинным её получателем.

  5. Если пользователь не смог расшифровать информацию, тогда он продолжает её распространять дальше по сети, отправляя таковую всем своим соединениям.

Уникальность такого подхода заключается в отстутствии маршрутизирующей информации, кроме как понимания факта {получатель / не получатель}. Тем не менее, Bitmessage не является анонимным мессенджером, потому что в нём отсутствует какая-либо запутывающая маршрутизация. Иными словами относительно легко определить кто является отправителем и кто получателем, при условии, если получатель всегда будет генерировать ответ на запрос инициатора.

Запутывающая маршрутизация

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

Схематичное описание запутывающей маршрутизации по отношению к внешним и внутренним атакующим (наблюдателям)

Схематичное описание запутывающей маршрутизации по отношению к внешним и внутренним атакующим (наблюдателям)

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

Реализация HLS

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

Криптографический протокол

Протокол определяется восьмью шагами, где три шага на стороне отправителя и пять шагов на стороне получателя. Для работы протокола необходимы алгоритмы КСГПСЧ (криптографически стойкого генератора псевдослучайных чисел), ЭЦП (электронной цифровой подписи), криптографической хеш-функции, установки / подтверждения работы, симметричного и асимметричного шифров.

Участники протокола: 
  A - отправитель, 
  B - получатель.

Шаги участника A:
1. K = G( N ), R = G( N ),
  где G - функция-генератор случайных байт,
      N - количество байт для генерации,
      K - сеансовый ключ шифрования,
      R - случайный набор байт.
2. HP = H( R || P || PubKA || PubKB ),
  где HP - хеш сообщения,
      H - функция хеширования,
      P - исходное сообщение,
      PubKX - публичный ключ. 
3. CP = [ E( PubKB, K ), E( K, PubKA ), E( K, R ), E( K, P ), HP, E( K, S( PrivKA, HP ) ), W( C, HP ) ],
  где CP - зашифрованное сообщение,
      E - функция шифрования,
      S - функция подписания,
      W - функция подтверждения работы,
      C - сложность работы,
      PrivKX - приватный ключ.

Шаги участника B: 
4. W( C, HP ) = PW( C, W( C, HP ) ),
  где PW - функция проверки работы.
  Если ≠, то протокол прерывается.
5. K = D( PrivKB, E( PubKB, K ) ),
  где D - функция расшифрования.
  Если ≠, то протокол прерывается.
6. PubKA = D( K, E( K, PubKA ) ).
  Если ≠, то протокол прерывается.
7. HP = V( PubKA, D( K, E( K, S( PrivKA, HP ) ) ) ),
  где  V - функция проверки подписи.
  Если  ≠, то протокол прерывается. 
8. HP = H( D( K, E( K, R ) ) || D( K, E( K, P ) ) || PubKA || PubKB ),
  Если  ≠, то протокол прерывается. 

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

Также протокол способен игнорировать сетевую идентификацию субъектов информации, замещая её идентификацией криптографической. При таком подходе аутентификация субъектов начинает становиться сингулярной функцией, относящейся лишь и только к асимметричной криптографии, и как следствие, прикладной уровень стека TCP/IP начинает симулятивно заменять криптографический слой по способу обнаружения отправителя и получателя. Из вышеописанного также справедливо следует, что для построения полноценной информационной системы необходимым является симулятивная замена транспортного и прикладного уровня последующими криптографическими абстракциями. Под транспортным уровнем может пониматься способ передачи сообщений из внешней (анонимной сети) во внутреннюю (локальную), под прикладным — взаимодействие со внутренними сервисами.

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

Безопасность протокола определяется в большей мере безопасностью асимметричной функции шифрования, т.к. все действия сводятся к расшифрованию сеансового ключа приватным ключом. Если приватный ключ не может расшифровать сеансовый, то это говорит о том факте, что само сообщение было зашифровано другим публичным ключом и потому получатель также есть другой субъект. Функция хеширования необходима для проверки целостности отправленных данных. Функция проверки подписи необходима для аутентификации отправителя. Функция проверки доказательства работы необходима для предотвращения спама.

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

Шифрование случайного числа (соли) также есть необходимость, потому как, если злоумышленник знает его и субъектов передаваемой информации, то он способен пройтись методом «грубой силы» по словарю часто встречаемых и распространённых текстов для выявления исходного сообщения.

Использование одной и той же пары асимметричных ключей для шифрования и подписания не является уязвимостью, если применяются разные алгоритмы кодирования или сама структура алгоритма представляет различные способы реализации. Так например, при алгоритме RSA для шифрования может использоваться алгоритм OAEP, а для подписания – PSS. В таком случае не возникает «подводных камней» связанных с возможным чередованием «шифрование-подписание». Тем не менее остаются риски связанные с компрометацией единственной пары ключей, при которой злоумышленник сможет не только расшифровывать все получаемые сообщения, но и подписывать отправляемые. Но этот критерий также является и относительным плюсом, когда личность субъекта не раздваивается и, как следствие, данный факт не приводит к запутанным ситуациям чистого отправления и скомпрометированного получения (и наоборот).

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

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

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

Для улучшения эффективности, допустим при передаче файлов, программный код можно изменить так, чтобы снизить количество проверок работы в процессе передачи, но с первоначальным доказательством работы на основе случайной строки (полученной от точки назначения), а потом и с накопленным хеш-значением из n-блоков файла, для i-ой проверки. Таким образом, минимальный контроль работы будет осуществляться лишь M/nN+1 раз, где M — размер файла, N — размер одного блока. Если доказательство не поступило или оно является неверным, то нужно считать, что файл был передан с ошибкой и тем самым запросить повреждённый или непроверенный блок заново.

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

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

Шифрование

// Принимает в качестве аргументов публичный ключ получателя
// и полезную нагрузку.
// Полезная нагрузка - это Header=uint64 и Body=[]byte.
func (client *sClient) Encrypt(receiver asymmetric.IPubKey, pl payload.IPayload) (message.IMessage, error) {
	// Для безопасности необходимо, чтобы ключи абонентов имели одинаковый размер
    // во избежание малых ключей со стороны отправителя или получателя
    if receiver.Size() != client.PubKey().Size() {
		return nil, fmt.Errorf("size of public keys sender and receiver not equal")
	}

    // fVoidMsgSize - размер пустого пакета без полезной нагрузки 
    // (здесь учитываются размеры публичного ключа, хеша, подписи и т.п. метаданных)
    // В качестве шифрованной информации принимаются байты, потому необходимо
    // их кодирование. 16-кодировка идеальное простое кодирование для точного
	// для точного понимания будущего размера информации.
    // Нам нужно учитывать реальный размер информации исходя из
    // закодированного размера, поэтому мы уменьшаем размер вдвое.
    var (
		maxMsgSize = client.Settings().GetMessageSize() >> 1 // limit of bytes without hex
		resultSize = uint64(client.fVoidMsgSize) + uint64(len(pl.ToBytes()))
	)

    // Если всё же полученный размер оказывается больше лимита,
    // тогда ничего не шифруем. В таком контексте надо подумать
    // о том, как разделить сообщение (если таковое больше положенного).
	if resultSize > maxMsgSize {
		return nil, fmt.Errorf(
			"limit of message size without hex encoding = %d bytes < current payload size with additional padding = %d bytes",
			maxMsgSize,
			resultSize,
		)
	}

    // Передаём в функцию шифрования размер дополнения до блока.
	return client.encryptWithParams(
		receiver,
		pl,
		client.Settings().GetWorkSize(),
		maxMsgSize-resultSize,
	), nil
}
// Тут используются первые три действия на инициирующей стороне
// криптографического протокола.
func (client *sClient) encryptWithParams(receiver asymmetric.IPubKey, pl payload.IPayload, workSize, addPadd uint64) message.IMessage {
	var (
		rand    = random.NewStdPRNG()
		salt    = rand.Bytes(symmetric.CAESKeySize)
		session = rand.Bytes(symmetric.CAESKeySize)
	)

    // Конкатенируем полезную нагрузку с дополнением случайных байт.
	// Образуем новую полезную нагрузку, в заголовке которой указан
    // размер настоящей информации.
    payloadBytes := pl.ToBytes()
	doublePayload := payload.NewPayload(
		uint64(len(payloadBytes)),
		bytes.Join(
			[][]byte{
				payloadBytes,
				rand.Bytes(addPadd),
			},
			[]byte{},
		),
	)

    // Хешируем всю основную информацию.
    // Соль необходима для образования разных хешей.
	hash := hashing.NewSHA256Hasher(bytes.Join(
		[][]byte{
			salt,
			client.PubKey().Bytes(),
			receiver.Bytes(),
			doublePayload.ToBytes(),
		},
		[]byte{},
	)).Bytes()

    // Шифруем все необходимые данные сеансовым ключом.
    // Сеансовый ключ шифруем публичным ключом.
	cipher := symmetric.NewAESCipher(session)
	bProof := encoding.Uint64ToBytes(puzzle.NewPoWPuzzle(workSize).Proof(hash))
	return &message.SMessage{
		FHead: message.SHeadMessage{
			FSender:  encoding.HexEncode(cipher.Encrypt(client.PubKey().Bytes())),
			FSession: encoding.HexEncode(receiver.Encrypt(session)),
			FSalt:    encoding.HexEncode(cipher.Encrypt(salt)),
		},
		FBody: message.SBodyMessage{
			FPayload: encoding.HexEncode(cipher.Encrypt(doublePayload.ToBytes())),
			FHash:    encoding.HexEncode(hash),
			FSign:    encoding.HexEncode(cipher.Encrypt(client.PrivKey().Sign(hash))),
			FProof:   encoding.HexEncode(bProof[:]),
		},
	}
}

Расшифрование

// Последние пять действий на принимающей стороне
// криптографического протокола.
func (client *sClient) Decrypt(msg message.IMessage) (asymmetric.IPubKey, payload.IPayload, error) {
	// Проверяем корректность принятого msg.
    if msg == nil {
		return nil, nil, fmt.Errorf("msg is nil")
	}

	// Проверяем размер хеша.
	if len(msg.Body().Hash()) != hashing.CSHA256Size {
		return nil, nil, fmt.Errorf("msg hash != sha256 size")
	}

	// Проверяем совершённую работу.
	diff := client.Settings().GetWorkSize()
	puzzle := puzzle.NewPoWPuzzle(diff)
	if !puzzle.Verify(msg.Body().Hash(), msg.Body().Proof()) {
		return nil, nil, fmt.Errorf("invalid proof of msg")
	}

	// Пытаемся расшифровать сеансовый ключ.
	session := client.PrivKey().Decrypt(msg.Head().Session())
	if session == nil {
		return nil, nil, fmt.Errorf("failed decrypt session key")
	}

	// Расшифровываем публичный ключ.
	cipher := symmetric.NewAESCipher(session)
	publicBytes := cipher.Decrypt(msg.Head().Sender())
	if publicBytes == nil {
		return nil, nil, fmt.Errorf("failed decrypt public key")
	}

	// Декодируем публичный ключ из байт и проверяем его размер.
	pubKey := asymmetric.LoadRSAPubKey(publicBytes)
	if pubKey == nil {
		return nil, nil, fmt.Errorf("failed load public key")
	}
	if pubKey.Size() != client.PubKey().Size() {
		return nil, nil, fmt.Errorf("invalid public key size")
	}

	// Расшифровываем и декодируем полезную нагрузку.
	doublePayloadBytes := cipher.Decrypt(msg.Body().Payload().ToBytes())
	if doublePayloadBytes == nil {
		return nil, nil, fmt.Errorf("failed decrypt double payload")
	}
	doublePayload := payload.LoadPayload(doublePayloadBytes)
	if doublePayload == nil {
		return nil, nil, fmt.Errorf("failed load double payload")
	}

    // Расшифровываем соль.
	salt := cipher.Decrypt(msg.Head().Salt())
	if salt == nil {
		return nil, nil, fmt.Errorf("failed decrypt salt")
	}

	// Проверяем корректность принятого хеша с полученным.
	check := hashing.NewSHA256Hasher(bytes.Join(
		[][]byte{
			salt,
			publicBytes,
			client.PubKey().Bytes(),
			doublePayload.ToBytes(),
		},
		[]byte{},
	)).Bytes()
	if !bytes.Equal(check, msg.Body().Hash()) {
		return nil, nil, fmt.Errorf("invalid msg hash")
	}

	// Расшифровываем подпись и проверяем её корректность.
	sign := cipher.Decrypt(msg.Body().Sign())
	if sign == nil {
		return nil, nil, fmt.Errorf("failed decrypt sign")
	}
	if !pubKey.Verify(msg.Body().Hash(), sign) {
		return nil, nil, fmt.Errorf("invalid msg sign")
	}

	// Удаляем случайные (добавочные) байты из полезной нагрузки.
	mustLen := doublePayload.Head()
	if mustLen > uint64(len(doublePayload.Body())) {
		return nil, nil, fmt.Errorf("invalid size of payload")
	}
	pld := payload.LoadPayload(doublePayload.Body()[:mustLen])
	if pld == nil {
		return nil, nil, fmt.Errorf("invalid load payload")
	}

	// Возвращаем публичный ключ отправителя и 
    // отправленную полезную нагрузку.
	return pubKey, pld, nil
}

Пример использования шифрования и расшифрования можно продемонстрировать следующим образом. Файл «github.com/number571/go-peer/examples/modules/client/encrypt/main.go».

package main

import (
	"fmt"

	"github.com/number571/go-peer/modules/client"
	"github.com/number571/go-peer/modules/crypto/asymmetric"
	"github.com/number571/go-peer/modules/payload"
)

func main() {
	var (
		client1 = newClient()
		client2 = newClient()
	)

	msg, err := client1.Encrypt(
		client2.PubKey(),
		payload.NewPayload(0x0, []byte("hello, world!")),
	)
	if err != nil {
		panic(err)
	}

	pubKey, pld, err := client2.Decrypt(msg)
	if err != nil {
		panic(err)
	}

	fmt.Printf("Message: '%s';nSender's public key: '%s';n", string(pld.Body()), pubKey.String())
	fmt.Printf("Encrypted message: '%s'n", string(msg.ToBytes()))
}

func newClient() client.IClient {
	return client.NewClient(
		client.NewSettings(&client.SSettings{
            // Размер итогового пакета.
			FMessageSize: (1 << 12), 
		}),
        // Небезопасный размер ключа. 
        // На практике лучше использовать 4096 (консервативная точка зрения).
		asymmetric.NewRSAPrivKey(1024),
	)
}

Результат выполнения

Message: ‘hello, world!’;
Sender’s public key: ‘Pub(go-peer/rsa){30818902818100CC228131C038583D6345EEFF79D5A6AD56EB3992CD1933655EC1830F66AAF8F9CAC7283F63C0E17D2C69DED57FF28F18A7C2E3905DD28466F57F3FA0F53F0EF724D109D0B120CD9CF49DAF4841EE22F86EBD6A498DF91518C52C78583E7D61509C5E3790694650A8A1891B3BA1F4FCFCF3945C93D16625432185E1677F3F0BD50203010001}’;
Encrypted message: ‘{«head»:{«salt»:»86232459f3a46d6f8bb2d45d1d39898d047066b92606ff125d5c09b484c56558518c2c6b02067667939879859ba06e5533174c4cd357031a257c2f8f34631119″,»session»:»c32d0e7e1b731f802de593104e5b062cfbda16437f89ede1c0b33a75fb008bd2b7b6b526f43116ccd5f8191ceae222d00adb2fd18ae4521c77f37da9199dad763b8a159caef9b5965527ede8b4ec4f43e16388845f41d07418b5abf3af22cad25cb546c21a1275f72ba1e5a6a7dd51139ad2ec61b07f4fe1bb0ddb108a1d51de»,»sender»:»2b66ea223b48ae813b62e8a50eb059839f7b7f661ab67c9cebed54fe62a701a341b7bce736f9c6e29fae8d6596eea819ae00c48012c4586a3c31048085d5325db90f8c87794be38ad6b8fdc81cb2f983ead4fb57e5e231a41e740d6540c1f3f0e2012282892a31e9b8b106caa92f969a7d136edaf7cb744549d31792a260a7d6ea34805f15fc35978784f431ccd4a030b1f3b43ddd2d3fe681053c9584596f13″},»body»:{«payload»:»2cc55c74ecbd7fa0f75e2ce97678cd86e7920a8c919e063e43525216e18c53cf8918b0a18c3b93d57607ac1ec9e8e4f43f6df12d31bf15db56202ab63fad8e1d898a7bf9e9add90cef4d84e7e2c2cc07ac13ee823efdc4397c5c539f8ee1e32b37e851399f7f683a58b7a64e3873c6c84b9738dbab3512d05ba55e96e133926a69e6af68d8c29123802058e75db1726d164b245b1afbb4d2bdb11884f837f5a642d72260a2f660d794a2ebbc044a74662676e5b20b3ae24c32c0074d330fb55b895bd08dddbd1b04cf88d13a9fbc062312d859156f1a36967cd4f01f8c794b7844ab61c7e66ea83bb0f25595c78750131434fc024869e85eb3eb8e51fde56fa8d49df5ab41c41197712c1b38af8f31c4063626e1326e129e1ac1cb53570eb820fc2d0b6dceff3f622f88921f64405f5dbefb2452652902ed1bc211495f19a5cabe1c71cc3334ae8290feaec9018e00074ce7298ff8fc09f815eb839f2f95ce78d8f1e7dc4be785de62b616b3e7061288b07b2729f5813a3382f1ca0a5f8821436a36a94b2447d86a6d2356c61981aa095eb56df8cddae9cb6060a069d45301085a8bdea635a770f8c8a082560f40b9f4336b737acb4a14d8c5e52fef12927cc3026b01ec2d8e8d1a97c00574e078a15d89962974f659d0efe0a5dda20f36b9cd6e89a363da24fedfabe684519f3814f88b62de28f07eabb15c9711d3bc0e6f41b2508513dcc4c7a46063b0f6f29492cbe96ccf418edd0f7ab764d7517ca7feef6a33b380ac135457f139d7e00cdfe82b2a629f59fa57c7f4fc4695d0a014632b4a4ee825b4d9766aa1ba2c9377853235b18507d4a4b1990fcb691ab996624e1baa276724d2b4e67dbb294d4c54a26df90a8e8ed269e1f8cdd06f03c5677a98c22acbd8d97adc52d33cc4777600a3d553c6da1fed38f8648da4f52381369c950c39daa302c5c9232fe8838760b2fa7e21ccc218f57f7ee5c4dcaf6ef84d9db7ce5212194e87ef4acc61613b912d3d517415265a94b953c304a391221483f380e5819e903dccec1042330f04e88196f55a73ca5aaf942e36ae4cf693d42624293248d8b708b3ecc74376137f7a02275623″,»sign»:»14438df8beb9f31a15fef4acbc644e5fd6401ca83a7e1154039564215b4682bac6cfc577c0174227c4400c419b212a028577d8db6747467e2ac386745d73c93a8ced28bc6feb14924bd9d44650ae9abd67940dde8c6aae390015e67cca723c0e574da75006d0b15f2b225d44696da834277f5c2a57a833f3fb04c46aede99d3174575b0013e7ee7baedfb2b00a20f8ea78848d8b49563fc32d24dfd8550436c2″,»hash»:»d8873b265353fd564a95c6b122fa878579ba71243903704922036484a30c997b»,»proof»:»0000000000001419″}}’

Сетевая коммуникация

Сетевая составляющая ядра скрытой сети будет базироваться на протоколе TCP. Необходимость в TCP над UDP заключается в понимании точной доставки всех пакетов от точки A до точки B. Необходимость в TCP над HTTP заключается в постоянном держании соединения между отправителем и получателем. В любом случае можно заменить TCP, модифицировав UDP протокол, или использовав вебсокеты на уровне HTTP, но это будет лишь усложнять систему без какой бы то ни было значительной положительной стороны.

Нужно определить базовые функции отправления информации. Таковых будет две. Broadcast является функцией распространения информации по всем текущим соединениям и потому привязан к объекту «хранителю» соединений (в нашем контексте — это узел). Request является функцией передачи информации к одному соединению с последующей целью получить от него ответ и потому привязан к объекту «соединение».

Функция Broadcast

func (node *sNode) Broadcast(pld payload.IPayload) error {
	// Сохранение хеша в памяти, чтобы предотвратить бесконечное
    // зацикливание пакета в сетевой передачи
    hash := hashing.NewSHA256Hasher(pld.ToBytes()).Bytes()
	node.inMappingWithSet(hash)

    // Берём все текущие соединения и отправляем каждому
    // узлу копию полезной нагрузки.
	var err error
	for _, conn := range node.Connections() {
		e := conn.Write(pld)
		if e != nil {
			err = e
		}
	}

	return err
}

Это конечно же метод, а не функция со стороны терминологии языка Go. Тем не менее, термин «функция» употребляется здесь и далее как некое действие, как некая процедура без привязки конкретно к языку программирования.

func (conn *sConn) Write(pld payload.IPayload) error {
    // Ставим мьютекс, чтобы нельзя было записывать параллельно
    // два разных сообщения одному пользователю (коллизии).
	conn.fMutex.Lock()
	defer conn.fMutex.Unlock()

    // Упаковываем полезную нагрузку в сообщение (добавление HMAC)
	msg := message.NewMessage(pld, []byte(conn.fSettings.GetNetworkKey()))
	// Упаковываем сообщение в пакет (добавление размера пакета)
    packBytes := message.NewPackage(msg.ToBytes()).ToBytes()
	ptr := len(packBytes)

    // Отправляем байты.
	for {
		n, err := conn.fSocket.Write(packBytes[:ptr])
		if err != nil {
			return err
		}

		ptr = ptr - n
        packBytes = packBytes[:ptr]

		if ptr == 0 {
			break
		}
	}

	return nil
}

Функция Request

func (conn *sConn) Request(pld payload.IPayload) (payload.IPayload, error) {
	var (
		chPld    = make(chan payload.IPayload)
		timeWait = conn.fSettings.GetTimeWait()
	)

    // Отправляем полезную нагрузку.
	if err := conn.Write(pld); err != nil {
		return nil, err
	}
    // Запускаем горутину и пытаемся получить ответ.
	go readPayload(conn, chPld)

	select {
	case rpld := <-chPld:
        // Принятые данные могут оказаться невалидными.
		if rpld == nil {
			return nil, fmt.Errorf("failed: read payload")
		}
		return rpld, nil
	case <-time.After(timeWait):
		return nil, fmt.Errorf("failed: time out")
	}
}
func readPayload(conn *sConn, chPld chan payload.IPayload) {
	// Результатом функции станет вывод полезной нагрузки.
    var pld payload.IPayload
	defer func() {
		chPld <- pld
	}()

	// Пытаемся прочитать блок в 64бит указывающий
    // размер принимаемых данных.
	bufLen := make([]byte, encoding.CSizeUint64)
	length, err := conn.fSocket.Read(bufLen)
	if err != nil {
		return
	}
	if length != encoding.CSizeUint64 {
		return
	}

	// mustLen = Size[u64] in uint64
	arrLen := [encoding.CSizeUint64]byte{}
	copy(arrLen[:], bufLen)

    // Сравниваем принятый размер с допустимым лимитом.
	mustLen := encoding.BytesToUint64(arrLen)
	if mustLen > conn.fSettings.GetMessageSize() {
		return
	}

    // Читаем принимаемые байты.
	msgRaw := make([]byte, 0, mustLen)
	for {
		buffer := make([]byte, mustLen)
		n, err := conn.fSocket.Read(buffer)
		if err != nil {
			return
		}

		msgRaw = bytes.Join(
			[][]byte{
				msgRaw,
				buffer[:n],
			},
			[]byte{},
		)

		mustLen -= uint64(n)
		if mustLen == 0 {
			break
		}
	}

	// Пытаемся распаковать полученные байты в структуру сообщения.
	msg := message.LoadMessage(
		msgRaw,
		[]byte(conn.fSettings.GetNetworkKey()),
	)
	if msg == nil {
		return
	}

    // Выгружаем из сообщения полезную нагрузку.
	pld = msg.Payload()
}

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

Далее определим функцию принятия соединения и всей последующей информации от данного соединения.

Функция Handle

// Аргументы = узел принимающий информацию, соединение отправителя,
// отправленная полезная нагрузка.
type IHandlerF func(INode, conn.IConn, payload.IPayload)

func (node *sNode) Handle(head uint64, handle IHandlerF) INode {
	node.fMutex.Lock()
	defer node.fMutex.Unlock()

    // Устанавливаем функцию в качестве роутинга.
	node.fHandleRoutes[head] = handle
	return node
}
func (node *sNode) handleConn(address string, conn conn.IConn) {
	defer node.Disconnect(address)
	for {
        // Если сообщение принято валидное, тогда пытаемся
        // прочитать ещё одно сообщение от данного узла.
		ok := node.handleMessage(conn, conn.Read())
		if !ok {
            // Иначе обрываем с ним соединение.
			break
		}
	}
}

func (node *sNode) handleMessage(conn conn.IConn, pld payload.IPayload) bool {
	// Проверяем валидность принятой полезной нагрузки
	if pld == nil {
		return false
	}

	// Проверяем сообщение в маппинге (принимало ли оно ранее?)
	hash := hashing.NewSHA256Hasher(pld.ToBytes()).Bytes()
	if node.inMappingWithSet(hash) {
        // Это не есть ошибка, потому что отправитель
        // может получить пакет с разнородных узлов.
		return true
	}

	// Пытаемся получить функцию роута. Если таковой нет
    // или она неопределена, тогда считаем, что это ошибка
    // на стороне отправителя.
	f, ok := node.getFunction(pld.Head())
	if !ok || f == nil {
		return false
	}

    // Обрабатываем полезную нагрузку полученной функцией.
	f(node, conn, pld)
	return true
}
func (conn *sConn) Read() payload.IPayload {
	chPld := make(chan payload.IPayload)
	go readPayload(conn, chPld)
	return <-chPld
}

Пример взаимодействия нескольких пользователей можно показать в бесконечном цикле передачи информации от инициатора к отправителю и наоборот в игре пинг-понг (тут искусственный интелект двух ботов будет играть бесконечно, т.к. все ходы они проанализировали и предопределили). Файл «github.com/number571/go-peer/examples/modules/network/ping-pong/main.go».

package main

import (
	"fmt"
	"strconv"
	"time"

	"github.com/number571/go-peer/modules/network"
	"github.com/number571/go-peer/modules/network/conn"
	"github.com/number571/go-peer/modules/payload"
)

const (
	serviceHeader  = 0xDEADBEAF
	serviceAddress = ":8080"
)

func main() {
	var (
		service1 = network.NewNode(network.NewSettings(&network.SSettings{}))
		service2 = network.NewNode(network.NewSettings(&network.SSettings{}))
	)

	service1.Handle(serviceHeader, handler("#1"))
	service2.Handle(serviceHeader, handler("#2"))

	go service1.Listen(serviceAddress)
	time.Sleep(time.Second) // wait

	_, err := service2.Connect(serviceAddress)
	if err != nil {
		panic(err)
	}

    // Отправляем нуль в качестве инициализации 
    // бесконечных запросов-ответов.
	service2.Broadcast(payload.NewPayload(
		serviceHeader,
		[]byte("0"),
	))

	select {}
}

func handler(serviceName string) network.IHandlerF {
	return func(n network.INode, c conn.IConn, p payload.IPayload) {
		time.Sleep(time.Second) // delay for view "ping-pong" game

        // Получаем сообщение, конвертируем его в число.
		num, err := strconv.Atoi(string(p.Body()))
		if err != nil {
			panic(err)
		}

		val := "ping"
		if num%2 == 1 {
			val = "pong"
		}

		fmt.Printf("service '%s' got '%s#%d'n", serviceName, val, num)

        // Отправляем новое сообщение в сеть.
		n.Broadcast(payload.NewPayload(
			serviceHeader,
			[]byte(fmt.Sprintf("%d", num+1)),
		))
	}
}

Результат выполнения

service ‘#1’ got ‘ping#0’
service ‘#2’ got ‘pong#1’
service ‘#1’ got ‘ping#2’
service ‘#2’ got ‘pong#3’
service ‘#1’ got ‘ping#4’
service ‘#2’ got ‘pong#5’
service ‘#1’ got ‘ping#6’
service ‘#2’ got ‘pong#7’
service ‘#1’ got ‘ping#8’
service ‘#2’ got ‘pong#9’
service ‘#1’ got ‘ping#10’
service ‘#2’ got ‘pong#11’

Синтез

Настал финальный этап в концепции HLS, а именно объединение сетевой коммуникации и криптографического протокола. Т.к. сам криптографический протокол достаточно легко абстрагируется от сетевых коммуникаций, то таковой способен самостоятельно и симулятивно заменять сетевую коммуникацию (идентификацию) криптографической. Иными словами, всю информацию мы будем транспортировать и маршрутизировать не на базе IP-адресов, а на основе публичных ключей. Сами публичные ключи станут сетевыми идентификаторами.

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

Функция Enqueue

func (q *sQueue) Enqueue(msg message.IMessage) error {
	q.fMutex.Lock()
	defer q.fMutex.Unlock()

    // Если очередь переполнена -> выдавать ошибку.
	if uint64(len(q.fQueue)) >= q.Settings().GetCapacity() {
		return errors.New("queue already full, need wait and retry")
	}

    // Поместить сообщение в очередь.
	go func() {
		q.fMutex.Lock()
		defer q.fMutex.Unlock()

		q.fQueue <- msg
	}()

	return nil
}

Функция Dequeue

func (q *sQueue) Dequeue() <-chan message.IMessage {
	time.Sleep(q.Settings().GetDuration())

	go func() {
		q.fMutex.Lock()
		defer q.fMutex.Unlock()

        // Если очередь неактивна -> остановить выполнение.
		if !q.fIsRun {
			return
		}

        // Если в очереди сообщений не существует сообщений,
        // тогда нужно взять ложное сообщение из пула сгенерированных.
		if len(q.fQueue) == 0 {
			q.fQueue <- (<-q.fMsgPull.fQueue)
		}
	}()

	return q.fQueue
}

Далее необходимым является создание обёртки над функциями отправления и принятия сообщений в сетевых коммуникациях, но изменив при этом идентификаторы. Иными словами, необходимо заменить получателя network.INode на anonymity.INode и отправителя conn.IConn на asymmetric.IPubKey.

Функция Handle

type IHandlerF func(INode, asymmetric.IPubKey, payload.IPayload) []byte

func (node *sNode) Handle(head uint32, handle IHandlerF) INode {
	node.fMutex.Lock()
	defer node.fMutex.Unlock()

	node.fHandleRoutes[head] = handle
	return node
}

Стоит заметить, что head ранее был равен 64bit, теперь 32bit. Связано это с тем, что в «обёрточной» реализации нам также необходимо создать механизм получения ответа. Данная реализация более затруднительна, чем Request в сетевых коммуникациях, потому что неизвестно насколько далеко и в какой части сети находится конечный адресат сообщения. В такой парадигме информация будет проходить несколько узлов и ответ получателя может быть принят не тем узлом, через кого первоначально отправлялось сообщение.

Поэтому здесь 64bit разбивается на две составляющие по 32bit в виде конкатенации. Левая половина отвечает за уникальный идентификатор связи между отправителем и получателем для правильного валидирования принимаемой информации и перенаправления таковой на функцию Request в качестве ответа. Правая половина отвечает за функции редиректа, как и в сетевых коммуникациях 64bit.

// Единственная роут функция из сетевых коммуникаций.
// Представляет собой обёртку над множеством роут функций
// анонимных коммуникаций.
func (node *sNode) handleWrapper() network.IHandlerF {
	go func() {
		for {
            // Если из очереди пришло сообщение,
            // то его необходимо отправить в сеть.
			msg, ok := <-node.Queue().Dequeue()
			if !ok {
				break
			}
			node.broadcast(msg)
		}
	}()

	return func(nnode network.INode, _ conn.IConn, npld payload.IPayload) {
		// Получаем сообщение из сети. Проверяем. Распаковываем.
        msg := node.initialCheck(message.LoadMessage(npld.Body()))
		if msg == nil {
			return
		}

		// Как только получили новое сообщение из сети ->
        // отправляем его всем своим соединениям.
		nnode.Broadcast(npld)
		client := node.Queue().Client()

		// Пытаемся расшифровать полученное сообщение.
		sender, pld, err := client.Decrypt(msg)
		if err != nil {
			return
		}

		// Если сообщение отправлено нам, тогда необходимо
        // проверить отправителя информации. Находится ли
        // он в нашем белом списке?
		if !node.F2F().InList(sender) {
			return
		}

		// Проверяем существования сообщения в базе данных.
  		// Если существует, то далее ничего не предпринимаем.
        // Если отсутствует, тогда заносим в БД и смотрим далее.
        hash := []byte(fmt.Sprintf("_hash_%X", msg.Body().Hash()))
		if _, err := node.KeyValueDB().Get(hash); err == nil {
			return
		}
		node.KeyValueDB().Set(hash, []byte{})

		// Получаем связь от отправителя и пытаемся сопоставить
        // со своими связями из Request. Если таковая связь 
        // существует - это значит, что данный отправитель
        // является получателем сгенерировавшим ответ.
		head := pld.Head()
		action, ok := node.getAction(
			loadHead(head).Actions(),
		)
		if ok {
            // Перенаправляем полезную нагрузку на функцию 
            // Request.
			action <- pld.Body()
			return
		}

		// Берём по роуту функцию и смотрим её существование.
		f, ok := node.getRoute(
			loadHead(head).Routes(),
		)
		if !ok || f == nil {
			return
		}

        // Выполняем действие на основе роут функции.
		resp := f(node, sender, pld)
		// Если resp пустой - это не ошибка. Может существовать
        // логика, где не требуется ответа.
        if resp == nil {
			return
		}

        // Генерируем ответ.
		respMsg, err := client.Encrypt(
			sender,
			payload.NewPayload(head, resp),
		)
		if err != nil {
			panic(err)
		}

        // Заносим ответ в очередь.
		for i := uint64(0); i <= node.Settings().GetRetryEnqueue(); i++ {
			err := node.Queue().Enqueue(respMsg)
			if err != nil {
				time.Sleep(node.Queue().Settings().GetDuration())
				continue
			}
			break
		}
	}
}
// Представляет собой низкоуровневую функцию (конечно же относительно
// действий данного пакета) и находится вне выполнения очередей.
// Такое свойство необходимо для распространения маршрутизирующей 
// информации без замедления по очередям.
// Чтобы отправить пакет без необходимости ожидать ответа,
// нужно воспользоваться конструкцией node.Queue().Enqueue(msg).
func (node *sNode) broadcast(msg message.IMessage) error {
    // Перенаправляем сообщение всем своим соединениям. 
	return node.Network().Broadcast(payload.NewPayload(
		settings.CMaskNetwork,
		msg.ToBytes(),
	))
}
// Данная функция необходима для предотвращения редиректа
// невалидной информации при помощи функции Broadcast.
func (node *sNode) initialCheck(msg message.IMessage) message.IMessage {
	// Проверяем валидность сообщения.
    if msg == nil {
		return nil
	}

    // Проверяем размер хеша.
	if len(msg.Body().Hash()) != hashing.CSHA256Size {
		return nil
	}

    // Проверяем проделанную работу.
	diff := node.Queue().Client().Settings().GetWorkSize()
	puzzle := puzzle.NewPoWPuzzle(diff)
	if !puzzle.Verify(msg.Body().Hash(), msg.Body().Proof()) {
		return nil
	}

    // Внешне структура сообщения валидна.
	return msg
}

Функция Request

func (node *sNode) Request(recv asymmetric.IPubKey, pld payload_adapter.IPayload) ([]byte, error) {
	// Если количество соединений равно нулю, то и нет смысла
    // что-либо отправлять.
    if len(node.Network().Connections()) == 0 {
		return nil, errors.New("length of connections = 0")
	}

    // Создаём 32-битную связь с отправителем и
    // заносим редирект функцию в качестве запроса к ней.
	headAction := uint32(random.NewStdPRNG().Uint64())
	headRoutes := mustBeUint32(pld.Head())

    // Создаём новую полезную нагрузку на базе нового заголовка.
	newPld = payload.NewPayload(
		joinHead(headAction, headRoutes).Uint64(),
		pld.Body(),
	)

    // Шифруем полезную нагрузку открытым ключом получателя.
	msg, err := node.Queue().Client().Encrypt(recv, newPld)
	if err != nil {
		return nil, err
	}

    // Устанавливаем связь с получателем.
	node.setAction(headAction)
	defer node.delAction(headAction)

    // Пытаемся занести сообщение в очередь.
	for i := uint64(0); i <= node.Settings().GetRetryEnqueue(); i++ {
		if err := node.Queue().Enqueue(msg); err != nil {
			time.Sleep(node.Queue().Settings().GetDuration())
			continue
		}
		break
	}

    // Получаем ответ по связи.
	return node.recv(headAction, node.Settings().GetTimeWait())
}
func (node *sNode) recv(head uint32, timeOut time.Duration) ([]byte, error) {
	// Берём "считыватель" по связи
    action, ok := node.getAction(head)
	if !ok {
		return nil, errors.New("action undefined")
	}
    // Просматриваем и получаем событие.
	select {
	case result, opened := <-action:
		if !opened {
			return nil, errors.New("chan is closed")
		}
		return result, nil
	case <-time.After(timeOut):
		return nil, errors.New("time is over")
	}
}

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

package main

import (
	"fmt"
	"os"
	"strconv"
	"time"

	"github.com/number571/go-peer/modules/client"
	"github.com/number571/go-peer/modules/crypto/asymmetric"
	"github.com/number571/go-peer/modules/friends"
	"github.com/number571/go-peer/modules/network"
	"github.com/number571/go-peer/modules/network/anonymity"
	payload_adapter "github.com/number571/go-peer/modules/network/anonymity/adapters/payload"
	"github.com/number571/go-peer/modules/network/conn"
	"github.com/number571/go-peer/modules/payload"
	"github.com/number571/go-peer/modules/queue"
	"github.com/number571/go-peer/modules/storage/database"
)

const (
	serviceHeader  = 0xDEADBEAF
	serviceAddress = ":8080"
)

const (
	dbPath1 = "database1.db"
	dbPath2 = "database2.db"
)

func deleteDBs() {
	os.RemoveAll(dbPath1)
	os.RemoveAll(dbPath2)
}

func main() {
	deleteDBs()
	defer deleteDBs()

	var (
		service1 = newNode(dbPath1)
		service2 = newNode(dbPath2)
	)

	service1.Handle(serviceHeader, handler("#1"))
	service2.Handle(serviceHeader, handler("#2"))

    // Пользователи добавляют в друзья друг друга (простите за тавтологию)
	service1.F2F().Append(service2.Queue().Client().PubKey())
	service2.F2F().Append(service1.Queue().Client().PubKey())

    // Запускаются очереди.
	if err := service1.Run(); err != nil {
		panic(err)
	}
	if err := service2.Run(); err != nil {
		panic(err)
	}

	go service1.Network().Listen(serviceAddress)
	time.Sleep(time.Second)

	if _, err := service2.Network().Connect(serviceAddress); err != nil {
		panic(err)
	}

    // Генерируется сообщение.
    // Число нуль в качестве инициализации.
	msg, err := service2.Queue().Client().Encrypt(
		service1.Queue().Client().PubKey(),
		payload_adapter.NewPayload(
			serviceHeader,
			[]byte("0"),
		),
	)
	if err != nil {
		panic(err)
	}

    // Сообщение кладётся в очередь для будущего отправления.
	if err := service2.Queue().Enqueue(msg); err != nil {
		panic(err)
	}

	select {}
}

func handler(serviceName string) anonymity.IHandlerF {
	return func(node anonymity.INode, pubKey asymmetric.IPubKey, pld payload.IPayload) []byte {
		num, err := strconv.Atoi(string(pld.Body()))
		if err != nil {
			panic(err)
		}

		val := "ping"
		if num%2 == 1 {
			val = "pong"
		}

		fmt.Printf("service '%s' got '%s#%d'n", serviceName, val, num)

        // Редактируем и создаём новое сообщение.
		msg, err := node.Queue().Client().Encrypt(
			pubKey,
			payload_adapter.NewPayload(
				serviceHeader,
				[]byte(fmt.Sprintf("%d", num+1)),
			),
		)
		if err != nil {
			panic(err)
		}

        // Отправляем новое сообщение.
		if err := node.Queue().Enqueue(msg); err != nil {
			panic(err)
		}
		return nil
	}
}

func newNode(dbPath string) anonymity.INode {
	return anonymity.NewNode(
		anonymity.NewSettings(&anonymity.SSettings{}),
		database.NewLevelDB(
			database.NewSettings(&database.SSettings{
				FPath: dbPath,
			}),
		),
		network.NewNode(
			network.NewSettings(&network.SSettings{
				FConnSettings: conn.NewSettings(&conn.SSettings{}),
			}),
		),
		queue.NewQueue(
			queue.NewSettings(&queue.SSettings{}),
			client.NewClient(
				client.NewSettings(&client.SSettings{}),
				asymmetric.NewRSAPrivKey(1024),
			),
		),
		friends.NewF2F(),
	)
}

Результат выполнения

Ровно такой же как в примере кода сетевых коммуникаций.

Осталось лишь единственная деталь для того, чтобы ядро скрытой сети могло называться действительно ядром — предоставление API сторонним приложениям.

1. GET/POST/DELETE /api/config/connects
2. GET/POST/DELETE /api/config/friends
3. GET/DELETE      /api/network/online
4. POST/PUT        /api/network/push
5. GET             /api/node/pubkey

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

В качестве примера итогового результата можно обратиться к директории examples/cmd/echo_service. В данном примере разворачивается несколько узлов, а именно — один отправитель, один получатель (сервис) и один промежуточный узел (созданный исключительно для маршрутизации.

$ cd examples/cmd/echo_service
$ make 
$ ./request.sh
> HTTP/1.1 200 OK
> Content-Type: application/json
> Date: Fri, 25 Nov 2022 08:02:51 GMT
> Content-Length: 97

> {"result":"7b226563686f223a2268656c6c6f2c20776f726c6421222c2272657475726e223a317d0a","return":1}

Скрипт request.sh

#!/bin/bash

str2hex() {
    local str=${1:-""}
    local fmt="%02X"
    local chr
    local -i i
    for i in `seq 0 $((${#str}-1))`; do
        chr=${str:i:1}
        printf "${fmt}" "'${chr}"
    done
}

JSON_DATA='{
        "method":"POST",
        "host":"hidden-echo-service",
        "path":"/echo",
        "head":{
            "Accept": "application/json"
        },
        "body":"aGVsbG8sIHdvcmxkIQ=="
}';

PUSH_FORMAT="{
        "receiver":"Pub(go-peer/rsa){3082020A0282020100B752D35E81F4AEEC1A9C42EDED16E8924DD4D359663611DE2DCCE1A9611704A697B26254DD2AFA974A61A2CF94FAD016450FEF22F218CA970BFE41E6340CE3ABCBEE123E35A9DCDA6D23738DAC46AF8AC57902DDE7F41A03EB00A4818137E1BF4DFAE1EEDF8BB9E4363C15FD1C2278D86F2535BC3F395BE9A6CD690A5C852E6C35D6184BE7B9062AEE2AFC1A5AC81E7D21B7252A56C62BB5AC0BBAD36C7A4907C868704985E1754BAA3E8315E775A51B7BDC7ACB0D0675D29513D78CB05AB6119D3CA0A810A41F78150E3C5D9ACAFBE1533FC3533DECEC14387BF7478F6E229EB4CC312DC22436F4DB0D4CC308FB6EEA612F2F9E00239DE7902DE15889EE71370147C9696A5E7B022947ABB8AFBBC64F7840BED4CE69592CAF4085A1074475E365ED015048C89AE717BC259C42510F15F31DA3F9302EAD8F263B43D14886B2335A245C00871C041CBB683F1F047573F789673F9B11B6E6714C2A3360244757BB220C7952C6D3D9D65AA47511A63E2A59706B7A70846C930DCFB3D8CAFB3BD6F687CACF5A708692C26B363C80C460F54E59912D41D9BB359698051ABC049A0D0CFD7F23DC97DA940B1EDEAC6B84B194C8F8A56A46CE69EE7A0AEAA11C99508A368E64D27756AD0BA7146A6ADA3D5FA237B3B4EDDC84B71C27DE3A9F26A42197791C7DC09E2D7C4A7D8FCDC8F9A5D4983BB278FCE9513B1486D18F8560C3F31CC70203010001}",
        "hex_data":"$(str2hex "$JSON_DATA")"
}";

curl -i -X POST -H 'Accept: application/json' http://localhost:7572/api/network/push --data "${PUSH_FORMAT}"

В данном контексте мы воспользовались HLS для отправления запроса с целью получить ответ от сервиса, расположенным за нодой, публичный ключ которой = Pub(go-peer/rsa){3082020A0282020100B752D35E81F4…8560C3F31CC70203010001}.

Мы отправили «hello, world». Получили hex-кодировку. Если раскодируем, то получим такой ответ: `{«echo»:»hello, world!»,»return»:1}`. HLS возвращает ответ (от функции Request) всегда в hex кодировке при успешном return’e = 1.

Сам сервис был написан таким образом.

Пример сервиса (echo)

package main

import (
	"encoding/json"
	"io"
	"net/http"
)

type sResponse struct {
	FEcho   string `json:"echo"`
	FReturn int    `json:"return"`
}

func main() {
	http.HandleFunc("/echo", echoPage)
	http.ListenAndServe(":8080", nil)
}

func echoPage(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		response(w, 2, "failed: incorrect method")
		return
	}
	res, err := io.ReadAll(r.Body)
	if err != nil {
		response(w, 3, "failed: read body")
		return
	}
	response(w, 1, string(res))
}

func response(w http.ResponseWriter, ret int, res string) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(&sResponse{
		FEcho:   res,
		FReturn: ret,
	})
}

Перенаправление HLS на сервисы реализовано достаточно просто.

package handler

import (
	"bytes"
	"fmt"
	"io"
	"net/http"

	"github.com/number571/go-peer/cmd/hls/config"
	hls_network "github.com/number571/go-peer/cmd/hls/network"
	hls_settings "github.com/number571/go-peer/cmd/hls/settings"
	"github.com/number571/go-peer/modules/crypto/asymmetric"
	"github.com/number571/go-peer/modules/network/anonymity"
	"github.com/number571/go-peer/modules/payload"
)

func HandleServiceTCP(cfg config.IConfig) anonymity.IHandlerF {
	return func(node anonymity.INode, sender asymmetric.IPubKey, pld payload.IPayload) []byte {
		// Получаем запрос из полезной нагрузки.
		requestBytes := pld.Body()
		request := hls_network.LoadRequest(requestBytes)
		if request == nil {
			return nil
		}

		// Смотрим существует ли такой хост в наших сервисах.
		address, ok := cfg.Service(request.Host())
		if !ok {
			return nil
		}

		// Если существует, тогда создаём новый запрос.
        // Здесь HLS находится в роли прокси сервера.
		req, err := http.NewRequest(
			request.Method(),
			fmt.Sprintf("http://%s%s", address, request.Path()),
			bytes.NewReader(request.Body()),
		)
		if err != nil {
			return nil
		}

		// Обновляем заголовки и добавляем всегда один уникальный
        // --> публичный ключ отправителя.
		req.Header.Add(hls_settings.CHeaderPubKey, sender.String())
		for key, val := range request.Head() {
			if key == hls_settings.CHeaderPubKey {
				continue
			}
			req.Header.Add(key, val)
		}

		// Отправляем запрос.
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil
		}
		defer resp.Body.Close()

        // Читаем ответ.
		data, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil
		}

		// Отправляем ответ от сервера.
		return data
	}
}

Кратко HLS можно изобразить следующим образом.

Анонимизатором, обработчиком исходящих и входящих сообщений является сам HLS. 
Приложение и сервисы (как надстройки) уже можно представлять как HLM.

Анонимизатором, обработчиком исходящих и входящих сообщений является сам HLS.
Приложение и сервисы (как надстройки) уже можно представлять как HLM.

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

Реализация HLM

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

Страница /about

Страница /about

По большей части анонимный мессенджер, сам мессенджер, будет являться лишь интерфейсом, а точнее GUI для реализованного ранее HLS с привязкой к его API. Поэтому думаю мало кому интересно наблюдать и читать чисто про то как я брал bootstrap код из сайтов-шаблонов, вставлял в редактор кода, перезагружал страницу и говорил «Ого, а это даже выглядит не как куча ***» (объективно GUI выглядит всё же как ***, но я пытался делать минималистично, без нагромаждений и дополнительных сложностей).

Страница /settings

Страница /settings

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

Страница /friends

Страница /friends

Все действия мессенджера — это все действия HLS. Получение, добавление, удаление друзей — это функции API HLS `/api/config/friends`. Получение, добавление, удаление соединений — это также функции API HLS `/api/config/connects`. Отправление сообщений — это функция POST `/api/network/push`. Даже получение публичного ключа — это функция `/api/node/pubkey`.

Страница /friends/chat?alias_name=Alice

Страница /friends/chat?alias_name=Alice

Единственная самостоятельная часть в HLM — это база данных, которая отлична от базы HLS, потому как первой необходимо хранить сообщения, второй же только хеши при передаче различных сообщений.

Для получения сообщений извне HLM создаёт отдельный сервис для перенаправления сообщений с HLS. Код его хендлера показан ниже.

func HandleIncomigHTTP(db database.IKeyValueDB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			response(w, hls_settings.CErrorMethod, "failed: incorrect method")
			return
		}

        // Читаем всё с HLS.
		msgBytes, err := io.ReadAll(r.Body)
		if err != nil {
			response(w, hls_settings.CErrorResponse, "failed: response message")
			return
		}

		msg := strings.TrimSpace(string(msgBytes))
		if len(msg) == 0 {
			response(w, hls_settings.CErrorResponse, "failed: message is null")
			return
		}

        // Читаем публичный ключ отправителя. 
        // HLS всегда обязан отправлять публичный ключ (поэтому стоит panica)
		pubKey := asymmetric.LoadRSAPubKey(r.Header.Get(hls_settings.CHeaderPubKey))
		if pubKey == nil {
			panic("public key is null (receive from hls)!")
		}

        // Сохраняем сообщение в БД.
		if err := db.Push(pubKey, database.NewMessage(true, msg)); err != nil {
			response(w, hls_settings.CErrorPubKey, "failed: push message to database")
			return
		}

        // Отправляем сообщение по вебсокету, чтобы таковое
        // отобразилось сразу, без перезагрузки страницы.
		gChatWS <- &sChatWS{pubKey.Address().String(), msg}
		response(w, hls_settings.CErrorNone, settings.CTitlePattern)
	}
}

А в конфигах HLS и HLM указывается следующее (пример).

Конфиги

HLS:

{
	"address": {
		"http": "localhost:8572"
	},
	"services": {
		"go-peer/hidden-lake-messenger": "localhost:8081"
	},
	"connections": [
		"localhost:9571"
	],
	"friends": {
		"Alice": "Pub(go-peer/rsa){3082020A0282020100C17B6FA53983050B0339A0AB60D20A8A5FF5F8210564464C45CD2FAC2F266E8DDBA3B36C6F356AE57D1A71EED7B612C4CBC808557E4FCBAF6EDCFCECE37494144F09D65C7533109CE2F9B9B31D754453CA636A4463594F2C38303AE1B7BFFE738AC57805C782193B4854FF3F3FACA2C6BF9F75428DF6C583FBC29614C0B3329DF50F7B6399E1CC1F12BED77F29F885D7137ADFADE74A43451BB97A32F2301BE8EA866AFF34D6C7ED7FF1FAEA11FFB5B1034602B67E7918E42CA3D20E3E68AA700BE1B55A78C73A1D60D0A3DED3A6E5778C0BA68BAB9C345462131B9DC554D1A189066D649D7E167621815AB5B93905582BF19C28BCA6018E0CD205702968885E92A3B1E3DB37A25AC26FA4D2A47FF024ECD401F79FA353FEF2E4C2183C44D1D44B44938D32D8DBEDDAF5C87D042E4E9DAD671BE9C10DD8B3FE0A7C29AFE20843FE268C6A8F14949A04FF25A3EEE1EBE0027A99CE1C4DC561697297EA9FD9E23CF2E190B58CA385B66A235290A23CBB3856108EFFDD775601B3DE92C06C9EA2695C2D25D7897FD9D43C1AE10016E51C46C67F19AC84CD25F47DE2962A48030BCD8A0F14FFE4135A2893F62AC3E15CC61EC2E4ACADE0736C9A8DBC17D439248C42C5C0C6E08612414170FBE5AA6B52AE64E4CCDAE6FD3066BED5C200E07DBB0167D74A9FAD263AF253DFA870F44407F8EF3D9F12B8D910C4D803AD82ABA136F93F0203010001}"
	}
}

HLM:

{
	"address": {
		"web_local": "localhost:8080",
		"incoming": "localhost:8081"
	},
	"connection": "localhost:8572"
}

Чтобы посмотреть работоспособность мессенджера, можно перейти в директорию `examples/cmd/anon_messenger`. В данном примере разворачивается три узла — два участника чата и один промежуточный (созданный исключительно для маршрутизации).

$ cd examples/cmd/anon_messenger
$ make
> # Откроется два HTTP порта :7070, :8080;

Остаётся поговорить лишь о самом графическом интерфейсе, но тут на самом деле мало что говорить, т.к. в начале уже большая часть была описана. Если есть интерес именно по графической составляющей, по HTML, CSS, JS коду и т.д., то можете посмотреть здесь.

Заключение

Таким образом, мы реализовали анонимный мессенджер построенный на теоретически доказуемой анонимности. Сам по себе мессенджер крайне минималистичен, потому как сам также сводится к примитивнейшему ядру анонимной сети. Нельзя сказать, что мессенджер завершён, потому как ему предстоит ещё многое пройти. Как минимум необходимо реализовать логин/регистрацию по которой будет выполняться два действия — расшифрование приватного ключа для HLS и расшифрование сообщение в БД переписок. Тем не менее, даже с этими недочётами и недостатками, HLM может представлять собой интересное приложение для вечерних изучений.

16 Сентября 2022


670



В избр.
Сохранено

В статье мы, команда разработки IT-компании Sibdev, рассказываем, как разработать свой мессенджер для Android или iOS.


Введение

Мессенджеры давно стали частью повседневной жизни многих людей. Они очень популярны и являются одними из самых скачиваемых приложений по всему миру. Мессенджерами пользуются миллионы людей из различных стран. Среди наиболее популярных можно выделить Telegram, WhatsApp и Viber.

В статье мы поговорим о том, как создать свой мессенджер для Android и iOS, что для этого нужно, как можно монетизировать приложение, а также какой функционал у него должен быть.

Для чего нужен мессенджер? (цели создания)

Как правило, мессенджер создается, исходя из следующих целей:

Монетизация

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

Создание социально значимого проекта

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

Кроссплатформенное или нативное приложение

Существует два способа разработки приложения для мессенджера:

Кроссплатформенное приложение

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

Нативное приложение

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

Как монетизировать мессенджер

Рассмотрим наиболее оптимальные варианты монетизации мессенджера:

Продажа регулярной подписки

Способ заключается в продаже пользователю определенного функционала за регулярную плату. В качестве популярного примера можно привести мессенджер Telegram, где пользователи могут купить Premium-подписку и получить доступ к дополнительной памяти, созданию большего количества папок и чатов, уникальным реакциям и др.


Реклама внутри приложения

Разместить рекламу внутри мессенджера — еще один способ заработка на приложении. Это можно делать в формате сообщений, как например, в Telegram Ads, так и размещая баннерную рекламу. Основатель может получать доход за размещение рекламы от рекламодателей в виде единоразового платежа или же регулярной комиссии. В случае с возможностью настройки таргетированной рекламы в мессенджере потребуется разработать и рекламный кабинет для настройки кампаний.

Продажа функционала внутри приложения

Метод отличается от регулярных платежей фиксированным единоразовым платежом. В качестве примера можно привести мессенджер Line, где пользователь за определенную стоимость может приобрести стикеры для общения внутри приложения.


Особенности разработки мессенджера

Разработка мессенджера имеет определенные особенности, которые следует учитывать при его реализации:

Гибкий и масштабируемый код

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

Высокий уровень производительности

Мессенджер — это приложение, которое рассчитано на достаточно большой и ежедневный поток пользователей. Для того чтобы приложение работало без ошибок, необходимо заранее обеспечить высокую производительность. Это поможет избежать проблем с работой мессенджера и, соответственно, негативной реакции пользователей.

Удобный для пользователя интерфейс

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

Приватность и безопасность общения

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

Функционал мессенджера

Поговорим об основных функциях мессенджера:

Авторизация

Это возможность входа в личный аккаунт для пользователя. Оптимально, если она будет недолгой. Но не следует забывать и о средствах дополнительной защиты от взлома. Авторизоваться пользователь может через адрес электронной почты, номер телефона или аккаунт в социальных сетях, дополнительно введя пароль.

Обмен сообщениями

Это ключевая функция приложения, поскольку в ней заключается вся суть мессенджера. Обмен информацией может быть разного формата: от голосовых сообщений до геолокации в реальном времени.


Доступ к контактам

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


Push-уведомления

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

Что влияет на сроки и стоимость разработки

Стоимость и сроки разработки могут быть отличаться во многих проектах: они сугубо индивидуальны. Это зависит от

Объем работы

Срок и стоимость разработки напрямую зависят от объема функционала, который необходимо реализовать разработчикам. Чем более обширный функционал необходим, тем выше будут сроки и цена создания приложения. Поскольку, как правило, команда разработчиков работает по почасовой ставке.

Уровень компетенции команды

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

Используемые технологии

Этот фактор также влияет на стоимость разработки, поскольку стоимость работы на разных языках программирования или фреймворках может отличаться. Соответственно, работа специалиста и почасовая ставка будет иметь разное значение.

Заключение

  1. На сроки и стоимость разработки прежде всего влияет объем работы.
  2. Такие особенности, как гибкий и масштабируемый код, удобный интерфейс, высокая производительность и приватность вместе с безопасностью, необходимо учитывать при разработке мессенджера.
  3. К основным функциям мессенджера можно отнести авторизацию, обмен сообщениями, доступ к контактам и push-уведомления.

Введение

Мобильные мессенджеры — один из наиболее популярных видов приложений в мире, которые находятся в топе скачиваний PlayGoogle, AppStore и других магазинов приложений. Этими приложениями пользуются сотни миллионов людей из разных стран. На данный момент наиболее популярны такие мессенджеры, как Telegram, WhatsApp и Viber. Неудивительно, что создание мессенджера в качестве стартапа является привлекательной идеей с потенциалом для популярности и прибыли.

В статье мы расскажем, как сделать мессенджер для iOS и Android, какие особенности есть у процесса разработки, а также поговорим о том, как мессенджер можно монетизировать и какие функции у него могут быть. 

Для чего нужен мессенджер. Цели создания

При создании приложения-мессенджера, основатель может преследовать следующие цели:

Монетизация

Большой поток пользователей приносит возможность для продажи рекламы, услуг, подписок и пр. Монетизация популярного мессенджера может принести своим основателям миллиарды долларов, о чем говорит мировой рейтинг Forbes, в котором присутствуют основатели популярных мессенджеров. Подробнее о монетизации таких приложений расскажем в разделе ниже.

Реализация социально значимого проекта

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

Кроссплатформенность VS нативность

Чтобы создать мессенджер для Android и iOS, можно выбрать один из двух путей разработки приложения:

Кроссплатформенное решение

Его суть заключается в написании приложения на кроссплатформенном языке программирования и использовании специальных фреймворков, то есть единого приложения, которое будет работать на обеих операционных системах. Такое приложение дешевле создания двух отдельных версий под каждую ОС, да и быстрее разработки отдельных приложений. Но из-за разницы в функционировании операционных систем некоторые функции могут быть труднореализуемыми или же недоступными. Такое решение можно выбрать для тестирования спроса рынка на начальном этапе. А при положительной реакции аудитории рассмотреть второй вариант.

Нативные приложения

Суть этого метода заключается в разработке двух отдельных приложений под каждую из операционных систем, то есть создание мессенджера на Android и создание мессенджера на iOS. Такой подход может быть более медленным из-за большего объема работы, а также более дорогостоящим. Однако созданные приложения будут актуализированы под определенную ОС и особенности ее функционала. Этот метод можно выбрать и как начальный вариант, так и дополнительный после успешной реализации кроссплатформенного приложения.

Монетизация мессенджеров

Монетизация мессенджеров возможна в нескольких вариантах:

Подписка на платные функции приложения

Суть этого метода заключается в том, что пользователь за определенную регулярную плату получает доступ к дополнительному функционалу мессенджера. Например, такой способ монетизации применяется в мессенджере Telegram — премиум-пользователям доступна загрузка файлов объемом до 4 Гб, отсутствие встроенной рекламы, дополнительные реакции на сообщения и др.

Внутренняя реклама

Этот способ заключается в размещении рекламы внутри мессенджера. Это может быть встроенная внутренняя реклама, как, например, Telegram Ads или же баннерная реклама внешних рекламодателей, вынесенная отдельными блоками. Внутренняя реклама позволяет мессенджеру получать средства за настройку рекламы или же комиссию за нее. Реклама внешних рекламодателей также может присутствовать в push-уведомлениях.

Продажа определенного функционала внутри приложения

Этот способ отличается от подписки тем, что за определенный функционал выплачивается единоразовая фиксированная стоимость. Например, такой способ монетизации применяет мессенджер Line, продавая на своей платформе стикеры для отправки их в сообщениях. 

Особенности разработки мессенджера

Разрабатывая мессенджер, стоит учитывать некоторые особенности, которые присущи данному виду разработки.

Гибкость и масштабируемость

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

Высокая производительность

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

Разработка интерфейса

Мессенджер — это приложения для ежедневного использования. Именно поэтому следует уделить большое внимание проектированию интерфейса. Он должен быть простым, вызывать эстетический отклик у пользователя, иметь интуитивно понятную структуру, а также возможность навигации для новых пользователей. Чем более “дружелюбным” будет интерфейс, тем более комфортным в использовании будет приложение. 

Приватность и безопасность

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

Ключевые функции мессенджера

К ключевым функциям мессенджера можно отнести следующие опции:

Авторизация

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

Обмен сообщениями

Это основная функция мессенджера, которая содержит в себе цель его разработки. Формат обмена сообщениями может быть любым: от текстовых сообщений до видеозвонка. При желании в функционал можно добавить несколько форматов. Например, в мессенджере Telegram можно отправить свою геопозицию.

Доступ к контактам

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

Push-уведомления

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

Сколько стоит разработать мессенджер

Стоимость и сроки разработки приложения мессенджера являются индивидуальными и зависят от следующих факторов: 

Объем функционала

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

Уровень разработчиков

Более долгий срок работы разработчика и владение определенным стеком технологий влияют на уровень его почасовой ставки. Работа более опытных программистов над проектом будет стоить дороже, что повлияет на стоимость разработки приложения. Однако опытные программисты выполняют работу за меньшее количество часов с меньшей вероятностью ошибок в коде.

Стек технологий

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

Заключение

  1. Создать мессенджер для Android и iOS — длительный процесс, в котором на сроки и стоимость влияет, прежде всего, объем функционала.
  2. При создании такого приложения следует учитывать определенные особенности: гибкость и масштабируемость, высокая производительность, удобный интерфейс, приватность и безопасность, которые влияют на восприятие аудитории и пользовательский опыт.
  3. Стоимость и сроки разработки сугубо индивидуальны и зависят от объема работы, опыта специалистов и стека технологий.

#Руководства

  • 24 июн 2020

  • 16

Клиент-серверная разработка — одна из самых востребованных отраслей программирования. Зная её азы, можно создавать как мессенджеры, так и онлайн-игры.

vlada_maestro / shutterstock

Евгений Кучерявый

Пишет о программировании, в свободное время создаёт игры. Мечтает открыть свою студию и выпускать ламповые RPG.

В этой серии статей мы напишем клиент-серверное приложение на C# — простейший мессенджер. Серия состоит из трёх частей:

  • Вёрстка приложения — мы создадим графический интерфейс на C# и XAML для Windows.
  • Создание WebAPI на ASP.NET — составим базу данных и разработаем серверную часть приложения.
  • Объединение клиента и сервера — напишем запросы к серверу и позаботимся, чтобы всё работало как надо.

Язык C# пригодится в разработке чего угодно. Возможности WPF (система создания графических интерфейсов) позволяют создавать красивые и функциональные приложения для Windows, а ASP.NET — мощные серверные приложения.

Я постараюсь объяснить подробно, но охватить всё невозможно, поэтому вам нужно знать основы C#, ООП, ASP.NET, WPF и работы в Visual Studio.

Вот несколько статей, с которыми стоит ознакомиться, если вы чего-то не знаете:

  • ООП в C# (серия статей).
  • Как за час создать сайт на ASP.NET.
  • Что такое MVVM: проектирование приложений для Windows.
  • Асинхронное программирование: Как работает процессор (серия статей).

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

Исходный код мессенджера вы найдете на GitHub.

Приложение мы поделим на экраны:

  • экран авторизации;
  • экран с контактами;
  • экран с чатом.

Экран — это элемент Border, который по умолчанию скрыт от пользователя. Виден будет только активный экран.

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

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

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

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

<Style x:Key="Screen">
    <Style.Setters>
   	 <Setter Property="Border.Visibility" Value="Hidden" />
   	 <Setter Property="Border.Background" Value="#151515" />
    </Style.Setters>
</Style>

<Style x:Key="LoginPanel">
    <Style.Setters>
   	 <Setter Property="StackPanel.Orientation" Value="Vertical" />
   	 <Setter Property="StackPanel.VerticalAlignment" Value="Center" />
    </Style.Setters>
</Style>

<Style x:Key="TextBoxBase">
    <Style.Setters>
   	 <Setter Property="TextBox.Background" Value="#333" />
   	 <Setter Property="TextBox.Foreground" Value="#f6f6f6" />
   	 <Setter Property="TextBox.Margin" Value="5"/>
   	 <Setter Property="TextBox.Padding" Value="15 10"/>
   	 <Setter Property="TextBox.HorizontalAlignment" Value="Center" />
   	 <Setter Property="TextBox.Width" Value="250" />
    </Style.Setters>
</Style>

<Style x:Key="ButtonBase" >
    <Style.Setters>
   	 <Setter Property="Button.Background" Value="#333" />
   	 <Setter Property="Button.Foreground" Value="#f6f6f6" />
   	 <Setter Property="Button.Margin" Value="5"/>
   	 <Setter Property="Button.Padding" Value="50 10"/>
   	 <Setter Property="Button.HorizontalAlignment" Value="Center" />
   	 <Setter Property="Button.FontSize" Value="14" />
    </Style.Setters>
</Style>

Теперь сверстаем сам экран авторизации — он должен быть видимым:

<Border Style="{StaticResource Screen}" Name="LoginScreen" Visibility="Visible">
    <StackPanel Style="{StaticResource LoginPanel}">
   	 <TextBlock Text="Login" Style="{StaticResource HeaderBlock}" />
   	 <TextBox Style="{StaticResource TextBoxBase}" Name="LoginBox" />
   	 <PasswordBox Style="{StaticResource TextBoxBase}" Name="PasswordBox"/>
   	 <Button Content="Enter" Style="{StaticResource ButtonBase}" Name="LoginButton" Click="LoginButton_Click" IsDefault="True"/>
   	 <TextBlock Text="" Name="LoginMessageBlock" Style="{StaticResource WarningBlock}" Visibility="Hidden"/>
    </StackPanel>
</Border>

Давайте посмотрим, как это выглядит:

Теперь напишем обработчик для кнопки Enter:

//Обработчик нажатия на кнопку Login
private void LoginButton_Click(object sender, RoutedEventArgs e)
{
    //Пока используем тестовые данные
    if(LoginBox.Text == "admin" && PasswordBox.Password == "12345")
    {
   	 //Если логин и пароль верные, то переходим на другой экран
   	 Open(ContactsScreen);
    }
    else
    {
   	 //Иначе выводим сообщение об ошибке авторизации
   	 LoginMessageBlock.Text = "Wrong login or password!";
   	 LoginMessageBlock.Visibility = Visibility.Visible;
    }
}
 
//Метод для открытия другого экрана
private void Open(Border screen)
{
    //Делаем все экраны невидимыми
    LoginScreen.Visibility = Visibility.Hidden;
    ContactsScreen.Visibility = Visibility.Hidden;
    ChatScreen.Visibility = Visibility.Hidden;
 
    //Делаем видимым необходимый экран
    screen.Visibility = Visibility.Visible;
}

У вас должны быть экраны с именами ContactsScreen и ChatScreen, чтобы метод Open () работал корректно. Для этого достаточно создать два пустых элемента Border.

Вот как выглядит экран авторизации при вводе неверных данных:

Дальше сверстаем экран с контактами.

Экран с контактами разделим на два ряда с помощью Grid — заголовок в первом ряду и список контактов во втором. Список — это элемент ListBox, в котором перечислены контакты.

<Border Name="ContactsScreen" Style="{StaticResource Screen}">
    <Grid>
   	 <Grid.RowDefinitions>
   		 <RowDefinition Height="50" />
   		 <RowDefinition />
   	 </Grid.RowDefinitions>
 
   	 <Border Grid.Row="0" Style="{StaticResource HeaderBorder}">
   		 <TextBlock Style="{StaticResource HeaderBlock}" Text="Contacts" VerticalAlignment="Center"/>
   	 </Border>
   	 
   	 <Border Grid.Row="1">
   		 <ListBox ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.CanContentScroll="True"  
   					 Style="{StaticResource ContactsList}" Name="ContactsList" SelectionChanged="ContactsList_SelectionChanged">
   			 <ListBox.ItemTemplate>
   				 <DataTemplate>
   					 <ListBoxItem>
   						 <DockPanel LastChildFill="True">
   							 <Image Style="{StaticResource ContactImage}" DockPanel.Dock="Left"></Image>
   							 <TextBlock Text="{Binding Name}" Style="{StaticResource ContactName}" DockPanel.Dock="Right"/>
   						 </DockPanel>
   					 </ListBoxItem>
   				 </DataTemplate>
   			 </ListBox.ItemTemplate>
   		 </ListBox>
   	 </Border>
    </Grid>
</Border>

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

Зададим стили:

<Style x:Key="ContactsList" BasedOn="{StaticResource TextBlockBase}">
    <Style.Setters>
   	 <Setter Property="ListBox.Background" Value="#151515"/>
   	 <Setter Property="ListBox.BorderThickness" Value="0"/>
    </Style.Setters>
</Style>
 
<Style x:Key="ContactImage" BasedOn="{StaticResource TextBlockBase}">
    <Style.Setters>
   	 
    </Style.Setters>
</Style>
 
<Style x:Key="ContactName" BasedOn="{StaticResource TextBlockBase}">
    <Style.Setters>
   	 <Setter Property="TextBlock.HorizontalAlignment" Value="Left"/>
    </Style.Setters>
</Style>
 
<Style x:Key="HeaderBorder">
    <Style.Setters>
   	 <Setter Property="Border.Background" Value="#222" />
    </Style.Setters>
</Style>

Теперь остаётся написать метод, который открывает чат с контактом. Этот метод вызывается, когда пользователь выберет пункт из списка.

private void ContactsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    //Метод вызывается, когда меняется индекс выделенного элемента
    //При выделении элемент списка будет подсвечиваться
    //Чтобы убрать это, мы будем менять индекс на -1
    //Чтобы метод не срабатывал повторно, мы проверяем, чтобы индекс был больше или равен 0
    if(ContactsList.SelectedIndex >= 0)
    {
   	 //Тут будет код загрузки сообщений из чата
 
   	 //Сбрасываем индекс
   	 ContactsList.SelectedIndex = -1;
 
   	 Open(ChatScreen);
    }
}

Чат похож на экран с контактами, но немного дополненный:

Grid делит экран на три части: заголовок, чат и поле ввода. В заголовке — имя собеседника и кнопка «Назад». В чате выведены сообщения с помощью ListBox, а внизу находятся поле ввода и кнопка отправки.

<Border Name="ChatScreen" Style="{StaticResource Screen}">
    <Grid>
   	 <Grid.RowDefinitions>
   		 <RowDefinition Height="50" />
   		 <RowDefinition />
   		 <RowDefinition Height="50"/>
   	 </Grid.RowDefinitions>
 
   	 <Border Grid.Row="0" Style="{StaticResource HeaderBorder}">
   		 <Grid>
    			 <Grid.ColumnDefinitions>
   				 <ColumnDefinition Width="1*" />
   				 <ColumnDefinition Width="6*" />
   				 <ColumnDefinition Width="1*" />
   			 </Grid.ColumnDefinitions>
 
   			 <Button Style="{StaticResource NavButton}" Grid.Column="0" Name="BackButton" Content="←" Click="BackButton_Click"/>
 
   			 <TextBlock Style="{StaticResource HeaderBlock}" Text="" VerticalAlignment="Center" Name="ChatName" Grid.Column="1"/>
   			 
   			 
   		 </Grid>
   		 
   	 </Border>
 
   	 <Border Grid.Row="1">
   		 <ListBox ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.CanContentScroll="True"
   					 Style="{StaticResource ContactsList}" Name="MessagesList" Focusable="False"
   					 HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
   			 <ListBox.ItemTemplate>
   				 <DataTemplate>
   					 <ListBoxItem>
   						 <Border Style="{StaticResource MessageBorder}" HorizontalAlignment="{Binding Alignment}">
   							 <StackPanel Orientation="Vertical">
   								 <TextBlock Text="{Binding Text}" Style="{StaticResource MessageText}"/>
   								 <TextBlock Text="{Binding Date}" Style="{StaticResource MessageDate}"/>
   							 </StackPanel>
   						 </Border>
   					 </ListBoxItem>
   				 </DataTemplate>
   			 </ListBox.ItemTemplate>
   		 </ListBox>
   	 </Border>
 
   	 <Border Grid.Row="2" Style="{StaticResource HeaderBorder}">
   		 <Grid>
 
   			 <Grid.ColumnDefinitions>
   				 <ColumnDefinition Width="6*" />
   				 <ColumnDefinition Width="1*" />
   			 </Grid.ColumnDefinitions>
 
   			 <TextBox Name="MessageBox" Style="{StaticResource MessageBox}" Grid.Column="0"/>
   			 
   			 <Button Style="{StaticResource NavButton}" Grid.Column="1" Name="SendButton" Content="→" Click="SendButton_Click"/>
 
   		 </Grid>
   	 </Border>
    </Grid>
</Border>

Тут, как на экране с контактами, пока нет данных -— только шаблон для их вывода. Немного стилей:

<Style x:Key="MessageBorder">
    <Style.Setters>
   	 <Setter Property="Border.Background" Value="#555" />
   	 <Setter Property="Border.CornerRadius" Value="13" />
   	 <Setter Property="Border.MinWidth" Value="100" />
   	 <Setter Property="Border.MaxWidth" Value="300" />
   	 <Setter Property="Border.Padding" Value="2" />
    </Style.Setters>
</Style>
 
<Style x:Key="MessageText" BasedOn="{StaticResource TextBlockBase}">
    <Style.Setters>
   	 <Setter Property="TextBlock.TextWrapping" Value="Wrap" />
   	 <Setter Property="TextBlock.Margin" Value="0" />
    </Style.Setters>
</Style>
 
<Style x:Key="MessageDate" BasedOn="{StaticResource TextBlockBase}">
    <Style.Setters>
   	 <Setter Property="TextBlock.HorizontalAlignment" Value="Right" />
   	 <Setter Property="TextBlock.FontSize" Value="8" />
   	 <Setter Property="TextBlock.Margin" Value="0" />
    </Style.Setters>
</Style>

Остаётся только обработать события для кнопок отправки и навигации:

private void SendButton_Click(object sender, RoutedEventArgs e)
{
 
    string text = "";
 
    if(!string.IsNullOrEmpty(MessageBox.Text))
    {
   	 
   	 text = MessageBox.Text.Trim();
    }
 
    if(!string.IsNullOrEmpty(text))
    {
 
   	 bool result = true;
 
   	 if(result)
   	 {
   		 MessageBox.Text = "";
   	 }
   	 
    }    
}
 
private void BackButton_Click(object sender, RoutedEventArgs e)
{
    Open(ContactsScreen);
}

У нас получился довольно минималистичный дизайн, который вы можете доработать как вам нравится. Дальше нам предстоит написать серверную часть приложения, а потом связать их вместе.

Учись бесплатно:
вебинары по программированию, маркетингу и дизайну.

Участвовать

Одним вечером, после очередного расстраивающего дня, наполненного попытками наладить баланс в своей игре, я решил, что мне срочно требуется отдых. Переключусь на другой проект, быстренько его сделаю, верну на место скатившуюся за время разработки игры самоооценку и с новыми силами возьму игру штурмом! Главное выбрать проект nice and relaxing… Написать свой месседжер? Ха! How hard can it be?

Код можно посмотреть здесь.

Краткая предыстория

До начала работы над мессенджером почти год корпел над мультиплеерной онлайн Line Tower Wars игрой. Программирование шло хорошо, всё остальное (баланс и визуал в особенности) — не очень. Внезапно оказалось, что сделать игру и сделать увлекательную игру (увлекательную для кого-то помимо самого себя) — две разные вещи. После года мытарств мне нужно было отвлечься, поэтому я решил попробовать свои силы в чём-то другом. Выбор пал на мобильную разработку, а именно, Flutter. Слышал множество хороших вещей про Flutter, да и дарт после недолгих экспериментов мне понравился. Решил написать свой собственный мессенджер. Во-первых, хорошая практика по реализации и клиента, и сервера. Во-вторых, будет что-то весомое положить в портфолио для поиска работы, я как раз нахожусь в процессе.

Запланированный функционал

  • Личные и групповые чаты
  • Отправка текста, изображений и видео
  • Аудио и видео-звонки
  • Подтверждение получения и прочтения (галочки из Вотсапа)
  • «Печатает…»
  • Уведомления
  • Поиск по QR-коду и геолокации

Забегая вперёд, могу с гордостью (и с облегчением) сказать, что почти всё запланированное было реализовано, а что ещё не реализовано — будет реализовано в ближайшее время.

Выбор языка

С выбором языка долго не думал. Сначала был соблазн использовать дарт и для клиента, и для сервера, но более детальная инспекция показала, что доступных драйверов для дарт не очень много, а те что есть не внушают особого доверия. Хотя не поручусь говорить о текущем моменте, возможно ситуация улучшилась. Так что выбор мой пал на C#, с которым я работал в Unity.

Архитектура

Начал с продумывания архитектуры. Конечно, учитывая что моим мессенджером скорее всего будут пользоваться 3 с половиной человека, можно было бы не заморачиваться с архитектурой вообще. Берёшь и делаешь как в бесчисленных туториалах. Вот нода, вот монго, вот вебсокеты. Готово. И Firebase где-то тут. Но так не интересно. Я решил делать мессенджер, способный легко горизонтально скейлиться, будто ожидаю миллионы одновременных клиентов. Однако так как опыта в этой сфере у меня не было никакого, пришлось всё познавать на практике методом ошибок и снова ошибок.

Финальная архитектура выглядит вот так

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

Ниже будет подробное описание отдельных компонентов.

Frontend Server

Ещё до того как я взялся делать игру, меня увлекла концепция асинхронного однопоточного сервера. Эффективно и без потенциальных race’ов — о чем ещё можно просить. С целью разобраться, как такие сервера устроены, я стал копаться в модуле asyncio языка python. Увиденное решение показалось мне очень изящным. Если кратко, то решение на псевдокоде выглядит так.

// Есть сокет, из которого мы ожидаем получить байты, но мы не знаем
// пришли ли они уже или ещё нет. Вместо того чтобы сразу вызывать socket.Receive
// и потенциально блокировать весь поток, делаем:
var bytesReceived = Completer<object>();
selector.Register(
    socket,
    SocketEvent.Receive,
    () => bytesReceived.Complete(null)
);

await bytesReceived.Future;

int n = socket.Receive(...); // точно не заблокирует

// selector - это простая обертка над poll. Он периодически опрашивает
// все зарегистрированные сокеты на предмет нужного события (Receive в
// данном случае), и, когда сокет становится готов, вызывает коллбек.
// Коллбек завершает completer, что приводит к возобновлению данного метода,
// и мы можем спокойно читать данные из сокета, зная, что байты там точно есть.
// Если не все байты получены, то мы просто повторяем те же шаги.

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

Frontend сервера реализованы именно так. Они все однопоточные и асинхронные. Поэтому для максимальной производительности нужно запускать столько серверов на одной машине сколько у неё имеется ядер (4 на картинке).

Frontend сервер читает сообщение от клиента и, основываясь на коде сообщения, отправляет его в один из топиков кафки.

Небольшая сноска для тех, кто не знаком с кафкой

Кафка имеет несколько применений, но я использую его как брокер сообщений по типу RabbitMQ. В кафке есть топики. Топик служит в качестве логического представления коллекции, в которую мы можем писать и на которую все заинтересованные клиенты могут подписываться (в моем случае authentication backend сервера подписываются на топик authentication, например). Почему логического? Потому что топик не является каким-то неделимым юнитом, каждый топик состоит из одного или более партишн (partition). Когда мы отправляем сообщение в топик, оно попадает в один из партишн. Мы можем либо явно указать партишн, либо довериться алгоритму, который определит в какой партишн отправить сообщение. Например, отправлять все сообщения с одинаковым ключом в один и тот же партишн (сообщения могут, но не обязаны, иметь ключ, а так же заголовки (headers)).

Зачем такие сложности? Зачем делить топик на партишн? Партишн служит в качестве единицы параллелизации. Несколько потребителей (consumer) могут подписаться на один и тот же топик (образуя группу consumer’ов), и тогда кафка (по умолчанию) распределит все партишн равномерно между ними. Если, скажем, у нас топик с двумя партишн, на который подписано 2 клиента, кафка распределит каждому клиенту по одному партишн. Если партишн 3 — одному из клиентов достанется 2. Кафка умеет детектить добавление новых партишн и новых клиентов и автоматически перераспределять партишн в случае необходимости.

Frontend сервер отправляет сообщение в кафку без ключа (когда нет ключа, кафка просто отправляет сообщения в партишн по очереди). Из топика сообщение вытаскивает один из соответствующих backend серверов. Сервер обрабатывает сообщение и… что дальше? А что дальше зависит от типа сообщения.

В самом банальном случае происходит цикл запрос-ответ. Например, на запрос о регистрации нам нужно просто дать клиенту ответ (Success, EmailAlreadyInUse, и тп). Но на сообщение, содержащем приглашение в существующий чат новых членов (Васю, Эмиля и Юлю), нам нужно ответить сразу тремя разными типами сообщений. Первый тип — нужно уведомить приглашающего об исходе операции (вдруг произошла серверная ошибка). Второй тип — нужно уведомить всех текущих членов чата, что в чате теперь такие-то новые члены. Третий — отправить приглашения Васе, Эмилю и Юле.

Окей, звучит не очень сложно, но для того чтобы отправить сообщение какому-либо клиенту нам нужно: 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 бд. Я выбрал редис.

Как всё выглядит на практике. Первым делом после установки соединения клиент Андрей отправляет серверу сообщение Join. Frontend сервер получает сообщение и пересылает его в топик session, предварительно добавляя заголовок «Frontend Server»: {index}. Один из backend session серверов получит сообщение, прочитает токен авторизации, определит что это за юзер присоединился, прочитает добавленный frontend сервером индекс и сделает запись UserId — Index в редис. С этого момента клиент считается онлайн, и теперь мы знаем через какой frontend сервер (и, соответственно, через какой партишн «frontend servers» топика) мы можем до него «достучаться», когда другие клиенты будут отправлять Андрею сообщения.

* На самом деле процесс чуть сложнее чем я описал. Можете ознакомиться в исходном коде.

Псевдокод frontend сервера

// Frontend Server 6
while (true) {
    // Consume from "Frontend Servers" topic, partition 6
    var messageToClient = consumer.Consume();
    if (message != null) {
        relayMessageToClient(messageToClient);
    }

    var callbacks = selector.Poll();
    while (callbacks.TryDequeue(out callback)) {
        callback();
    }

    long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    while (!callAtQueue.IsEmpty && callAtQueue.PeekPriority() <= now) {
        callAtQueue.Dequeue()();
    }

    while (messagesToRelayToBackendServers.TryDequeue(out messageFromClient)) {
        // choose topic
        producer.Produce(topic, messageFromClient);
    }
}

Здесь есть несколько трюков.
1) relayMessageToClient. Будет ошибкой просто взять нужный сокет и сразу начать отправлять в него сообщение, потому что, возможно, мы уже отправляем клиенту какое-то другое сообщение. Если мы начнем посылать байты, не проверив не занят ли сокет в данный момент, сообщения будут перемешаны. Как и во многих других местах, где требуется упорядоченная обработка данных, трюк заключается в использовании очереди, а именно, очереди из Completer’ов (TaskCompletionSource в C#).

void async relayMessageToClient(message) {
    // find client
    await client.ReadyToSend();
    await sendMessage(client, message);
    client.CompleteSend();
}

class Client {
    // ...
    sendMessageQueue = new LinkedList<Completer<object>>();

    async Future ReadyToSend() {
        var sendMessage = Completer<object>();
	if (sendMessageQueue.IsEmpty) {
	    sendMessageQueue.AddLast(sendMessage);
	} else {
	    var prevSendMessage = sendMessageQueue.Last;
	    sendMessageQueue.AddLast(sendMessage);
	    await prevSendMessage.Future;
	}
    }

    void CompleteSend() {
        var sendMessage = sendMessageQueue.RemoveFirst();
	sendMessage.Complete(null);
    }
}

Если очередь не пуста, значит, в данный момент сокет уже занят. Создаём новый completer, добавляем его в очередь и await‘им предыдущий completer. Таким образом, когда предыдущее сообщение будет отправлено, CompleteSend завершит completer, что приведет к тому, что сервер начнёт отправлять следующее сообщение. Такая очередь так же позволяет гладко propagate исключения. Допустим, во время отправки клиенту какого-то сообщения произошла ошибка. В таком случае нам нужно завершить с исключением отправку не только этого сообщения, но и всех сообщений, которые в данный момент ожидают своего часа в очереди (висят на await‘ах). Если мы этого не сделаем, то они так и продолжат висеть, и мы получим утечку памяти. Для краткости, код, который занимается этим, здесь не приведён.

2) selector.Poll. Собственно, даже не трюк, а просто попытка сгладить недостатки реализации метода Socket.Select (selector — просто обертка над этим методом). В зависимости от ОС этот метод под капотом использует либо select, либо poll. Но важно здесь не это. Важно то, как этот метод работает со списками, которые мы подаём ему на вход (список сокетов на чтение, на запись, на проверку ошибки). Этот метод берёт списки, опрашивает сокеты и оставляет в списках только те сокеты, которые готовы выполнить требуемую операцию. Все остальные сокеты выкидываются из списков. «Выкидывание» происходит через RemoveAt (то есть все последуюшие элементы сдвигаются, что неэффективно). Плюс к этому, так как нам нужно опрашивать все зарегистрированные сокеты каждую итерацию цикла, такое «очищение» вообще приносит вред, приходится каждый раз заново наполнять списки. Мы можем обойти все эти проблемы, используя кастомный List, метод RemoveAt которого не удаляет элемент из списка, а просто помечает его как удалённый. Класс ListForPolling и есть моя реализация такого списка. ListForPolling работает только с методом Socket.Select и не годится ни для чего другого.

3) callAtQueue. В большинстве случаев frontend сервер, переслав клиентское сообщение backend серверу, ожидает ответ (подтверждение, что операция прошла успешно, или ошибка, если что-то пошло не так). Если он не дожидается ответа в течение какого-то конфигурируемого промежутка времени, он отправляет клиенту ошибку, чтобы тот не ждал ответа, который никогда не придёт. callAtQueue — это priority queue. Сразу после того, как сервер отправляет сообщение в кафку, он делает примерно следущее:

long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
callAtQueue.Enqueue(callback, now + config.WaitForReplyMSec);

В коллбэке ожидание ответа отменяется и начинается отправка серверной ошибки. Если же ответ от backend сервера получен, коллбэк ничего не делает.

Использовать await Task.WhenAny(answerReceivedTask, Task.Delay(x)) нет возможности, так как код после Task.Delay выполняется на потоке из пула.

Вот, собственно, всё, что касается frontend серверов. Здесь требуется небольшая поправка. На самом деле, сервер не полностью однопоточный. Конечно, кафка под капотом использует потоки, но я имею в виду код приложения. Дело в том, что отправка сообщения в топик кафки (produce) может и не преуспеть. Кафка в случае провала сам повторяет отправку определённое конфигурируемое количество раз, но, если и повторные отправления проваливаются, кафка бросает это дело как безнадёжное. Проверить, было ли сообщение успешно отправлено или нет, можно в deliveryHandler, который мы передаём в метод Produce. Кафка вызывает этот хэндлер в I/O потоке producer’а (поток, который занимается отправкой сообщений). Мы должны удостовериться, что сообщение отправлено успешно, и, если нет, отменить ожидание ответа от backend сервера (ответ не придёт, потому что запрос не был отправлен) и отправить клиенту ошибку. То есть нам никак не избежать взаимодействия с другим потоком.

* При написании статьи я вдруг осознал, что мы можем не передавать deliveryHandler в метод Produce или просто игнорировать все ошибки кафки (клиенту всё равно будет отправлена ошибка по таймауту, который я описал ранее) — тогда весь наш код будет однопоточным. Теперь думаю, как лучше сделать.

Почему, собственно, кафка, а не рэббит?

Учитывая, что я использую кафку в качестве брокера сообщений, может возникнуть вопрос, а почему, собственно, не использовать RabbitMQ? Весь нужный мне функционал есть и в рэббите. И я, действительно, сначала использовал его. Так почему перешёл на кафку? Рэббит проще в использовании, но в моем случае он существенно усложнял код frontend серверов. При использовании рэббита, чтобы мониторить сообщения от backend серверов, мы подписываемся на определенную очередь сообщений, и рэббит вызывает предоставленный нами коллбек при каждом новом сообщении. Проблема заключается в том, что коллбеки вызываются на потоках из пула, что ломает мою однопоточную модель. Приходится использовать мютексы, что сразу делает код сложным и error-prone. О том, что рэббит также предоставляет basicGet механизм, который делает именно то, что мне нужно, мне было невдомёк в то время. Поэтому я перешёл на кафку. Если бы я знал про basicGet, скорее всего остался бы на рэббите, но о переходе на кафку не жалею. Кафка легче кластеризуется и в теории обладает большей пропускной способностью.

Backend Server

По сравнению с frontend сервером, интересных моментов здесь практически нет. Все backend сервера работают одинаково. На стартапе сервер подписывается на топик (authentication, session или call в зависимости от роли), и кафка назначает ему один или более партишн. Сервер получает сообщение из кафки, обрабатывает и обычно посылает в ответ одно или более сообщений. Почти реальный код:

void Run() {
    long lastCommitTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

    while (true) {
        var consumeResult = consumer.Consume(
            TimeSpan.FromMilliseconds(config.Consumer.PollTimeoutMSec)
        );

        if (consumeResult != null) {
            var workUnit = new WorkUnit() {
                ConsumeResult = consumeResult,
            };

            LinkedList<WorkUnit> workUnits;
            if (partitionToWorkUnits.ContainsKey(consumeResult.Partition)) {
                workUnits = partitionToWorkUnits[consumeResult.Partition];
            } else {
                workUnits = partitionToWorkUnits[consumeResult.Partition] =
                    new LinkedList<WorkUnit>();
            }

            workUnits.AddLast(workUnit);

            handleWorkUnit(workUnit);
        }

	if (
            DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - lastCommitTime >=
            config.Consumer.CommitIntervalMSec
        ) {
            commitOffsets();
	    lastCommitTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
	}
    }
}

Что за оффсеты, которые надо коммитить?

Каждое сообщение в партишн кафки имеет свой номер. Этот номер — просто смещение (offset) от начала партишн (0, 1 и тд). То есть в каждом партишн нумерация начинается с 0. Сообщение может быть уникально идентифицировано по трио TopicPartitionOffset. Когда мы читаем (consume) сообщение из кафки, мы получаем ConsumeResult, который, помимо самого сообщения, также содержит TopicPartitionOffset. Зачем нам нужна эта информация?

Кафка гарантирует at least once delivery, что означает, что сообщения не будут потеряны и будут доставлены в большинстве случаев один раз (иногда возможна повторная доставка сообщения). Это достигается за счёт того, что кафка для каждого партишн каждого топика хранит последний подтвержённый (commited) оффсет. Скажем, один consumer вытащил из назначенного ему партишн сообщение с оффсетом 16, обработал его, закоммитил 16й оффсет, вытащил следующее сообщение, но во время обработки вдруг умер, не сделав коммит. Кафка назначит его партишн какому-то другому consumer’у из той же группы consumer’ов и начнёт доставлять ему сообщения из данного партишн, начиная с оффсета 16 + 1 (последний подтверждённый оффсет + 1). Таким образом сообщение 17 не будет потеряно. Кафка может либо коммитить оффсеты автоматически каждые N миллисекунд, либо полностью передать контроль над коммитами пользователю.

Я отключил авто-коммит и занимаюсь коммитами самостоятельно. Это необходимо так как handleWorkUnit, где собственно и осуществляется обработка сообщения, — это async void метод, поэтому нет никаких гарантий, что сообщение 5 будет обработано раньше сообщения 6. Кафка хранит только один commited оффсет (а не набор оффсетов), соответственно, перед тем как коммитить оффсет 6, нам нужно убедиться, что все предыдущие сообщения тоже были обработаны. Помимо этого, один backend сервер может потреблять сообщения из нескольких партишн одновременно, и, значит, должен следить за тем чтобы коммитить правильный оффсет в соответствующий партишн. Для этого мы используем hash map вида partition: work units. Вот как выглядит код commitOffsets (настоящий код на этот раз):

private void commitOffsets() {
    foreach (LinkedList<WorkUnit> workUnits in partitionToWorkUnits.Values) {
        WorkUnit lastFinishedWorkUnit = null;
        LinkedListNode<WorkUnit> workUnit;
        while ((workUnit = workUnits.First) != null && workUnit.Value.IsFinished) {
            lastFinishedWorkUnit = workUnit.Value;
            workUnits.RemoveFirst();
        }

        if (lastFinishedWorkUnit != null) {
            offsets.Add(lastFinishedWorkUnit.ConsumeResult.TopicPartitionOffset);
        }
    }

    if (offsets.Count > 0) {
        consumer.Commit(offsets);
        foreach (var offset in offsets) {
            logger.Debug(
                "{Identifier}: Commited offset {TopicPartitionOffset}",
                identifier,
                offset
            );
        }
        offsets.Clear();
    }
}

Как видно, мы итерируем по ворк юнитам, находим последний завершённый к данному моменту юнит, после которого нет незавершённых, и коммитим соответствующий ему оффсет. Такой цикл позволяет нам избежать «дырявых» коммитов. Например, если у нас в данный момент 4 ворк юнита (0: Finished, 1: Not Finished, 2: Finished, 3: Finished), мы можем закоммитить только 0й юнит, так как, если закоммитим сразу 3й, это может привести к потенциальной потере 1го, если вдруг сервер умрёт прямо сейчас.

class WorkUnit {
    public ConsumeResult<Null, byte[]> ConsumeResult { get; set; }
    private int finished = 0;

    public bool IsFinished => finished == 1;

    public void Finish() {
        Interlocked.Increment(ref finished);
    }
}

handleWorkUnit, как было сказано, async void метод, и он, соответственно, полностью обёрнут в try-catch-finally. В try он вызывает нужный сервис, а в finallyworkUnit.Finish().

Сервисы довольно тривиальны. Вот, например, какой код выполняется, когда юзер отправляет новое сообщение:

private async Task<ServiceResult> createShareItem(CreateShareItemMessage msg) {
    byte[] message;
    byte[] messageToPals1 = null;
    int?[] partitions1 = null;

    // Вытаскиваем UserId из токена.
    long? userId = hashService.ValidateSessionIdentifier(msg.SessionIdentifier);
    if (userId != null) {
        var shareItem = new ShareItemModel(
            requestIdentifier: msg.RequestIdentifier,
            roomIdentifier: msg.RoomIdentifier,
            creatorId: userId,
            timeOfCreation: null,
            type: msg.ShareItemType,
            content: msg.Content
        );

        // Создаём новое сообщение или возвращаем null,
        // если такой комнаты не существует.
        long? timeOfCreation = await storageService.CreateShareItem(shareItem);
        if (timeOfCreation != null) {
            // Ищем всех членов комнаты в кэше.
            List<long> pals = await inMemoryStorageService.GetRoomPals(
                msg.RoomIdentifier
            );
            if (pals == null) {
            	// Если нет в кэше - вытаскиваем из бд и сохраняем в кэш.
                pals = await storageService.GetRoomPals(msg.RoomIdentifier);
                await inMemoryStorageService.SaveRoomPals(msg.RoomIdentifier, pals);
            }

            // Хотим отправить сообщение всем, кроме отправителя.
            pals.Remove(userId.Value);

            if (pals.Count > 0) {
            	// Создаём ack, чтобы отслеживать, кто не получил и
                // кто не прочитал сообщение.
                await storageService.CreateAck(
                    msg.RequestIdentifier, userId.Value, msg.RoomIdentifier,
                    timeOfCreation.Value, pals
                );

                // in - список UserId, out - список индексов frontend серверов,
                // к которым юзеры подключены. Если какой-то юзер офлайн -
                // индекс будет null.
                partitions1 = await inMemoryStorageService.GetUserPartitions(pals);

                List<long> onlinePals = getOnlinePals(pals, partitions1);

                // Если никого нет онлайн, то и слать сообщение никому не нужно.
                // Офлайн юзеры получат сообщение при следующем заходе в приложение.
                if (onlinePals.Count > 0) {
                    messageToPals1 = converterService.EncodeNewShareItemMessage(
                        userId.Value, timeOfCreation.Value, onlinePals, shareItem
                    );
                    nullRepeatedPartitions(partitions1);
                    // Какие-то юзеры могут быть подключены к одному и тому же
                    // frontend серверу, поэтому здесь мы null'им дупликаты.
                }
            }

            message = converterService.EncodeSuccessfulShareItemCreationMessage(
                msg.RequestIdentifier, timeOfCreation.Value
            );
        } else {
            message = converterService.EncodeMessage(
                MessageCode.RoomNotFound, msg.RequestIdentifier
            );
        }
    } else {
        message = converterService.EncodeMessage(
            MessageCode.UserNotFound, msg.RequestIdentifier
        );
    }

    return new ServiceResult(
        message: message, // Это сообщение уйдёт отправителю.
        messageToPals1: messageToPals1, // Это - всем остальным членам комнаты.
        partitions1: partitions1
    );
}

База данных

Большая часть функционала сервисов, вызываемых backend серверами, — это просто добавление новых данных в бд и обработка уже имеющихся. Очевидно, как база данных устроена и как мы ей оперируем играет очень важное значение для мессенджера, и тут мне бы хотелось сказать, что я подошёл к вопросу выбора бд очень тщательно после внимательного изучения всех вариантов, но это не так. Я просто выбрал CockroachDb, потому что он обещает много при минимуме усилий и имеет совместимый с postgres синтаксис (я работал с постгрес раньше). Были мысли использовать Кассандру, но в конце концов решил остановиться на чём-то знакомом. Я никогда раньше не работал ни с кафкой, ни с рэббитом, ни с Flutter и дарт, ни с WebRtc, поэтому решил не тащить ещё и Кассандру, так как боялся утонуть во всём множестве новых для меня технологий.

Из всех частей моего проекта дизайн базы данных — вещь, в которой я сомневаюсь больше всего. Я не уверен, что решения, которые я принял, действительно, хорошие решения. Всё работает, но можно было сделать лучше. Например, есть таблицы ShareRooms (так я называю чаты) и ShareItems (так я называю сообщения). Так вот все юзеры, входящие в какую-то комнату, записаны в jsonb поле этой комнаты. Это удобно, но явно очень медленно, так что скорее всего переделаю на использование внешних ключей. Или, например, таблица ShareItems хранит все сообщения. Что тоже удобно, но так как ShareItems является одной из самых нагруженных таблиц (постоянные select и insert), возможно стоит создавать новую таблицу для каждой комнаты или что-то в этом роде. Кокроач раскидывает записи по разным нодам, соответственно, нужно тщательно продумывать куда какая запись пойдёт, чтобы добиться максимальной производительности, а я этого не делал. В общем, как можно понять из всего вышесказанного, базы данных не самое моё сильное место. Прямо сейчас я вообще тестирую всё на постгрес, а не кокроач, потому что так меньше нагрузки на мою рабочую машину, она и так бедная от нагрузок скоро взлетит. Благо код для постгрес и кокроач разнится совсем немного, так что переключаться не составляет труда.

Сейчас я нахожусь в процессе изучения, как, собственно, кокроач работает (как происходит mapping между SQL и key-value (кокроач использует RocksDb под капотом), как он распределяет данные между нодами, реплицирует и тд). Стоило, конечно, изучить кокроач перед тем как использовать его, но лучше поздно чем никогда.

Я думаю, что база претерпит большие изменения, когда я стану лучше разбираться в этом вопросе. Прямо сейчас мне не даёт покоя таблица Acks. В этой таблице я храню данные о том, кто ещё не получил и кто ещё не прочитал сообщение (чтобы показывать юзеру галочки). Легко уведомить юзера, что его сообщение прочитано, если юзер сейчас онлайн, но если нет, нам нужно сохранять эту информацию, чтобы уведомить юзера позже. И так как доступны групповые чаты, недостаточно просто хранить флаг, нужны данные про отдельных юзеров. Так вот здесь прямо просится использование битовых строк (одна строка на ещё не получивших юзеров, вторая — на ещё не прочитавших). Тем более кокроач поддерживат bit и bit varying. Однако я так и не придумал, как это дело реализовать, учитывая, что состав комнат может постоянно меняться. Чтобы битовые строки сохраняли свой смысл, юзеры в комнате должны оставаться в том же порядке, что довольно затрудительно сделать, когда, например, какой-то юзер покидает комнату. Здесь есть варианты. Возможно стоит записывать -1 вместо того чтобы удалять юзера из jsonb поля, чтобы сохранялся порядок, или использовать какой-то способ версионирования, чтобы мы знали, что вот эта вот битовая строка ссылается на порядок юзеров, который был тогда-то тогда-то, а не на нынешний порядок юзеров. Я всё ещё в процессе продумывания, как это дело лучше реализовать, а пока ещё не получившие и ещё не прочитавшие юзеры — это тоже просто jsonb поля. Учитывая, что запись в таблицу Acks делается при каждом сообщении, объём данных получается большим. Хотя запись, конечно, удаляется, когда сообщение получено и прочитано всеми.

Flutter

Долгое время я работал над серверной частью и использовал простые консольные клиенты для теста, так что даже не создавал Flutter проект. А когда создал, думал, что серверная часть была сложной частью, а приложение это так, фигня, за пару дней разберусь. Пока работал над сервером, пару раз создавал Hello World’ы на флаттер, чтобы прочувствовать фреймворк, и, так как мессенджеру не требуется какой-то замысловатый UI, думал, что полностью готов. Так вот UI, действительно, фигня, но реализация функционала доставила мне проблем (и ещё доставит, так как не всё готово).

State management

Самая популярная тема. Есть тысяча способов управлять состоянием, и рекомендуемый подход меняется раз в полгода. Сейчас мэйнстримом является provider. Лично я для себя выбрал 2 способа: bloc и redux. Bloc (Business Logic Component) для управления локальным состоянием и redux для управления глобальным.

Bloc — это не какая-то библиотека (хотя, конечно, есть и библиотека, уменьшающая бойлерплейт, но я ей не пользуюсь). Bloc — это подход, основанный на стримах. Вообще дарт довольно приятный язык, а стримы так вообще конфетка. Суть этого подхода заключается в том, что мы распихиваем всю бизнес-логику по сервисам, а коммуникацию между UI и сервисами осуществляем посредством контроллёра, который предоставляет нам различные стримы. Пользователь нажал кнопку «найти контакт»? Используя sink (другой конец стрима) отправляем в контроллёр событие SearchContactsEvent, контроллёр вызовет нужный сервис, дождётся результата и вернёт список юзеров обратно UI тоже посредством стрима. UI ждёт результаты, используя StreamBuilder (виджет, который ребилдится каждый раз когда в стрим, на который он подписан, поступают новые данные). Вот, собственно, и всё. В некоторых случаях нам нужно обновлять UI безо всякого участия юзера (например, когда пришло новое сообщение), но это тоже легко делается посредством стримов. Фактически, простой MVC со стримами, никакой магии.

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

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

Самая мучительная часть

Что делать, если юзер отправил сообщение, но перед тем, как оно было послано, пропало интернет соединение? Что делать, если юзеру пришло подтверждение прочтения, но он закрыл приложение перед тем, как соответствующая запись в базе данных была обновлена? Что делать, если юзер пригласил в комнату своего друга, но перед тем как приглашение было отправлено, у него умерла батарея? Вы когда-нибудь задавались подобными вопросами? Вот и я нет. Раньше. А вот в процессе разработки стал задаваться. Так как соединение может в любой момент пропасть, а телефон в любой момент выключиться, необходимо подтверждать всё. Not fun. Поэтому самое первое сообщение, которое клиент отправляет серверу (Join, если помните) — это не просто «Hello I am online», это «Hello I am online and here are unconfirmed rooms, here are unconfirmed messages, here are unconfirmed acks, here are unconfirmed room membership operations, and here are last received messages per room». И сервер отвечает похожей простынёй: «Пока ты был офлайн такие-то твои сообщения были прочитаны такими-то юзерами, а ещё в эту комнату пригласили Петю, а ещё из этой комнаты ушла Света, а ещё тебя пригласили в эту комнату, а вот в этих двух комнатах по 40 новых сообщений». Я бы очень хотел знать, как подобные вещи делаются в других мессенджерах, потому что моя реализация не блещет изяществом.

Изображения

В данный момент можно отправлять текст, текст + изображения и просто изображения. Отправка видео ещё не реализована. Изображения немного ужимаются и сохраняются в Firebase storage. В самом сообщении передаются ссылки. По получении сообщения клиент скачивает изображения, генерирует миниатюры и сохраняет всё на файловую систему. В базу записываются пути к файлам. Кстати, генерация миниатюр — единственный код, выполняемый на отдельном треде, так как это compute-heavy операция. Я просто запускаю один воркер-поток, скармливаю ему изображение и в ответ получаю миниатюру. Код предельно прост, так как дарт даёт удобные абстракции для работы с потоками.

ThumbnailGeneratorService

class ThumbnailGeneratorService {
  SendPort _sendPort;
  final Queue<Completer<Uint8List>> _completerQueue =
      Queue<Completer<Uint8List>>();

  ThumbnailGeneratorService() {
    var receivePort = ReceivePort();
    Isolate.spawn(startWorker, receivePort.sendPort);

    receivePort.listen((data) {
      if (data is SendPort) {
        _sendPort = data;
      } else {
        var completer = _completerQueue.removeFirst();
        completer.complete(data);
      }
    });
  }

  static void startWorker(SendPort sendPort) async {
    var receivePort = ReceivePort();
    sendPort.send(receivePort.sendPort);

    receivePort.listen((imageBytes) {
      Image image = decodeImage(imageBytes);
      Image thumbnail = copyResize(image, width: min(image.width, 200));

      sendPort.send(Uint8List.fromList(encodePng(thumbnail)));
    });
  }

  Future<Uint8List> generate(Uint8List imageBytes) {
    var completer = Completer<Uint8List>();
    _completerQueue.add(completer);
    
    _sendPort.send(imageBytes);

    return completer.future;
  }
}

Также используется Firebase auth, но только для авторизации доступа к Firebase storage (чтобы юзер не мог, скажем, залить профильную картинку кому-то другому). Вся остальная авторизация осуществляется через мои серверы.

Формат сообщений

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

Первые 4 байта — длина сообщения.
Следующий байт — код сообщения.
Следующие 16 байт — идентификатор запроса (uuid).
Следующие 40 байт — токен авторизации.
Остальная часть сообщения.

Длина сообщения требуется, так как я не использую http или вебсокеты, или какой-то другой протокол, который обеспечивает разделение одного сообщения от другого. Мои frontend сервера видят только потоки байтов, и они должны знать, где одно сообщение заканчивается, и начинается другое. Есть несколько способов разделять сообщения (например, использовать какой-то никогда не встречающийся в сообщениях символ в качестве разделителя), но я предпочёл указывать длину, так как этот способ самый простой, хоть он и влечёт за собой оверхед, так как большинству сообщений хватает и одного байта для указания длины.

Код сообщения это просто один из членов enum’а MessageCode. Routing осуществляется по коду, и, так как мы можем вытащить код из массива без предварительной десериализации, frontend сервер сам решает в какой топик кафки отправить сообщение вместо того чтобы делегировать эту обязанность кому-то другому.

Идентификатор запроса присутствует в большинстве сообщений, но не во всех. Он выполняет 2 функции: по этому идентификатору клиент устанавливает соответствие между отправленным запросом и полученным ответом (если клиент отправил сообщения А, Б, В в таком порядке, это не означает, что ответы тоже придут по порядку). Вторая функция — избежание дупликатов. Как было сказано ранее, кафка гарантирует at least once delivery. То есть в редких случаях сообщения всё-таки могут быть продублированы. Добавив в нужную таблицу базы данных колонку RequestIdentifier с unique ограничением, мы можем избежать вставки дупликата.

Токен авторизации — это UserId (8 байт) + 32 байта HmacSha256 подпись. Не думаю, что здесь стоит использовать Jwt. Jwt это примерно в 7-8 раз больший размер ради получения чего? У меня юзеры не имеют никаких claims, поэтому простая подпись hmac’ом годится. Авторизации через другие сервисы нет и не планируется.

Аудио и видео звонки

Забавно, что реализацию аудио и видео звонков я сознательно откладывал, так как был уверен, что проблем не оберусь, а на деле это оказалось одной из самых легких в реализации фич. По крайней мере базовый функционал. Вообще просто добавление WebRtc в приложение и получение первого сеанса видеосвязи заняло всего несколько часов, и, о чудо, первый же тест увенчался успехом. До этого я думал, что работающий с первого раза код — это миф. Обычно первый тест новой фичи всегда проваливается из-за какой-нибудь тупой ошибки вроде «добавил сервис, но не зарегистрировал его в DI-контейнере».

Не очень кратко о WebRtc для непосвящённых

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 будет создан, добавить их в соединение. Тот факт, что кандидаты могут начать приходить ещё до оффера, а также некоторые другие причины, делают реализацию полноценных звонков нетривиальной задачей. Что делать, если нам звонят сразу несколько юзеров? Нам будут приходить кандидаты от всех, и, хотя мы можем отделить кандидатов одного юзера от другого, становится неясно каких кандидатов отбрасывать, потому что мы не знаем чей оффер придёт раньше. Также будут проблемы, если нам начинают приходить кандидаты и затем оффер в момент, когда мы сами кому-то звоним.

Потестировав несколько вариантов с «голым» WebRtc, я пришёл к выводу, что в таком виде будет проблематично и чревато утечками памяти пытаться реализовать звонки, поэтому я решил добавить ещё одну стадию в процесс переговоров WebRtc. Я называю эту стадию Inquire - Grant/Refuse.

Идея очень проста, но у меня заняло довольно много времени, чтобы до неё дойти. Звонящий ещё до создания стрима и RTCPeerConnection (и вообще до выполнения любого кода, связанного с WebRtc) отправляет через сигнальный сервер другой стороне сообщение Inquire. На принимающей стороне проверяется не находится ли юзер в каком-то другом сеансе связи в данный момент (простое bool поле). Если находится, то обратно посылается сообщение Refuse, и таким образом мы даем знать звонящему, что юзер занят, а принимающему — что ему звонил такой-то такой-то, пока он был занят другим разговором. Если же юзер в данный момент свободен, то он резервируется. В сообщении Inquire отправляется идентификатор сессии, и данный идентификатор устанавливается как идентификатор текущей сессии. Если юзер зарезервирован, он откланяет все Inquire/Offer/Candidate сообщения с идентификаторами сессий, отличных от текущего. После резервации принимающий отправляет через сигнальный сервер звонящему сообщение Grant. Стоит сказать, что принимающему юзеру этот процесс не виден, так как никакого звонка ещё нет. И главное здесь не забыть на принимающей стороне повесить тайм-аут. Вдруг мы зарезервируем сессию, а никакого оффера не последует.

Звонящий получает Grant, и вот здесь начинается WebRtc с офферами, кандидатами и вот этим вот всем. Оффер улетает принимающему, и тот, при получении, отображает экран с кнопками Ответить/Отклонить. Но кандидаты, как обычно, никого не ждут. Они снова начинают приходить даже раньше оффера, потому что нет никаких причин ждать, пока юзер ответит на звонок. Он может и не ответить, а отклонить или дождаться пока истечёт тайм-аут — тогда кандидаты просто будут выброшены.

Текущее состояние и дальнейшие планы

  • Личные и групповые чаты
  • Отправка текста, изображений и видео
  • Аудио и видео-звонки
  • Подтверждение получения и прочтения
  • «Печатает…»
  • Уведомления
  • Поиск по QR-коду и геолокации

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

Уведомления в процессе реализации, как и отправка видео.

Что ещё нужно сделать?

Ох, многое.
Во-первых, нет тестов. Раньше тесты писали коллеги, так что я совсем расслабился.
Во-вторых, пригласить юзеров в существующий чат и покинуть чат в данный момент невозможно. Серверный код для этого готов, клиентский — нет.
В-третьих, если с обработкой ошибок на сервере всё более-менее, то на клиенте никакой обработки ошибок нет. Недостаточно просто сделать запись в лог, нужно повторять попытки операции. Сейчас, например, механизм повторной отправки сообщений не реализован.
В-четвёртых, сервер не пингует клиента, поэтому дисконнект не детектится, если, например, у клиента пропал интернет. Дисконнект детектится только когда клиент закрывает приложение.
В-пятых, индексы в бд не используются.
В-шестых, оптимизация. В коде огромное число мест, где написано что-то вроде // @@TODO: Pool. Большинство массивов просто new‘ятся. Backend сервер создаёт множество массивов фиксированной длины, так что тут можно и нужно использовать пул.
В-седьмых, на клиенте много мест, где код await‘ится, хотя в этом нет необходимости. Отправка изображений, например, поэтому кажется медленной, потому что код await‘ит сохранение картинок на файловую систему и генерацию миниатюр перед тем как отобразить сообщение, хотя ничего этого и не нужно делать. Или, например, если вы открываете приложение и за время вашего отсутствия вам посылали изображения, то стартап будет медленным, потому что опять же все эти изображения скачиваются, сохраняются на систему, миниатюры генерируются, и только после этого стартап завершается и вас перекидывает со splash скрина на home скрин. Все эти redundant await‘ы были сделаны для более простого дебага, но, конечно, перед релизом от ненужного ожидания нужно избавиться.
В-восьмых, UI сейчас готов на половину, потому что я пока не решил каким хочу его видеть. Поэтому сейчас всё неинтуитивно, половина кнопок неясно, что делают. И кнопки часто не нажимаются с первого раза, так как сейчас это просто иконки с GestureDetector и без паддинга, поэтому не всегда получается в них попадать. Плюс в некоторых местах pixel overflow не исправлен.
В-девятых, сейчас даже невозможно Sign-in в аккаунт, только Sign Up. Поэтому если удалить приложение и установить заново, зайти в аккаунт не получится :)
В-десятых, код подтвержения не отправляется на почту. Сейчас код вообще всегда одинаковый, опять же потому что так проще дебажить.
В-одиннадцатых, single-responsibility принцип нарушается во многих местах. Нужен рефактор. Классы, отвечающие за взаимодействие с бд (что на клиенте, что на сервере), вообще очень сильно раздуты, потому как занимаются всеми бд операциями.
В-двенадцатых, frontend сервер сейчас всегда ожидает ответ от backend сервера, даже если сообщение не подразумевает отправки ответа (например, сообщение с кодом IsTyping и некоторые WebRtc-related сообщения). Поэтому не дождавшись ответа, он пишет в консоль ошибку, хоть это и не является ошибкой.
В-тринадцатых, полные изображения не открываются по тапу.
В-сто миллион пятых, некоторые сообщения, которые нужно отправлять пачкой, отправляются отдельно. То же касается и некоторых бд операций. Вместо того чтобы выполнить одну команду, команды выполняются в цикле с await (брр..).
В-сто миллион шестых, некоторые значения захардкожены, вместо того чтобы быть конфигурируемыми.
В-сто миллион седьмых, логирование на сервере сейчас только в консоль, а на клиенте вообще прямо в виджет. На главном экране есть таб Logs, куда скидываются все логи on tap. Дело в том, что моя рабочая машина отказывается запускать одновременно и эмулятор, и всё необходимое для сервера (кафка, бд, редис и все сервера). Дебажить с подлючённым устройством тоже не выходило, всё просто намертво зависало в половине случаев, потому что компьютер не справлялся с нагрузками. Поэтому приходится каждый раз делать билд, скидывать его на устройство, устанавливать и тестировать вот так. Чтобы видеть логи, скидываю их прямо в виджет. Извращение, знаю, но тут уж выбирать не приходится. По этой же причине многие методы возвращают Future и await‘ятся (чтобы поймать исключение и скинуть в виджет), хотя и не должны. Если будете смотреть код, то увидите уродливый _logError метод во многих классах, который этим и занимается. Это всё, конечно, тоже отправится в мусорку.
В-сто миллион восьмых, нет звуков.
В-сто миллион девятых, нужно больше использовать кэширование.
В-сто миллион десятых, много повторяющегося кода. Например, многие action’ы первым делом проверяют валидность токена, и, если он не валиден, отправляют ошибку. Думаю нужно реализовать простенький middleware-pipeline.

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

Я бы оценил готовность проекта в 70%.

Итоги

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

Если есть какие-то вопросы, пишите. Почта есть на гитхаб.

Понравилась статья? Поделить с друзьями:
  • Как написать меню на css
  • Как написать меню для дисплея на arduino
  • Как написать менуэт
  • Как написать менее 1 процента
  • Как написать менеджеру озон