Часть 3: Добавление базы данных


Полный код этой части находится в ветке part-3, расположенной здесь

В предыдущих частях мы настроили папки нашего проекта и соединили все вместе. В этой части мы сосредоточимся на пакете server, добавив базу данных для сохранения данных при перезапуске или обновлении сервера.

Какую базу данных использовать?

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

В конечном итоге я хочу иметь возможность разместить весь проект (сервер/портал/виджет) на одной виртуальной машине, не беспокоясь о внешних соединениях, базах данных и тому подобном. Исходя из этого, я рассматривал что-то вроде базы данных в памяти с сохранением в локальном файле, который будет загружаться обратно при перезагрузке/обновлении.

Я хотел что-то производительное, чтобы (надеюсь) не столкнуться с проблемами при одновременном подключении около 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 });
      },
    });
  });
}
Войти в полноэкранный режим Выход из полноэкранного режима

Краткое описание происходящего:

  1. Lokijs использует так называемые адаптеры для обработки персистентности файлов. Мы используем самый быстрый и масштабируемый адаптер под названием fs-structured-adapter. Подробнее о нем вы можете прочитать здесь
  2. Мы экспортируем функцию initDB, которая настроит базу данных и вернет обещание, разрешив его по завершении.
  3. Внутри настройки мы предоставляем некоторые начальные данные для нашей базы данных, мы каждый раз пополняем админов из нашего начального файла. Также мы проверяем, существует ли коллекция для наших клиентов, и если нет, то создаем ее. Коллекции — это логически разделенные части базы данных, которые также сохраняются в собственном файле.
  4. Для обеих коллекций мы используем параметр 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);
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Разбиение:

  1. По умолчанию socket.io создаст идентификатор пользователя, сохранит его в socket.id и присоединится к комнате с этим конкретным идентификатором. Теперь нам нужно присоединиться к комнате socket.cliendID, поскольку мы определяем наш clientID вручную.
  2. Мы передаем clientID клиенту, чтобы он мог сохранить его в localStorage и передать при повторном подключении.
  3. Мы проверяем, существует ли клиент, и если нет, то создаем и вставляем его в базу данных.
  4. Если клиент уже есть в базе данных, мы отправляем историю сообщений клиенту.

В том же файле мы также должны обновить наш слушатель события 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;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Завершение

Вот и все на этом! В следующей части я добавлю страницу входа на портал и сгенерирую токен для защиты соединения портала с сервером. Увидимся в следующий раз! 🚀

Оцените статью
Procodings.ru
Добавить комментарий