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