Убедитесь, что ваш бот может масштабироваться вместе с вашими пользователями

Предупреждение

В этой статье речь идет не только о ботах Discord, и она не предназначена для их надлежащего введения. Если вы ищете это или даже как создать своего собственного бота, ознакомьтесь с https://discordjs.guide/ или взгляните на некоторые ответы в этой ветке Quora.

Чтобы полностью разобраться в этой статье, вам потребуется некоторое понимание JavaScript, MongoDB, Mongoose ODM, Node.js, Promises и Async / Await, а также некоторые общие знания программирования для понимания некоторых используемых синтаксисов.

Я также не буду касаться основы настройки MongoDB и Mongoose.
Вот несколько ссылок, если вам нужно узнать больше:

Для начала я подробно расскажу о нескольких проблемах, которые возникли у меня при создании Jeetbot.

Это длинное чтение, поэтому я ставлю tl; dr в конце.

Итак, в чем проблема?

На моем пути к созданию полезного бота Discord для меня и пользователей Discord наступил момент, когда я получал данные из своей базы данных MongoDB слишком много раз. В счастливом, свободном от Интернета типе Интернета все операции чтения / записи мне ничего не стоили бы. Но передача данных туда и обратно по Интернету временами стоит денег, и я хотел правильно масштабировать этого бота, поэтому мне нужно было сохранить некоторые данные локально, внутри бота.

Одна из вещей, за которой должен следить бот, - это вводимые пользователем данные, которые изменяют базу данных. Это означает, что пользователи активно мутируют / изменяют определенные параметры, и это происходит асинхронно с остальными другими серверами Discord, на которых работал бот.

Это не так сложно исправить ...

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

У меня есть обработчик команд, который разделяет сообщение, поступающее от пользователя Discord, и распознает его как команду:

client.on("message", async (msg) => {
  commandHandler(msg);
});

Когда сообщение отправляется боту, он пропускает сообщение через commandHandler, а затем оказывается там, где должна быть эта конкретная команда. Хотя это происходит асинхронно для каждого отправленного сообщения.

Итак, поток команд выглядит так:

index.js → commandHandler → определенная команда.

Когда я изначально настраивал хэш-карту, то есть new Map(), внутри нее были бы следующие данные:

{
  guild.id: guildDataObject
}

Примечание. Я использую «гильдию» и «сервер разногласий» как синонимы.

Но когда я отправил свой Map объект, содержащий всю информацию о гильдии, он вернулся неверно. Данные не вернулись вовремя или застряли в стеке, ожидая выгрузки обещания.

Примечание. В настоящее время ему присвоено имя j.edit вместо j.welcomeEdit в версии Jeetbot 1.1.2.

Например:

  1. Модератор Discord набирает j.welcomeEdit в канале Discord.
  2. Бот распознает команду.
  3. Бот отправляет пользователю приглашение ввести новую информацию для приветственного сообщения.
  4. Бот ожидает нового приветственного сообщения, но другие сообщения и новые команды накапливаются сверху.
  5. Модератор Discord отправляет ответ на изменение приветственного сообщения.
  6. Бот принимает сообщение, но обещание не возвращается вовремя для немедленного использования. Он в очереди, ожидая выполнения других обещаний.

Error: Not Returning The Right Welcome Message.

Чтобы бот действительно использовал новое сообщение, мне пришлось несколько раз позвонить j.welcomeEdit, прежде чем новое приветственное сообщение вернется на свое законное место.

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

У меня было две основные проблемы

  1. Слишком много операций чтения / записи в базу данных каждый раз при отправке сообщения.
  2. Мой бот не мог вернуть локальный кеш должным образом, поэтому он не обновлялся немедленно, когда пользователь обновляет свои данные.

Я интуитивно чувствовал, что мне нужно что-то вроде useState() от ReactJS. Поскольку я не создавал веб-приложение в рамках этого бота, единственное, что пришло в голову, - это попробовать Redux.js.

Redux.js спешит на помощь

Один из моих наставников, который научил меня программировать в Code Chrysalis, однажды сказал мне: Настанет день, когда вам действительно понадобится Redux.js.

Конечно, я использовал вместе ReactJS и Redux.js. Я знаком с хуками и обработкой состояния в приложениях React. Но этот бот работает полностью отдельно от веб-страницы, созданной для него командой Jeetbot. Итак, потратив время на изучение статей и архитектуры того, как приложения React создаются в JavaScript, я почувствовал, что мне, возможно, придется сделать что-то подобное с Redux.js, обрабатывая состояние таким же образом.

Redux отлично подходит для обработки глобального состояния, которое имеет дело с постоянными мутациями и асинхронностью.

Когда я обозначил, как я хочу, чтобы бот работал, поток управления в итоге выглядел так:

Моя запись в Jeetbot начинается с index.js. Он инициализирует хранилище redux и сохранит в нем все существующие данные в MongoDB.

После запуска Jeetbot начинает прослушивать события или команды, отправленные на серверах Discord, где Jeetbot находится на разных серверах. Все события в настоящее время обрабатываются в index.js, и все команды переходят к обработчику команд, который затем переходит к командам.

Серверы Discord → Index.js → CommandHandler → Команды

Когда запускается команда для изменения текущих данных, расположенных в хранилище, обновляется локальное хранилище, как и база данных MongoDB.

Отлично - у нас есть рабочая идея

Но как мне написать код, чтобы это произошло? Нам нужно понять, как работает Redux. Это не похоже на то, что я делал веб-сайт React-Redux, и я не пытался сделать небольшое приложение To-Do, которое требовало состояния для рендеринга и отображения на экране.

После долгих мучений и пота я нашел решение с помощью Redux Toolkit - официального, самоуверенного набора инструментов с включенными батареями для эффективной разработки Redux.

Если вы знаете карты Pokemon, это, по сути, предустановленный бустер с картами, которые, как вы знаете, будут полезны, если вы будете использовать их правильно.

Общий обзор

Логика Redux

guildsSlice.js — Здесь мы обрабатываем всю логику мутаций. Отсюда я создаю несколько общих селекторов и редукторов для импорта в остальную часть программы. Вы скоро увидите, как я их использую.

Я следую методике уток, которую предлагает Redux. (По сути, вся логика сокращения для одной функции помещается в один файл).

store.js - здесь мы настраиваем наш магазин. Он будет включать наши редукторы и любое связующее ПО, которое мы собираемся использовать.

guildsSlice.js

Redux Toolkit поставляется с изящными функциями, такими как createEntityAdapter и createSlice. Обе функции упрощают создание правильной логики Redux. Здесь нам нужны обе функции, поэтому я буду говорить о них в унисон.

По сути, createEntityAdapter поставляется с операциями CRUD (создание, чтение, обновление, удаление) для мутаций в вашем хранилище Redux и поставляется с Selectors, чтобы вы могли получать данные из хранилища.

Сначала я создал экземпляр или копию createEntityAdapter и назвал его guildsAdapter. Теперь guildsAdapter имеет доступ к другим функциям в createEntityAdapter.

Я буду использовать следующие методы:

В строке 4 файла guildsSlice.js я создаю свой собственный guildsSelector, который обращается к определенным данным в магазине. Я экспортирую эту функцию из guildsSlice.js и позже импортирую ее в index.js для использования.

Взгляните на строку 6, где я делаю const guildsSlice = createSlice({});. Для правильной работы функции createSlice необходимы следующие входные данные:

// A name, used in action types
name: string,
// The initial state for the reducer
initialState: any,
// An object of "case reducers". 
// Key names will be used to generate actions.
reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>

Теперь у меня есть доступ ко всем функциям в createEntityAdapter через guildsAdapter.

name: guilds - мы имеем дело с серверами Discord, и в Discord.js они называются гильдиями.

initialState: guildsAdapter.getInitialState() - вспомните тот getInitialState() метод, о котором я говорил ранее. Он создает для нас следующую структуру данных:

{
  // The unique IDs of each item. Must be strings or numbers
  ids: [],
  // A lookup table mapping entity IDs to the corresponding entity
  // objects
  entities: {}
}

Для каждого id он будет ссылаться на entity.id и иметь объект, связанный с этим entity.id.

Вот как будут выглядеть данные, когда я использую store.getState() в index.js:

{
  guilds: {
    ids: [
      '276775249180360704', // discord server ID
      '428518913144520704',
      // additional IDs
    ],
    entities: {
      '276775249180360704': [Object], // discord server ID
      '428518913144520704': [Object],
      // additional { IDs: Objects }
    }
  }
}

Далее для объекта createSlice нам понадобятся наши редукторы.

guildAdded(), guildRemoved() и guildWelcomeMessageUpdated() принимают текущее состояние и действие. Внутри действия есть action.payload, который по сути является всем, что мы отправляем, когда вызываем функцию в другом месте. (Подробнее об этом позже)

Внутри guildAdded() и guildRemoved() находится следующее:

  1. Я разрушаю _id из полезной нагрузки.
  2. Я переназначаю его на action.payload.id = _id.
  3. Я использую guildsAdapter.addOne (или .removeOne), чтобы магазин обновлялся, если в базу данных добавлен новый сервер Discord (другой пользователь добавляет Jeetbot) или сервер Discord, который удаляется из базы данных (кто-то удаляет Jeetbot со своего сервера).

В guildWelcomeMessageUpdated() я также деструктурирую _id и ищу сервер Discord, указанный в const guild = state.entities[_id].

После этого я применяю свою логику, чтобы обновить приветственное сообщение гильдии в магазине.

Наконец, мы еще немного деструктурируем наши действия, чтобы мы могли использовать их в наших файлах позже. Давайте module.exports все, и вперед:

const {
    guildAdded,
    guildRemoved,
    guildWelcomeMessageUpdated,
} = guildsSlice.actions // destructure the actions to use later
module.exports = {
    guildsSelector, // our selector to access the data
    guildsSlice, // our slice adding to the config of the store
    guildAdded, 
    guildRemoved,
    guildWelcomeMessageUpdated,
}

store.js

Здесь мы настраиваем нашу конфигурацию. Большая часть архитектурной и логической работы была сделана в guildsSlice.js. Когда мы настраиваем хранилище в нашем index.js, он будет иметь эту конфигурацию хранилища каждый раз.

Давайте рассмотрим это.

Из Redux Toolkit я получаю функцииconfigureStore и getDefaultMiddleware. Я импортирую guildsSlice из нашего файла guildsSlice.js.

Самая важная часть здесь заключается в том, что мы правильно настраиваем редукторы для магазина:

module.exports = configureStore({
  reducer: {
   guilds: guildsSlice.reducer
  },
  // other code
});

Мы экспортируем редукторы из guildsSlice.js сюда, чтобы их можно было распознать в магазине при инициализации магазина в Index.js.

guildsSlice.js → store.js → index.js - это поток.

Для целей разработки хорошо использовать immutableCheck, serializableCheck и devTools (вам не нужно писать здесь false, поскольку они по умолчанию имеют значение true, если вы оставите outgetDefaultMiddleware() и devTools.) Поскольку они требуют времени для каждого отправляемого действия, Я выключаю его, чтобы мои программы работали быстрее. Хорошо, если они будут проверять, соблюдаете ли вы методологию Redux!

module.exports все это, и мы готовы начать использовать Redux Toolkit в index.js JeetBot.

index.js

А вот самое интересное с Discord.js и Redux.js.

По сути, этот код делает следующее:

  1. Jeetbot проверяет с помощью Discord, был ли Jeetbot добавлен на новые серверы.
  2. Если да, новая информация о гильдии добавляется в базу данных и в локальный store.
  3. Если бот видит, что сервер уже существует в базе данных, он кэширует информацию из MongoDB в локальный store для дальнейшего использования.

Все просто, правда? Но вот важная черта для нашего магазина на данный момент:

store.dispatch(guildAdded(serverCache(guildInfo)));

Примечание. Моя функция serverCache возвращает объект, который включает идентификатор сервера Discord как _id. Ничего особенного, кроме того, как я работал с базой данных - это не первичный ключ.

  • store - это глобальное состояние, в котором мы все держим.
  • store.dispatch - это метод в магазине, который говорит: «Эй, мы хотим запустить действие».
  • guildAdded - редуктор, который я создал еще в guildsSlice.js.
  • guildInfo - это данные, которые я получил и передаю редуктору.

Оттуда для любой команды, которая выполняется в нашем боте, мы можем проверить store с помощью нашего guildsSelector.

Примеры guildAdded и guildRemoved

Вот еще один пример того, как мы добавляем в магазин одну гильдию Discord:

store.dispatch(guildAdded(serverCache(serverInfo)));

Вот еще один пример удаления гильдии:

store.dispatch(guildRemoved(serverCache(guildInfo)));

Это здорово - теперь мы можем добавить в Redux Store!

А как насчет возможности читать в магазине?

Вот первый случай, когда мы будем получать доступ к данным в нашем магазине, а не просто добавлять / удалять данные из него. В документации Redux Toolkit вы увидите, что мы создали guildsSelector в guildsSlice.js. Обратитесь сюда, чтобы увидеть доступные методы выбора.

В строке 3 мы можем получить глубокий клон гильдии Discord в магазине.

guildsSelector.selectById(store.getState(), member.guild.id));

guildsSelector - Импорт, который мы сделали ранее из guildsSlice.js. Он был частью createEntityAdapter, у которого есть метод под названием…

.selectById(state, entity.id) - принимает два параметра. Текущее состояние и идентификатор сервера Discord объекта, который мы хотим получить.

store.getState() - это то, как мы получаем текущее состояние магазина.

member.guild.id - это объект Discord.js, который находит сообщение участника и идентификатор сервера Discord, с которого они отправляют сообщения.

После того, как я получу данные для этого конкретного сервера Discord, я могу запустить свою логику, чтобы увидеть, есть ли приветственное сообщение, установленное администратором или модераторами этого сервера Discord.

Большой! Теперь я могу читать из своего магазина приветственные сообщения для всех серверов Discord, на которых работает мой бот. Но что, если пользователь хочет настроить сообщение?

Время управления состоянием

Продолжение следующего кода - истинная красота Redux.js. Не имея возможности управлять глобальным состоянием, я бы изо всех сил пытался найти решение для всего этого.

Если ты все еще со мной, давай продолжим!

Вот часть, где у меня были мои первоначальные проблемы. В Vanilla JavaScript или, по крайней мере, в текущей структуре моего приложения с использованием Discord.js я отправлял объект, на котором раньше была переменная store. Даже если бы я сделал await commandHandler(msg, localStore), новые данные не вернутся вовремя, чтобы появиться локально. Итак, на этот раз я отправляю async (msg) и store.

Папка commandHandler (index.js)

Бот проверяет, отправлена ​​ли ему правильная команда. Если это так, то переходите к конкретной команде, которая была отправлена ​​в этом вызове.

PREFIX = j. иcommand = welcomeEdit

Пользователь вводит j.welcomeEdit, и поток переводит пользователя к команде редактирования кода.

Примечание. В настоящее время ему присвоено имя j.edit вместо j.welcomeEdit в версии Jeetbot 1.1.2.

edit.js

На данный момент мы немного далеки от нашего файла index.js, и, кроме того, в файл commandHandler отправляется еще тонна сообщений, чтобы узнать, являются ли они законными командами или нет. Пока мы ждем сообщения для ввода пользователем, на других серверах создаются обещания, которые необходимо выполнить!

Когда я ранее вводил обычный объект и пытался вернуть его, после того, как пользователь завершил запрос на ввод нового приветственного сообщения, мой локальный кеш не обновлялся.

На этот раз я импортирую действие guildWelcomeMessageUpdated, мы отправляем действие в магазин и, по сути, завершили полный цикл с тем, что хотели сделать.

store.dispatch(guildWelcomeMessageUpdated(serverCache(guildInfo)));

Здесь происходит много разных вещей, но главное - программа работает так, как задумано! Отправляя действие в мой магазин, он обновляет глобальное состояние, и я могу сразу же использовать новые данные, введенные пользователем.

Ух!

Если вы дошли до этого момента, я надеюсь, что смог поделиться некоторыми знаниями или подтвердить то, о чем вы думали.

Я хочу поблагодарить команды, которые поддерживают Discord.js и Redux ToolKit. Также особая благодарность сообществу Reactiflux на Discord, особенно phryneas # 4779 и acemarke # 9340, которые направили меня в правильном направлении при создании магазина Redux.

Совсем недавно я не знал, как правильно писать код, но, к счастью, с помощью Code Chrysalis и их учебного лагеря по иммерсивному программированию. Я смог обрести уверенность и способность критически относиться к своему коду и быстро искать решения при возникновении проблем.

Создавая собственное хранилище redux для ваших приложений, старайтесь следовать Руководству по стилю Redux. Это отличный способ отформатировать ваши магазины Redux. После щелчка он будет похож на любой инструмент в коробке. Используйте его, когда вам это нужно.

Надеюсь, это был познавательный пост для тех, кто в этом нуждался.

Если есть другое решение, я хотел бы погрузиться глубже и поработать с ним еще немного. Но благодаря этому опыту я почувствовал, что немного поднялся на своем пути, чтобы стать лучшим программистом.

tl; dr - У меня были две большие проблемы

  1. Слишком много операций чтения / записи в базу данных.
  2. Мутация состояния с асинхронностью не возвращается должным образом.

Я решил их с помощью Redux Toolkit.

Мой бот кэширует локальные данные и правильно обрабатывает глобальное состояние.