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

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

Статья подойдёт состоявшимся программистам и тем, кто только интересуется, как войти в 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»

Одним вечером, после очередного расстраивающего дня, наполненного попытками наладить баланс в своей игре, я решил, что мне срочно требуется отдых. Переключусь на другой проект, быстренько его сделаю, верну на место скатившуюся за время разработки игры самоооценку и с новыми силами возьму игру штурмом! Главное выбрать проект 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%.

Итоги

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

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

16 Сентября 2022


671



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

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


Введение

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

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

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

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

Монетизация

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

Авторизация

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

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

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


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

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


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

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

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

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

Объем работы

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

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

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

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

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

Заключение

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

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

Если вы сомневаетесь нужно в ли ступать на территорию разработки мессенджеров, просто взгляните на последние цифры. В 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.

Работать в «Комитете», понимать, как работает API «Основы», на которой построен vc.ru, и вообще быть программистом для этого необязательно.

Шаг 1. Приложению нужна красивая иконка

Для этого нужен фотошоп и шаблон иконки приложения для iOS. Но я всё сделал за вас

Загрузите в полном размере к себе на компьютер и сохраните как «icon.png». Она пригодится нам позднее – мы добавим её к приложению, чтобы оно было красивое :)

Шаг 2. Нужно загрузить nativefier, который создаёт из сайта приложение на Electron

На самом деле это Chrome, внутри которого открывается заранее установленный сайт.

Как загрузить nativefier можно посмотреть на официальной страничке:

для этого требуется macOS 10.9+ / Windows 7+ / Linux (как повезёт)

Если вам повезло, и у вас есть npm или homebrew, установить его можно в одну команду:

npm i nativefier -g
или
brew install nativefier

На линукс npm так же просто поставить, как на мак homebrew

Поставить на мак:
ruby -e «$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)»
Поставить на линукс:
sudo apt install npm

и затем выполните предыдущее действие.

Шаг 3. Изменяем стиль

Если мы сейчас просто запустим nativefier, то получим обычный сайт vc.ru

Чтобы сделать его больше похожим на месенджер, необходимо с помощью javascript удалить шапку сайта и левую панель.

Для этого в той же папке, что и иконка, создадим файл «remove.js»

И запишем в него 4 простые команды – найти объект, удалить объект:

let sidebar = document.getElementsByClassName(«layout__left-column»)[0]
let header = document.getElementsByClassName(«site-header»)[0]
sidebar.parentNode.removeChild(sidebar)
header.parentNode.removeChild(header)

Мы практически у цели! Нужно теперь запустить сам nativefier

nativefier https://vc.ru/m/
—name SiliconMessenger
—icon ./icon.png
—counter
—bounce
—inject ./remove.js
—disable-context-menu
—disable-dev-tools
Дополнительные параметеры (нужны для кроссплатформенной сборки)
—platform mac | win32 | linux ; нужен, если вы хотите собрать приложение для другой системы
—arch x86 | x64 | arm ; нужен, если хотите собрать для другой архитектуры

—name SiliconMessenger укажет название для приложения, которое мы хотим задать

—counter —bounce нужны для того, чтобы уведомления страницы в браузере (которые обычно видны на вкладке) отображались на иконке приложения в доке или панели задач (см. api nativefier).

—inject. /remove.js подтянет команды для удаления лишних деталей интерфейса, которые мы написали выше.

—disable-context-menu —disable-dev-tools нужны чтобы никто не догадался, что это на самом деле Chrome: )

Шаг 4. Ура! Можно общаться!

Если вы всё сделали по инструкции, то должны были получить что-то такое (для Windows и линукс – аналогично, но с другим оформлением окна)

Это телеграм? Телеграм, да?

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

Можно запустить его слева столбиком, а справа работать

Плюсом, так как это Chrome, все стандартные команды ctrl(cmd)+C/+V/+R (чтобы перезагрузить страницу) и остальные продолжают работать, как вы привыкли.

Надеюсь, этот удобный способ общаться поможет налаживать бизнес-связи и искать партнеров в чатах vc.ru!

На правах юмора. Если захотите, я могу выложить. exe /.dmg / исполняемый файл для linux или помочь вам сделать это самостоятельно

  • Download demo project — 17.1 KB
  • Download source — 29.1 KB

Client-server communiaction

Table of contents

  1. Introduction
  2. Background
  3. Preparing
  4. Listen! Incoming connection!
  5. Connection
  6. Packet types
  7. Say hello
  8. Events
  9. Register and login
  10. Packets receiving loop
  11. Save users information
  12. Check other users availability
  13. Culmination: Sending and receiving messages
  14. User interface
  15. Conclusion

Introduction

Did you ever want to write your own instant messenger program like Skype? OK, not so advanced… I will try to explain how to write
a simple instant messenger (IM) in C#.NET.

First, some theory. Our instant messenger will work on a client-server model.

Client-server communiaction

Users have client programs which connect to the server application. Client programs know
the server’s IP or hostname (e.g., example.com).

The most popular internet protocols are TCP and UDP. We will use TCP/IP, because it is reliable and it has established connection. .NET offers TcpClient and
TcpListener classes for this protocol. TCP/IP doesn’t offer encryption. It is possible to create own encryption protocol over TCP, but I
recommend using
SSL (used in HTTPS). It authenticates server (and optionally client) and encrypts
connection.

SSL is using X.509 certificates for authenticating. You can buy real SSL
certificate (trusted) or generate self-signed certificate (untrusted). Untrusted
certificates allow encryption, but authentication isn’t safe. We can use them
for testing. I made batch script, which generates self-signed certificate in PFX package. My
script requires OpenSSL installed in system. I included also one in
server application project.

At the end there is your higher-level protocol, which sends messages to specified
users and does other IM stuff. I will explain my protocol during article.

You can debug your server and client on the same computer: hostname of server
will be localhost or 127.0.0.1 (local IP — same computer).

Background

You should know something about SSL protocol, certificates, and networking.

Preparing

Create two projects: server and client. Server will be a console application, client
— Windows Forms (or WPF). You will need to debug two projects at once, so don’t place
them in one solution.

Server

We will write the main server code in non-static scope. Add these lines
to Main:

Program p = new Program();
Console.WriteLine();
Console.WriteLine("Press enter to close program.");
Console.ReadLine();

It will create a new instance of the Program (class containing Main).
The server code will be in the constructor.

public Program()
{
    Console.Title = "InstantMessenger Server";
    Console.WriteLine("----- InstantMessenger Server -----");
}

Client

Add class IMClient — it will process and send all packets of our protocol.
Add basic variables:

Thread tcpThread;      
bool _conn = false;    
bool _logged = false;  
string _user;          
string _pass;          
bool reg;              

And some properties:

public string Server { get { return "localhost"; } }
public int Port { get { return 2000; } }

public bool IsLoggedIn { get { return _logged; } }
public string UserName { get { return _user; } }
public string Password { get { return _pass; } }

Server is the name or IP of the computer where the server software is running. We will test
the IM on one computer so the address of the server is localhost. Port is
the TCP port of the server. For example, HTTP default port is 80. These methods will be
used to connect and disconnect:

void SetupConn()  
{
}
void CloseConn() 
{
}

Finally public methods for login, register, and disconnect.

void connect(string user, string password, bool register)
{
    if (!_conn)
    {
        _conn = true;
        _user = user;
        _pass = password;
        reg = register;

        
        tcpThread = new Thread(new ThreadStart(SetupConn));
        tcpThread.Start();
    }
}
public void Login(string user, string password)
{
    connect(user, password, false);
}
public void Register(string user, string password)
{
    connect(user, password, true);
}
public void Disconnect()
{
    if (_conn)
        CloseConn();
}

Listen! Incoming connection!

The server will listening to incoming connections. Some variables at the start:

public IPAddress ip = IPAddress.Parse("127.0.0.1");
public int port = 2000;
public bool running = true;
public TcpListener server;  

These lines in the constructor will create and start the server:

server = new TcpListener(ip, port);
server.Start();

The server is started. Now listen to incoming connections:

void Listen()  
{
    while (running)
    {
        TcpClient tcpClient = server.AcceptTcpClient();
    }
}

AcceptTcpClient waits for incoming connections and then it returns it as
a TcpClient. Now we have to handle
the client. Create
a class for this and name it Client. Then add a constructor and these variables:

public Client(Program p, TcpClient c)
{
    prog = p;
    client = c;
}

Program prog;
public TcpClient client;

In the method for listening, we are passing tcpClient and Program instances to
the Client
class:

Client client = new Client(this, tcpClient);

In the class for handling the client, add these functions (again):

void SetupConn()  
{
}
void CloseConn() 
{
}

And finally in the constructor (of the Client class), run code for preparing
connection in another thread.

(new Thread(new ThreadStart(SetupConn))).Start();

It’s time to setup a connection.

Connection

We have to establish a connection. The following code will be placed in
the server’s
Client class and in the client’s IMClient. We need the following variables:

public TcpClient client;
public NetworkStream netStream;  
public SslStream ssl;            
public BinaryReader br;          
public BinaryWriter bw;          

Connect to the server (only at client):

client = new TcpClient(Server, Port);

Now let’s get a stream of the connection. We can read and write raw data using this
stream.

netStream = client.GetStream();

OK, we can read and write, but it isn’t encrypted. Let’s add SSL.

ssl = new SslStream(netStream, false);
ssl = new SslStream(netStream, false,
    new RemoteCertificateValidationCallback(ValidateCert));

When the server is authenticating, the client has to confirm the certificate. SslStream checks
the certificate and then passes the results to RemoteCertificateValidationCallback.
We need to confirm the certificate in the callback. Add this function (it is passed in
the
SslStream constructor):

public static bool ValidateCert(object sender, X509Certificate certificate, 
              X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
    return true; 
}

For testing we are using an untrusted certificate, so we are ignoring policy errors
and accepting all certificates.
SSL needs a certificate. You have to generate one (I made a script for this) or
you can use the certificate included in the source code. Let’s load this in
the Program class.

public X509Certificate2 cert = new X509Certificate2("server.pfx", "instant");

The second parameter is the password of the certificate. My batch script is
automatically setting the password to «instant«. Now
authenticate the server and client:

ssl.AuthenticateAsServer(prog.cert, false, SslProtocols.Tls, true);
ssl.AuthenticateAsClient("InstantMessengerServer");

Now the connection is authenticated and encrypted. We need some reader and
writer for simple data, such as integers, strings, etc.

br = new BinaryReader(ssl, Encoding.UTF8);
bw = new BinaryWriter(ssl, Encoding.UTF8);

There remains closing the connection.

void CloseConn()
{
    br.Close();
    bw.Close();
    ssl.Close();
    netStream.Close();
    client.Close();
}
CloseConn(); 

We are ready to communicate.

Packet types

In this instant messenger packets start from byte type and then there are other data
written with BinaryWriter. These
are the  packet types used in my protocol (as yet):

public const int IM_Hello = 2012;      
public const byte IM_OK = 0;           
public const byte IM_Login = 1;        
public const byte IM_Register = 2;     
public const byte IM_TooUsername = 3;  
public const byte IM_TooPassword = 4;  
public const byte IM_Exists = 5;       
public const byte IM_NoExists = 6;     
public const byte IM_WrongPass = 7;    
public const byte IM_IsAvailable = 8;  
public const byte IM_Available = 9;    
public const byte IM_Send = 10;        
public const byte IM_Received = 11;    

Put them in the client and server.

Say hello

For courtesy reasons, the client and server should greet themselves…
The server will do
that first, then the client.

Server

First, write IM_HELLO.

bw.Write(IM_Hello);
bw.Flush();  

Second, receive hello.

int hello = br.ReadInt32();

Third, check if hello is OK.

if (hello == IM_Hello)
{
    
}

Client

First, receive hello.

int hello = br.ReadInt32();

Second, check if hello is OK.

if (hello == IM_Hello)
{
    
}

Third, write IM_HELLO.

bw.Write(IM_Hello);
bw.Flush();  

Events

Now we have to write our own event args, handlers, and define events
for the IMClient class. First,
an enumeration with errors. It will be used for error events (e.g., login failed).

public enum IMError : byte
{
    TooUserName = IMClient.IM_TooUsername,
    TooPassword = IMClient.IM_TooPassword,
    Exists = IMClient.IM_Exists,
    NoExists = IMClient.IM_NoExists,
    WrongPassword = IMClient.IM_WrongPass
}

It is created from packet types. It will contain all errors. Now event args for
error events.

public class IMErrorEventArgs : EventArgs
{
    IMError err;

    public IMErrorEventArgs(IMError error)
    {
        this.err = error;
    }

    public IMError Error
    {
        get { return err; }
    }
}

And  custom event handler:

public delegate void IMErrorEventHandler(object sender, IMErrorEventArgs e);

We can define other event args for future use now.

public class IMAvailEventArgs : EventArgs
{
    string user;
    bool avail;

    public IMAvailEventArgs(string user, bool avail)
    {
        this.user = user;
        this.avail = avail;
    }

    public string UserName
    {
        get { return user; }
    }
    public bool IsAvailable
    {
        get { return avail; }
    }
}
public class IMReceivedEventArgs : EventArgs
{
    string user;
    string msg;

    public IMReceivedEventArgs(string user, string msg)
    {
        this.user = user;
        this.msg = msg;
    }

    public string From
    {
        get { return user; }
    }
    public string Message
    {
        get { return msg; }
    }
}

Corresponding handlers:

public delegate void IMAvailEventHandler(object sender, IMAvailEventArgs e);
public delegate void IMReceivedEventHandler(object sender, IMReceivedEventArgs e);

Now events for the client class.

public event EventHandler LoginOK;
public event EventHandler RegisterOK;
public event IMErrorEventHandler LoginFailed;
public event IMErrorEventHandler RegisterFailed;
public event EventHandler Disconnected;
public event IMAvailEventHandler UserAvailable;
public event IMReceivedEventHandler MessageReceived;

Then you have only to write helpers for raising events.

Register and login

We are connected. Now it’s time to login or register.

Client

First we have to send the packet type. This will be the register or login. We will use
the previously defined variable reg (in section Preparing).

bw.Write(reg ? IM_Register : IM_Login);

Then the username and password. We are using only the username and password for register
(and in order to login too).

bw.Write(UserName);
bw.Write(Password);
bw.Flush();  

The server will process this and then answer. Let’s read the packet
type. Then check if login is OK and raise events.

if (ans == IM_OK)  
{
    if (reg)
        OnRegisterOK();  
    OnLoginOK();         
}
else  
{
    IMErrorEventArgs err = new IMErrorEventArgs((IMError)ans);
    if (reg)
        OnRegisterFailed(err);
    else
        OnLoginFailed(err);
}

Server

The server has to store usernames and passwords.

public class UserInfo
{
    public string UserName;
    public string Password;
    public bool LoggedIn;      
    public Client Connection;  
        
    public UserInfo(string user, string pass)
    {
        this.UserName = user;
        this.Password = pass;
        this.LoggedIn = false;
    }
    public UserInfo(string user, string pass, Client conn)
    {
        this.UserName = user;
        this.Password = pass;
        this.LoggedIn = true;
        this.Connection = conn;
    }
}

This simple class will contain information about the user. If the user is connected then
it will contain the Client class too.

In Program there will be a dictionary of users (key — username, value — information).

In Client class define a variable for storing the
associated UserInfo
(after login).

UserInfo userInfo;

Now it’s time to handle user login. Read information from stream.

byte logMode = br.ReadByte();
string userName = br.ReadString();
string password = br.ReadString();

Let’s check
if the values are not too long and answer if they are incorrect.

if (userName.Length < 10)
{
    if (password.Length < 20)
    {
    }
    else
        bw.Write(IM_TooPassword);  
}
else
    bw.Write(IM_TooUsername);  

If they are correct, check which mode is
selected. If we are registering we have to check whether username is free, and if we are
logging in we must check if the account is existing and if the password is correct.

if (logMode == IM_Register)  
{
    if (!prog.users.ContainsKey(userName))
    {
        
    }
    else
        bw.Write(IM_Exists);  
}
else if (logMode == IM_Login)  
{
    
    if (prog.users.TryGetValue(userName, out userInfo))
    {
        if (password == userInfo.Password)
        {
            
        }
        else
            bw.Write(IM_WrongPass);  
    }
    else
        bw.Write(IM_NoExists);  
}

If register is OK, create and add UserInfo.
If login is OK, get UserInfo and associate current connection with this. At the
end, tell client OK.

userInfo = new UserInfo(userName, password, this);
prog.users.Add(userName, userInfo);
bw.Write(IM_OK);
bw.Flush();

if (userInfo.LoggedIn)
    userInfo.Connection.CloseConn();


userInfo.Connection = this;
bw.Write(IM_OK);
bw.Flush();

Packets receiving loop

When we are logged in, we have to listen to incoming packets in loop.
Define the method Receiver in the server and client.

void Receiver()  
{
    _logged = true;

    try
    {
        while (client.Connected)  
        {
            byte type = br.ReadByte();  
        }
    }
    catch (IOException) { } 

    _logged = false;
}

At server logged will be replaced with userInfo.LoggedIn.
Call this method after successful login or register.

Save user information

Information about users are stored in a collection. If server closes then data
would be lost.

It can be simply done using serialization. It’s not recommended if there are
millions of users (then use database), but for a simple messenger, we can use
serialization.

As yet, we have to save only usernames and passwords. We have to add some
attributes in the UserInfo class.

[Serializable] public class UserInfo
[NonSerialized] public bool LoggedIn;
[NonSerialized] public Client Connection;

The Serializable attribute makes a class serializable… We don’t want to serialize
connection data, so it has a NonSerialized attribute. Now functions
to save and load users to file. A Dictionary is not serializable, but we
don’t need the keys. Before saving, values from dictionary are converted to
an array.
While loading array with users it is converted to dictionary using LINQ.

string usersFileName = Environment.CurrentDirectory + "\users.dat";
public void SaveUsers()  
{
    try
    {
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = new FileStream(usersFileName, FileMode.Create, FileAccess.Write);
        bf.Serialize(file, users.Values.ToArray());  
        file.Close();
    }
    catch (Exception e)
    {
        Console.WriteLine(e.ToString());
    }
}
public void LoadUsers()  
{
    try
    {
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = new FileStream(usersFileName, FileMode.Open, FileAccess.Read);
        UserInfo[] infos = (UserInfo[])bf.Deserialize(file);      
        file.Close();
        users = infos.ToDictionary((u) => u.UserName, (u) => u);  
    }
    catch { }
}

Now before the server starts, put this line in order to load users:

LoadUsers();

As yet, we are changing user info only, while registering. After adding
a new user
to collection save users.

prog.SaveUsers();

Check other users availability

Before we implement sending messages we have to know
if recipient is online.

Server

First, check type (obtained in the loop).

if (type == IM_IsAvailable)
{
}

Second, who has to be checked?

string who = br.ReadString();

Then begin packet:

bw.Write(IM_IsAvailable);  
bw.Write(who);             

Now we have to check if user
exists and then if user is connected:

if (prog.users.TryGetValue(who, out info))
{
    if (info.LoggedIn)
        bw.Write(true);   
    else
        bw.Write(false);  
}
else
    bw.Write(false);  

It’s writing the last part of the packet, too. Now we only have to flush
the buffer.

bw.Flush();

Client

Client has to send request and then asynchronously receive answer in loop.

public void IsAvailable(string user)
{
    bw.Write(IM_IsAvailable);
    bw.Write(user);
    bw.Flush();
} 

Now receive answer in receiver loop.

if (type == IM_IsAvailable)
{
    string user = br.ReadString();
    bool isAvail = br.ReadBoolean();
}

And invoke event:

OnUserAvail(new IMAvailEventArgs(user, isAvail));

Culmination: Sending and receiving messages

Finally, we have reached the destination of instant messaging.

Client

First, method for sending message.

public void SendMessage(string to, string msg)
{
}

We have to send packet type, recipient name, and message.

bw.Write(IM_Send);
bw.Write(to);
bw.Write(msg);
bw.Flush();

Now receiving.
Check packet type and get additional data.

else if (type == IM_Received)
{
    string from = br.ReadString();
    string msg = br.ReadString();
}

And raise event.

OnMessageReceived(new IMReceivedEventArgs(from, msg));

Server

Server has to receive the send packet and send the receive packet with
message to the recipient.

else if (type == IM_Send)
{
    string to = br.ReadString();
    string msg = br.ReadString();
}

We have all the needed data. Now let’s try to get the user:

UserInfo recipient;
if (prog.users.TryGetValue(to, out recipient))
{
} 

If recipient exists we must check if he
is online.

if (recipient.LoggedIn)
{
}

Using associated connection we can access the BinaryWriter
of recipient and write
the
receive packet.

recipient.Connection.bw.Write(IM_Received);
recipient.Connection.bw.Write(userInfo.UserName);  
recipient.Connection.bw.Write(msg);                
recipient.Connection.bw.Flush();

It’s done! Simple instant messenger protocol is ready to use!

User interface

Server and protocol are ready, but we haven’t
got a user interface. You have to design your Instant Messenger user interface. Then simply connect to
the server using your IMClient and use its functions and events.
You can download the source code and see how it is working.

Conclusion

It’s the end of my sixth article (for the time being…). I tried to explain
step by step how to write a simple instant messenger in C#.NET and I hope I
succeeded.

This member has not yet provided a Biography. Assume it’s interesting and varied, and probably something to do with programming.

  • Download demo project — 17.1 KB
  • Download source — 29.1 KB

Client-server communiaction

Table of contents

  1. Introduction
  2. Background
  3. Preparing
  4. Listen! Incoming connection!
  5. Connection
  6. Packet types
  7. Say hello
  8. Events
  9. Register and login
  10. Packets receiving loop
  11. Save users information
  12. Check other users availability
  13. Culmination: Sending and receiving messages
  14. User interface
  15. Conclusion

Introduction

Did you ever want to write your own instant messenger program like Skype? OK, not so advanced… I will try to explain how to write
a simple instant messenger (IM) in C#.NET.

First, some theory. Our instant messenger will work on a client-server model.

Client-server communiaction

Users have client programs which connect to the server application. Client programs know
the server’s IP or hostname (e.g., example.com).

The most popular internet protocols are TCP and UDP. We will use TCP/IP, because it is reliable and it has established connection. .NET offers TcpClient and
TcpListener classes for this protocol. TCP/IP doesn’t offer encryption. It is possible to create own encryption protocol over TCP, but I
recommend using
SSL (used in HTTPS). It authenticates server (and optionally client) and encrypts
connection.

SSL is using X.509 certificates for authenticating. You can buy real SSL
certificate (trusted) or generate self-signed certificate (untrusted). Untrusted
certificates allow encryption, but authentication isn’t safe. We can use them
for testing. I made batch script, which generates self-signed certificate in PFX package. My
script requires OpenSSL installed in system. I included also one in
server application project.

At the end there is your higher-level protocol, which sends messages to specified
users and does other IM stuff. I will explain my protocol during article.

You can debug your server and client on the same computer: hostname of server
will be localhost or 127.0.0.1 (local IP — same computer).

Background

You should know something about SSL protocol, certificates, and networking.

Preparing

Create two projects: server and client. Server will be a console application, client
— Windows Forms (or WPF). You will need to debug two projects at once, so don’t place
them in one solution.

Server

We will write the main server code in non-static scope. Add these lines
to Main:

Program p = new Program();
Console.WriteLine();
Console.WriteLine("Press enter to close program.");
Console.ReadLine();

It will create a new instance of the Program (class containing Main).
The server code will be in the constructor.

public Program()
{
    Console.Title = "InstantMessenger Server";
    Console.WriteLine("----- InstantMessenger Server -----");
}

Client

Add class IMClient — it will process and send all packets of our protocol.
Add basic variables:

Thread tcpThread;      
bool _conn = false;    
bool _logged = false;  
string _user;          
string _pass;          
bool reg;              

And some properties:

public string Server { get { return "localhost"; } }
public int Port { get { return 2000; } }

public bool IsLoggedIn { get { return _logged; } }
public string UserName { get { return _user; } }
public string Password { get { return _pass; } }

Server is the name or IP of the computer where the server software is running. We will test
the IM on one computer so the address of the server is localhost. Port is
the TCP port of the server. For example, HTTP default port is 80. These methods will be
used to connect and disconnect:

void SetupConn()  
{
}
void CloseConn() 
{
}

Finally public methods for login, register, and disconnect.

void connect(string user, string password, bool register)
{
    if (!_conn)
    {
        _conn = true;
        _user = user;
        _pass = password;
        reg = register;

        
        tcpThread = new Thread(new ThreadStart(SetupConn));
        tcpThread.Start();
    }
}
public void Login(string user, string password)
{
    connect(user, password, false);
}
public void Register(string user, string password)
{
    connect(user, password, true);
}
public void Disconnect()
{
    if (_conn)
        CloseConn();
}

Listen! Incoming connection!

The server will listening to incoming connections. Some variables at the start:

public IPAddress ip = IPAddress.Parse("127.0.0.1");
public int port = 2000;
public bool running = true;
public TcpListener server;  

These lines in the constructor will create and start the server:

server = new TcpListener(ip, port);
server.Start();

The server is started. Now listen to incoming connections:

void Listen()  
{
    while (running)
    {
        TcpClient tcpClient = server.AcceptTcpClient();
    }
}

AcceptTcpClient waits for incoming connections and then it returns it as
a TcpClient. Now we have to handle
the client. Create
a class for this and name it Client. Then add a constructor and these variables:

public Client(Program p, TcpClient c)
{
    prog = p;
    client = c;
}

Program prog;
public TcpClient client;

In the method for listening, we are passing tcpClient and Program instances to
the Client
class:

Client client = new Client(this, tcpClient);

In the class for handling the client, add these functions (again):

void SetupConn()  
{
}
void CloseConn() 
{
}

And finally in the constructor (of the Client class), run code for preparing
connection in another thread.

(new Thread(new ThreadStart(SetupConn))).Start();

It’s time to setup a connection.

Connection

We have to establish a connection. The following code will be placed in
the server’s
Client class and in the client’s IMClient. We need the following variables:

public TcpClient client;
public NetworkStream netStream;  
public SslStream ssl;            
public BinaryReader br;          
public BinaryWriter bw;          

Connect to the server (only at client):

client = new TcpClient(Server, Port);

Now let’s get a stream of the connection. We can read and write raw data using this
stream.

netStream = client.GetStream();

OK, we can read and write, but it isn’t encrypted. Let’s add SSL.

ssl = new SslStream(netStream, false);
ssl = new SslStream(netStream, false,
    new RemoteCertificateValidationCallback(ValidateCert));

When the server is authenticating, the client has to confirm the certificate. SslStream checks
the certificate and then passes the results to RemoteCertificateValidationCallback.
We need to confirm the certificate in the callback. Add this function (it is passed in
the
SslStream constructor):

public static bool ValidateCert(object sender, X509Certificate certificate, 
              X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
    return true; 
}

For testing we are using an untrusted certificate, so we are ignoring policy errors
and accepting all certificates.
SSL needs a certificate. You have to generate one (I made a script for this) or
you can use the certificate included in the source code. Let’s load this in
the Program class.

public X509Certificate2 cert = new X509Certificate2("server.pfx", "instant");

The second parameter is the password of the certificate. My batch script is
automatically setting the password to «instant«. Now
authenticate the server and client:

ssl.AuthenticateAsServer(prog.cert, false, SslProtocols.Tls, true);
ssl.AuthenticateAsClient("InstantMessengerServer");

Now the connection is authenticated and encrypted. We need some reader and
writer for simple data, such as integers, strings, etc.

br = new BinaryReader(ssl, Encoding.UTF8);
bw = new BinaryWriter(ssl, Encoding.UTF8);

There remains closing the connection.

void CloseConn()
{
    br.Close();
    bw.Close();
    ssl.Close();
    netStream.Close();
    client.Close();
}
CloseConn(); 

We are ready to communicate.

Packet types

In this instant messenger packets start from byte type and then there are other data
written with BinaryWriter. These
are the  packet types used in my protocol (as yet):

public const int IM_Hello = 2012;      
public const byte IM_OK = 0;           
public const byte IM_Login = 1;        
public const byte IM_Register = 2;     
public const byte IM_TooUsername = 3;  
public const byte IM_TooPassword = 4;  
public const byte IM_Exists = 5;       
public const byte IM_NoExists = 6;     
public const byte IM_WrongPass = 7;    
public const byte IM_IsAvailable = 8;  
public const byte IM_Available = 9;    
public const byte IM_Send = 10;        
public const byte IM_Received = 11;    

Put them in the client and server.

Say hello

For courtesy reasons, the client and server should greet themselves…
The server will do
that first, then the client.

Server

First, write IM_HELLO.

bw.Write(IM_Hello);
bw.Flush();  

Second, receive hello.

int hello = br.ReadInt32();

Third, check if hello is OK.

if (hello == IM_Hello)
{
    
}

Client

First, receive hello.

int hello = br.ReadInt32();

Second, check if hello is OK.

if (hello == IM_Hello)
{
    
}

Third, write IM_HELLO.

bw.Write(IM_Hello);
bw.Flush();  

Events

Now we have to write our own event args, handlers, and define events
for the IMClient class. First,
an enumeration with errors. It will be used for error events (e.g., login failed).

public enum IMError : byte
{
    TooUserName = IMClient.IM_TooUsername,
    TooPassword = IMClient.IM_TooPassword,
    Exists = IMClient.IM_Exists,
    NoExists = IMClient.IM_NoExists,
    WrongPassword = IMClient.IM_WrongPass
}

It is created from packet types. It will contain all errors. Now event args for
error events.

public class IMErrorEventArgs : EventArgs
{
    IMError err;

    public IMErrorEventArgs(IMError error)
    {
        this.err = error;
    }

    public IMError Error
    {
        get { return err; }
    }
}

And  custom event handler:

public delegate void IMErrorEventHandler(object sender, IMErrorEventArgs e);

We can define other event args for future use now.

public class IMAvailEventArgs : EventArgs
{
    string user;
    bool avail;

    public IMAvailEventArgs(string user, bool avail)
    {
        this.user = user;
        this.avail = avail;
    }

    public string UserName
    {
        get { return user; }
    }
    public bool IsAvailable
    {
        get { return avail; }
    }
}
public class IMReceivedEventArgs : EventArgs
{
    string user;
    string msg;

    public IMReceivedEventArgs(string user, string msg)
    {
        this.user = user;
        this.msg = msg;
    }

    public string From
    {
        get { return user; }
    }
    public string Message
    {
        get { return msg; }
    }
}

Corresponding handlers:

public delegate void IMAvailEventHandler(object sender, IMAvailEventArgs e);
public delegate void IMReceivedEventHandler(object sender, IMReceivedEventArgs e);

Now events for the client class.

public event EventHandler LoginOK;
public event EventHandler RegisterOK;
public event IMErrorEventHandler LoginFailed;
public event IMErrorEventHandler RegisterFailed;
public event EventHandler Disconnected;
public event IMAvailEventHandler UserAvailable;
public event IMReceivedEventHandler MessageReceived;

Then you have only to write helpers for raising events.

Register and login

We are connected. Now it’s time to login or register.

Client

First we have to send the packet type. This will be the register or login. We will use
the previously defined variable reg (in section Preparing).

bw.Write(reg ? IM_Register : IM_Login);

Then the username and password. We are using only the username and password for register
(and in order to login too).

bw.Write(UserName);
bw.Write(Password);
bw.Flush();  

The server will process this and then answer. Let’s read the packet
type. Then check if login is OK and raise events.

if (ans == IM_OK)  
{
    if (reg)
        OnRegisterOK();  
    OnLoginOK();         
}
else  
{
    IMErrorEventArgs err = new IMErrorEventArgs((IMError)ans);
    if (reg)
        OnRegisterFailed(err);
    else
        OnLoginFailed(err);
}

Server

The server has to store usernames and passwords.

public class UserInfo
{
    public string UserName;
    public string Password;
    public bool LoggedIn;      
    public Client Connection;  
        
    public UserInfo(string user, string pass)
    {
        this.UserName = user;
        this.Password = pass;
        this.LoggedIn = false;
    }
    public UserInfo(string user, string pass, Client conn)
    {
        this.UserName = user;
        this.Password = pass;
        this.LoggedIn = true;
        this.Connection = conn;
    }
}

This simple class will contain information about the user. If the user is connected then
it will contain the Client class too.

In Program there will be a dictionary of users (key — username, value — information).

In Client class define a variable for storing the
associated UserInfo
(after login).

UserInfo userInfo;

Now it’s time to handle user login. Read information from stream.

byte logMode = br.ReadByte();
string userName = br.ReadString();
string password = br.ReadString();

Let’s check
if the values are not too long and answer if they are incorrect.

if (userName.Length < 10)
{
    if (password.Length < 20)
    {
    }
    else
        bw.Write(IM_TooPassword);  
}
else
    bw.Write(IM_TooUsername);  

If they are correct, check which mode is
selected. If we are registering we have to check whether username is free, and if we are
logging in we must check if the account is existing and if the password is correct.

if (logMode == IM_Register)  
{
    if (!prog.users.ContainsKey(userName))
    {
        
    }
    else
        bw.Write(IM_Exists);  
}
else if (logMode == IM_Login)  
{
    
    if (prog.users.TryGetValue(userName, out userInfo))
    {
        if (password == userInfo.Password)
        {
            
        }
        else
            bw.Write(IM_WrongPass);  
    }
    else
        bw.Write(IM_NoExists);  
}

If register is OK, create and add UserInfo.
If login is OK, get UserInfo and associate current connection with this. At the
end, tell client OK.

userInfo = new UserInfo(userName, password, this);
prog.users.Add(userName, userInfo);
bw.Write(IM_OK);
bw.Flush();

if (userInfo.LoggedIn)
    userInfo.Connection.CloseConn();


userInfo.Connection = this;
bw.Write(IM_OK);
bw.Flush();

Packets receiving loop

When we are logged in, we have to listen to incoming packets in loop.
Define the method Receiver in the server and client.

void Receiver()  
{
    _logged = true;

    try
    {
        while (client.Connected)  
        {
            byte type = br.ReadByte();  
        }
    }
    catch (IOException) { } 

    _logged = false;
}

At server logged will be replaced with userInfo.LoggedIn.
Call this method after successful login or register.

Save user information

Information about users are stored in a collection. If server closes then data
would be lost.

It can be simply done using serialization. It’s not recommended if there are
millions of users (then use database), but for a simple messenger, we can use
serialization.

As yet, we have to save only usernames and passwords. We have to add some
attributes in the UserInfo class.

[Serializable] public class UserInfo
[NonSerialized] public bool LoggedIn;
[NonSerialized] public Client Connection;

The Serializable attribute makes a class serializable… We don’t want to serialize
connection data, so it has a NonSerialized attribute. Now functions
to save and load users to file. A Dictionary is not serializable, but we
don’t need the keys. Before saving, values from dictionary are converted to
an array.
While loading array with users it is converted to dictionary using LINQ.

string usersFileName = Environment.CurrentDirectory + "\users.dat";
public void SaveUsers()  
{
    try
    {
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = new FileStream(usersFileName, FileMode.Create, FileAccess.Write);
        bf.Serialize(file, users.Values.ToArray());  
        file.Close();
    }
    catch (Exception e)
    {
        Console.WriteLine(e.ToString());
    }
}
public void LoadUsers()  
{
    try
    {
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = new FileStream(usersFileName, FileMode.Open, FileAccess.Read);
        UserInfo[] infos = (UserInfo[])bf.Deserialize(file);      
        file.Close();
        users = infos.ToDictionary((u) => u.UserName, (u) => u);  
    }
    catch { }
}

Now before the server starts, put this line in order to load users:

LoadUsers();

As yet, we are changing user info only, while registering. After adding
a new user
to collection save users.

prog.SaveUsers();

Check other users availability

Before we implement sending messages we have to know
if recipient is online.

Server

First, check type (obtained in the loop).

if (type == IM_IsAvailable)
{
}

Second, who has to be checked?

string who = br.ReadString();

Then begin packet:

bw.Write(IM_IsAvailable);  
bw.Write(who);             

Now we have to check if user
exists and then if user is connected:

if (prog.users.TryGetValue(who, out info))
{
    if (info.LoggedIn)
        bw.Write(true);   
    else
        bw.Write(false);  
}
else
    bw.Write(false);  

It’s writing the last part of the packet, too. Now we only have to flush
the buffer.

bw.Flush();

Client

Client has to send request and then asynchronously receive answer in loop.

public void IsAvailable(string user)
{
    bw.Write(IM_IsAvailable);
    bw.Write(user);
    bw.Flush();
} 

Now receive answer in receiver loop.

if (type == IM_IsAvailable)
{
    string user = br.ReadString();
    bool isAvail = br.ReadBoolean();
}

And invoke event:

OnUserAvail(new IMAvailEventArgs(user, isAvail));

Culmination: Sending and receiving messages

Finally, we have reached the destination of instant messaging.

Client

First, method for sending message.

public void SendMessage(string to, string msg)
{
}

We have to send packet type, recipient name, and message.

bw.Write(IM_Send);
bw.Write(to);
bw.Write(msg);
bw.Flush();

Now receiving.
Check packet type and get additional data.

else if (type == IM_Received)
{
    string from = br.ReadString();
    string msg = br.ReadString();
}

And raise event.

OnMessageReceived(new IMReceivedEventArgs(from, msg));

Server

Server has to receive the send packet and send the receive packet with
message to the recipient.

else if (type == IM_Send)
{
    string to = br.ReadString();
    string msg = br.ReadString();
}

We have all the needed data. Now let’s try to get the user:

UserInfo recipient;
if (prog.users.TryGetValue(to, out recipient))
{
} 

If recipient exists we must check if he
is online.

if (recipient.LoggedIn)
{
}

Using associated connection we can access the BinaryWriter
of recipient and write
the
receive packet.

recipient.Connection.bw.Write(IM_Received);
recipient.Connection.bw.Write(userInfo.UserName);  
recipient.Connection.bw.Write(msg);                
recipient.Connection.bw.Flush();

It’s done! Simple instant messenger protocol is ready to use!

User interface

Server and protocol are ready, but we haven’t
got a user interface. You have to design your Instant Messenger user interface. Then simply connect to
the server using your IMClient and use its functions and events.
You can download the source code and see how it is working.

Conclusion

It’s the end of my sixth article (for the time being…). I tried to explain
step by step how to write a simple instant messenger in C#.NET and I hope I
succeeded.

This member has not yet provided a Biography. Assume it’s interesting and varied, and probably something to do with programming.

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