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