Полный код этой части находится в ветке
part-3
, расположенной здесь
В предыдущих частях мы настроили папки нашего проекта и соединили все вместе. В этой части мы сосредоточимся на пакете server
, добавив базу данных для сохранения данных при перезапуске или обновлении сервера.
- Какую базу данных использовать?
- Не согласны со мной?
- Давайте кодить!
- Установка lokijs
- Инициализация базы данных
- Обновление packages/server/index.ts
- Обработчики обновлений
- Сохранение клиентской сессии
- Генерируем случайный идентификатор для клиентов на сервере
- Использование нового clientID в clientHandler
- Сохранение идентификатора клиента на стороне виджета
- Обработка повторных подключений на стороне портала
- Исправление небольшой ошибки в портале
- Завершение
Какую базу данных использовать?
Я неоднократно возвращался к этому вопросу, так как используемая технология должна соответствовать вашим целям проекта. В первую очередь я хотел что-то простое, легко развертываемое и не требующее дополнительных настроек для разработки.
В конечном итоге я хочу иметь возможность разместить весь проект (сервер/портал/виджет) на одной виртуальной машине, не беспокоясь о внешних соединениях, базах данных и тому подобном. Исходя из этого, я рассматривал что-то вроде базы данных в памяти с сохранением в локальном файле, который будет загружаться обратно при перезагрузке/обновлении.
Я хотел что-то производительное, чтобы (надеюсь) не столкнуться с проблемами при одновременном подключении около 100 клиентов. Я рассматривал low-db
некоторое время, но мне не понравилось, что он JSON.stringify
всю мою базу данных при каждом изменении, что может стать проблемой, когда она станет слишком большой.
Поэтому я выбрал lokijs
, несмотря на то, что я нахожу документацию по нему довольно ужасной, я использовал его раньше, и он работает без проблем и имеет много возможностей. И я подумал, что было бы неплохо показать, как я его использую, чтобы другим не пришлось разбираться самим 😇.
Не согласны со мной?
Это, конечно, прекрасно! В конце концов, не имеет значения, какую технологию баз данных вы используете, не стесняйтесь реализовать это с помощью MongoDB или Firebase, как вам удобнее. Единственное, что вам нужно будет изменить, это инициализацию базы данных и то, как сохранять/обновлять/извлекать что-то оттуда.
Давайте кодить!
Изменения в этом разделе обобщены в этом коммите
Чтобы сохранить разделение, я помещу все связанные с базой данных вещи в папку /packages/server/database
. Поскольку файл /packages/server/admins.ts
, который мы используем для загрузки в нашу базу данных, логически принадлежит этой папке, я переместил его туда, изменив верхнюю строку на: import { Admin } from './../types';
.
Установка lokijs
Для установки lokijs выполните следующие команды:
yarn workspace server add lokijs
yarn workspace server add -D @types/lokijs
Инициализация базы данных
Я создаю файл packages/server/database/database.ts
со следующими параметрами:
import { join } from 'path';
import adminSeed from './admins';
import loki from 'lokijs';
import { Admin, Client, Database } from '../types';
const lsfa = require('lokijs/src/loki-fs-structured-adapter');
export default function initDB() {
return new Promise<Database>((resolve) => {
const adapter = new lsfa();
const db = new loki(join(__dirname, './server.db'), {
adapter,
autoload: true,
autosave: true,
autosaveInterval: 4000,
autoloadCallback: () => {
db.removeCollection('admins');
const admins = db.addCollection<Admin>('admins', {
autoupdate: true,
});
adminSeed.forEach((admin) => {
admins.insertOne(admin);
});
let clients = db.getCollection<Client>('clients');
if (clients === null) {
clients = db.addCollection<Client>('clients', {
autoupdate: true,
indices: ['id'],
});
}
resolve({ admins, clients });
},
});
});
}
Краткое описание происходящего:
- Lokijs использует так называемые
адаптеры
для обработки персистентности файлов. Мы используем самый быстрый и масштабируемый адаптер под названиемfs-structured-adapter
. Подробнее о нем вы можете прочитать здесь - Мы экспортируем функцию
initDB
, которая настроит базу данных и вернет обещание, разрешив его по завершении. - Внутри настройки мы предоставляем некоторые начальные данные для нашей базы данных, мы каждый раз пополняем админов из нашего начального файла. Также мы проверяем, существует ли коллекция для наших клиентов, и если нет, то создаем ее. Коллекции — это логически разделенные части базы данных, которые также сохраняются в собственном файле.
- Для обеих коллекций мы используем параметр
autoupdate
, который будет автоматически сохранять изменения, внесенные в коллекцию. По умолчанию вам придется вызывать.update()
вручную, чтобы убедиться, что данные в памяти также сохраняются в файл.
Внутри нашего файла .gitignore
мы должны добавить /packages/server/database/*.db*
, чтобы убедиться, что созданные нами файлы базы данных игнорируются git.
Обновление packages/server/index.ts
Теперь нам нужно использовать только что созданную функцию initDB
внутри нашего основного файла. Сначала удалите текущую инициализацию database
:
И добавьте import initDB from './database/database';
где-то вверху.
Замените вызов server.listen
на:
let db: Database;
(async function () {
try {
db = await initDB();
server.listen(5000, () => {
console.log(
`Server started on port ${5000} at ${new Date().toLocaleString()}`
);
});
} catch (err) {
console.log('Server failed to start.');
console.error(err);
}
})();
Это наша новая функция initialize, которая будет запускать сервер после установки базы данных.
В этот момент typescript, вероятно, жалуется, что тип Database
больше не является правильным. Давайте изменим packages/server/types.ts
:
- добавим
import { Collection } from 'lokijs';
в верхней части - обновим интерфейс:
export interface Database {
clients: Collection<Client>;
admins: Collection<Admin>;
}
Обработчики обновлений
Наш код в packages/server/handlers
по-прежнему ожидает обычный объект в качестве базы данных, нам нужно обновить некоторый код внутри adminHandler
и clientHandler
, чтобы правильно использовать нашу новую базу данных:
- Вместо
.find((admin) => admin.name === name)
мы теперь можем использовать.findOne({name})
. - Когда мы хотим отправить все элементы коллекции, мы должны
db.clients.find()
вместо простоdb.clients
. - При добавлении нового клиента мы используем
.insert
вместо.push
.
При добавлении нового сообщения в массив messages
клиентов есть одна загвоздка. Поскольку lokijs
использует Object.observe
на всем клиенте, чтобы определить, нужно ли что-то обновить. Это не работает для мутаций массива (обычное предостережение реактивности Vue2, которое не раз меня подводило😅). Поэтому всякий раз, когда мы добавляем сообщение, мы должны обновлять его вручную, добавляя db.clients.update(client);
после этого.
Сохранение клиентской сессии
Коммит найден здесь
Когда клиент подключается сейчас, он будет генерировать новое случайное имя, и когда этот клиент обновит окно браузера, он создаст нового клиента. Это, конечно, не совсем осуществимо, нам нужно где-то хранить сессию клиента, и если тот же клиент снова подключится, мы восстановим эту сессию.
Генерируем случайный идентификатор для клиентов на сервере
Внутри packages/server/index.ts
мы добавляем следующее
// Socket middleware to set a clientID
const randomId = () => crypto.randomBytes(8).toString('hex');
io.use((socket, next) => {
const clientID = socket.handshake.auth.clientID;
if (clientID) {
const client = db.clients.findOne({ id: clientID });
if (client) {
socket.clientID = clientID;
return next();
}
}
socket.clientID = randomId();
next();
});
и добавляем import crypto from 'crypto';
в самом верху.
Это часть промежуточного программного обеспечения, которое будет выполняться для каждого клиента, подключающегося к нашему серверу. Он будет проверять объект auth
на рукопожатии, которое сервер сокетов выполняет с клиентом, если там присутствует идентификатор клиента, мы устанавливаем этот идентификатор в объект сокета. Если нет, то это новый клиент, и мы генерируем новый случайный ID.
Поскольку мы используем typescript и устанавливаем свойство clientID
на объект сокета, о котором он не знает, мы должны добавить его к типу socket
.
Для этого мы добавляем в packages/server/types.ts
:
declare module 'socket.io' {
interface Socket {
clientID: string;
}
}
Использование нового clientID в clientHandler
Внутри packages/server/handlers/clientHandler.ts
мы сейчас определяем клиента и добавляем его в базу данных. Здесь мы должны проверить, существует ли уже наш клиент, и только в случае необходимости добавить новую запись в базу данных.
Удалить:
И добавить:
socket.join(socket.clientID);
socket.emit('client:id', socket.clientID);
let client: Client;
const DBClient = db.clients.findOne({ id: socket.clientID });
if (DBClient) {
client = DBClient;
client.connected = true;
socket.emit('client:messages', client.messages);
} else {
client = {
...data,
messages: [],
id: socket.clientID,
connected: true,
};
db.clients.insert(client);
}
Разбиение:
- По умолчанию socket.io создаст идентификатор пользователя, сохранит его в
socket.id
и присоединится к комнате с этим конкретным идентификатором. Теперь нам нужно присоединиться к комнатеsocket.cliendID
, поскольку мы определяем наш clientID вручную. - Мы передаем clientID клиенту, чтобы он мог сохранить его в localStorage и передать при повторном подключении.
- Мы проверяем, существует ли клиент, и если нет, то создаем и вставляем его в базу данных.
- Если клиент уже есть в базе данных, мы отправляем историю сообщений клиенту.
В том же файле мы также должны обновить наш слушатель события disconnect
, поскольку мы должны изменить логику, которая определяет, подключен ли клиент. Теоретически мы можем иметь одного клиента, открывающего несколько вкладок, каждая из которых будет устанавливать свое собственное сокетное соединение. Если одна из них закрывается, нам нужно проверить, не осталось ли открытых соединений для этого клиента, прежде чем мы обновим статус соединения.
Измените обработчик socket.on('disconnect')
на:
socket.on('disconnect', async () => {
const matchingSockets = await io.in(socket.clientID).allSockets();
const isDisconnected = matchingSockets.size === 0;
if (isDisconnected) {
client.connected = false;
io.to('admins').emit('admin:client_status', {
id: client.id,
status: false,
});
}
});
Сохранение идентификатора клиента на стороне виджета
В виджете мы будем сохранять id и имя клиента в хранилище сокетов и генерировать новое имя, если подключение происходит впервые.
Внутри packages/widget/src/stores/socket.ts
мы добавим в наш state:
id: localStorage.getItem('clientID'),
name: localStorage.getItem('clientName') || '',
и к нашим действиям:
SOCKET_messages(payload: Message[]) {
this.messages = payload;
},
SOCKET_id(payload: string) {
localStorage.setItem('clientID', payload);
this.id = payload;
},
setName() {
const name = faker.name.firstName();
this.name = name;
localStorage.setItem('clientName', name);
},
Также добавьте import faker from '@faker-js/faker/locale/en';
в начало файла, и удалите его из packages/widget/src/App.vue
;
Теперь мы должны использовать имя и id из магазина при подключении к сокет-серверу, измените const socket = io(URL);
на:
const socket = io(URL, {
auth: {
clientID: socketStore.id,
},
});
watch(
() => socketStore.id,
(val) => {
socket.auth = {
clientID: val,
};
}
);
if (!socketStore.name) {
socketStore.setName();
}
Наблюдатель здесь нужен, когда по какой-то причине сервер отключается (например, для перезагрузки) и соединение с сокетом сбрасывается. В этом случае сокет переподключится с правильным clientID.
В объекте addClient
измените name
на name: socketStore.name
и добавьте watch
в список импорта из ‘vue’.
Обработка повторных подключений на стороне портала
Коммит найден здесь
Последнее, о чем мы позаботимся в этой части, это обработка повторного подключения портала к серверу, когда сервер перезапускается или иным образом теряет соединение. В настоящее время на портале мы вызываем admin:add
только при загрузке нашего приложения. Если соединение с сокетом теряется и восстанавливается, мы должны снова вызвать admin:add
, чтобы зарегистрировать правильные обработчики на этом сокете.
Внутри packages/portal/src/boot/socket.ts
мы изменим вызов admin:add
на:
// This will be called on the initial connection and also on reconnects
socket.on('connect', () => {
socket.emit('admin:add', 'Evert');
});
Мы должны сделать то же самое внутри нашего виджета в packages/widget/src/App.vue
изменим client:add
на:
// This will be called on the initial connection and also on reconnects
socket.on('connect', () => {
socket.emit('client:add', addClient);
});
Исправление небольшой ошибки в портале
В коде портала есть ошибка, которая возникает при перезагрузке сервера и повторном подключении сокета. Даже если мы повторно вызываем событие admin:add
, если у нас уже есть выбранный клиент, мы не можем отправлять новые сообщения этому выбранному клиенту. Это происходит потому, что при повторном подключении мы повторно отправляем весь список клиентов и в действии SOCKET_list
внутри packages/portal/src/stores/client.ts
мы заменяем массив clients
в состоянии на вновь полученное значение.
Однако если у нас уже был выбран клиент, состояние clientSelected
будет указывать на элемент в старом массиве, который мы переписали. Поэтому, чтобы все работало, мы должны сбросить clientSelected
и там:
if (this.clientSelected) {
const currentSelectedId = this.clientSelected.id;
this.clientSelected =
this.clients.find((client) => client.id === currentSelectedId) ||
null;
}
Завершение
Вот и все на этом! В следующей части я добавлю страницу входа на портал и сгенерирую токен для защиты соединения портала с сервером. Увидимся в следующий раз! 🚀