Технический директор YuSMP Group, Никита Обухов, ранее уже писал для Хабр о том, как сделать стриминговый сервис. Сегодня он рассказывает о создании чатов.

Во многих мобильных и веб-приложениях требуется создать чаты. Мы говорим не о надоевших всплывашках для общения с оператором: обычно они предоставляются As a service и подключаются одной строчкой кода.

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

Технически любой чат состоит из следующих вещей: UI, транспортный уровень, сервер, хранилище. В качестве транспорта для чатов лучше всего подходят веб-сокеты. Хранилищем может выступать любая база данных, сервер должен обеспечивать права доступа и API для клиентов.

Стандартных решений для чатов множество, одним из наиболее популярных и проверенных временем можно назвать Jabber (XMPP), имеющий множество реализаций. Также популярны кастомные решения на основе Node.js и socket.io, а также Firebase. В этой статье не будет сравнений разных подходов, а пойдет речь о реализации чатов с помощью Firebase realtime database.

Делаем чаты с Firebase realtime database

Firebase realtime database (RDB) — nosql база данных, размещённая на серверах Google  и доступная по сети по протоколу Websocket, есть SDK для практически всех платформ и языков. Стоит отметить, что Firebase недоступен в Китае и ряде других стран, где сервера Google заблокированы фаерволами.

RDB покрывает 3 из 4 компонентов любого чата: транспорт, сервер, хранилище. Фактически фронтенд напрямую соединяется с базой данных и может как читать так и записывать туда документы (сообщения, пользователей, комнаты чатов). Доступ пользователя до документов регулируется Security rules. При соединении с базой данных клиент авторизуется по JWT токену, в котором зашит userId или другая информация о нём, и с помощью выражений security rules, можно описать, имеет ли он доступ до того или иного документа или коллекции. Также security rules используются для индексации документов.

Вот прим��р security rules из опенсорсного проекта Firechat:

{
"rules": {

    ".read": false,

    ".write": false,

    "messages": {

      "$roomId": {

        ".indexOn": ["ts"],

        "$messageId": {

          ".indexOn": "ts"

         ".read": "(auth != null) && ((root.child('rooms').child($roomId).child('authorizedUsers').hasChild(auth.uid)))"

         ".write": "(auth != null) && ((root.child('rooms').child($roomId).child('authorizedUsers').hasChild(auth.uid)))"

        }

      }

    },

}

Смысл этих выражений в том, что пользователь может получить доступ до объекта внутри коллекции messages только в том случае, если uid из его JWT токена есть в списке authorizedUsers соответствующей комнаты чата. Проще говоря, выражения проверяют, находится ли юзер в этом чате. Также здесь описана индексация сообщений по полю ts (timestamp).

Сама схема базы данных в Firebase состоит из следующих коллекций, и, конечно, зависит от бизнес-требований к чату. Примерный вид схемы:

users: {
  $userId: {
    rooms: [], // массив id комнат, в которых участвует пользователь
    userInfo: {} // произвольная информация о пользователе, зависит от ваших потребностей. Может содержать имя, url аватарки и так далее.
  } 
},
rooms: {
  $roomId: {
    id, // совпадает с roomId, сгенерирован Firebase
    type, // тип комнаты, персональная или групповая
    ts, // время создания
    authorizedUsers: [] // массив uid авторизованных пользователей
    moderators: [] // массив uid модераторов чата
 
  }
},
messages: {
  $roomId: { // внутри коллекции с ID=roomId хранятся все её сообщения
    $messageId: {
       id,
       type, // тип сообщения, текстовое, фото, видео
       ts,
       senderId,
       readBy: [], // массив uid пользователей, прочитавших сообщение. Массив, потому что в групповых чатах пользователей в комнате может быть много.
       deliveredTo: [], // массив uid пользователей, получивших сообщение
  }
} 
}

Пример авторизации в RDB с помощью Javascript SDK:

firebase.auth().signInWithCustomToken(this.jwt).catch(function (error) {
   alert('Error: ' + error.message);
});

JWT токен можно сгенерировать на бэкенде вашего приложения и отдавать пользователю. Например, в методе login, вместе с токеном вашего сервера.

Пример генерации JWT-токена с помощью SDK PHP:

use Firebase\JWT\JWT;


$now = time();

$payload = [
   'email' => $user->getEmail(),
   'iss' => $this->clientEmail,
   'sub' => $this->clientEmail,
   'aud' => 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit',
   'iat' => $now,
   'exp' => ($now + (60 * 60)),
   // Maximum expiration time is one hour
   'uid' => (string) $user->getId(),
];

return JWT::encode($payload, $this->privateKey, 'RS256');

clientEmail и privateKey берутся из сервисного файла Firebase.

Примеры интеграции с Firebase на фронтенде

Загрузка списка id комнат пользователя

Для загрузки сообщений из комнаты чатов:

async getUserRoomIds(userId)
{
   const snapshot = await firebase.database()
       .ref().child('users')
       .child(userId.toString())
       .once('value');
   const user = snapshot.val();
   if (user == null) {
       return [];
   }
   return Object.keys(user.rooms);
}

Для записи сообщения в чат мы просто записываем сообщение в таблицу messages новый объект:

async postMessage(roomId, messageStr, user)
{
   const ref = firebase.database()
       .ref()
       .child('messages')
       .child(roomId)
       .push();
   const message = new Message(user, firebase.database.ServerValue.TIMESTAMP, messageStr);
   message.id = ref.key;
   try {
       await ref.set(message.getJson());
   }
   catch (e) {
       alert(e.message);
       throw e;
   }
}

Часто нужно взаимодействовать с чатами на сервере (например, отправлять пуш нотификации при отправке нового сообщения). Используя Admin SDK, можно подписаться на любые новы�� сообщения в базе, минуя security rules. Вот пример подписки на новые сообщения в комнате чата:

async subscribeToRoom(id: string): Promise<void>
{
   const room = await this.getRoom(id);
   const query = this.messages
       .child(id);
   query.on('child_added', this.onChildAdded(id, room), this.onCancel, this);
}

onChildAdded(roomId: string, room: Room): (snapshot: DataSnapshot) => void
{
   return async (snapshot: DataSnapshot): Promise<void> => {
       const message = snapshot.val();
       console.debug('(onAdded) message ' + message.id + ' in room ' + roomId);
    }
}

onCancel(): void
{
   console.debug('Listener was canceled.');
}

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

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