negezor / vk-io

Modern VK API SDK for Node.js
https://npm.im/vk-io
MIT License
549 stars 85 forks source link

Архитектура: Какой `официально` оптимальный способ организации бота на VK-IO #387

Open AlexXanderGrib opened 3 years ago

AlexXanderGrib commented 3 years ago

Привет, смотрел твой разбор ботов на ютубе, и у них у всех была одна большая проблема: весь код - портянка на 5к строк. Поэтому у меня созрел вопрос: как ты видишь, что должен быть устроен проект с VK-IO. Потому что в нынешнем виде это либо портянка, либо индекс + конфиг + куча файлов, который выглядит примерно вот так:

Frame 4

/** @filename: config.ts */
export const vk = new VK({ /* ... */ })
/** @filename: feat1.ts */
import { vk } from "./config"

vk.updates.on('message', async(ctx, next) => {
  /* code */
})
/** @filename: index.ts */
import { vk } from "./config"
import "./feat1"

vk.updates.startPolling().then(() => console.log("Bot works!"))

И у него тоже есть проблемы:

  1. Порядок поключения фич в индексе влияет на поведение бота
  2. Можно забыть подключить фичу
  3. Чтобы было удобно работать, инициализированый экземпляр ВК должен лежать в конфиге
  4. Сложно уследить за связями между фичами

Хотелось бы услышать твой ответ на это и дублирование его в очень явном виде в документацию, и возможно в README.md

negezor commented 3 years ago

Не скажу что "официальный" подход, просто поделюсь личными предпочтениями и немного практической части.

Общий дизайн

Я предпочитаю использовать подход monorepo для организации распределённых модулей (собственно его использует библиотека). Можно взять уже готовый шаблон для сервисов, и отделить реализацию бота от библиотеки с помощью абстракций (так как любое критическое изменения потребует большого внимания для его адаптирования). Так же по-хорошему стоит использовать виртуальные машины для идентичных условий в разработке и продакшене, здесь поможет например Docker.

Архитектура

Зависимости бота должны быть явными, т.е. никаких добавлений "фич" с помощью одного импорта, иначе тут начнётся сущий кошмар отладки. Абстрактный код:

// commands/random.ts
import { Command } from '@my-project/core';
import { getRandomIntegerInRange } from '@my-project/utils';

export const randomCommand = new Command({
    slug: 'random',

    aliases: [
        'рандом',
        'random'
    ],

    description = 'рандмоное число в промежутке';

    arguments: [
        {
            type: 'integer',
            key: 'min',
            label: 'минк/макс',
            default: null
        },
        {
            type: 'integer',
            key: 'max',
            label: 'минк/макс',
            default: null
        }
    ],

    handler(context) {
        // Работаем с аргументами, а не текстом
        let { min = null, max = null } = context.commander.params;

        if (min === null && max === null) {
            min = 0;
            max = 100;
        } else if (max === null) {
            max = min;
            min = 0;
        }

        const result = getRandomIntegerInRange(min, max);

        return context.answer({
            text: `число в промежутке ${min} - ${max}: ${result}`
        });
    }
});

// commands/index.ts
export * from './random';

// bot.ts
import {
    Bot,

    SessionManager,
    RedisSessionStorage,

    RateLimitManager,

    CommanderManager
} from '@my-project/core';

import * as commands from './commands';

const sessionManager = new SessionManager({
    storage: new RedisSessionStorage({})
});

const rateLimitManager = new RateLimitManager({
    maxPerSecond: 1
});

const commanderManager = new CommanderManager();

for (const command of Object.values(commands)) {
    commanderManager.add(command);
}

const bot = new Bot({
    // ...options
});

// Это может быть кастомная цепочка middleware в боте
bot.incoming.on('message', sessionManager.middleware);
bot.incoming.on('message', rateLimitManager.middleware);
bot.incoming.on('message', commanderManager.middleware);

bot.start()
    .then(() => {
        console.log('Bot started', error);
    })
    .catch((error: Error) => {
        console.error('Error starting bot', error);

        process.exit(1);
    });

Важные вещи из кода выше:

  1. Реализован базовый пакет @my-project/core, в котором находятся вещи необходимые для бота.
  2. Команды используют параметры, тем самым реализуя Dispatcher. Зачем же это нужно? Всё очень просто — можно вызвать команду из любого места с указанными параметрами. Из текста мы парсим любым удобным способом аргументы которые описаны в команде, а кнопки в клавиатуре просто задаются уже с ними и адресуются к нужной нам команде. Тем самым мы избежали дублирование логики и организовали валидацию аргументов. Например вызов одной команды из другой:
    export const dndCommand = new Command({
    // ...
    handler(context) {
        return context.commander.enter('random', {
            params: {
                min: 1,
                max: 20
            }
        });
    }
    });
  3. Каждый модуль менеджера который был использован, имеет свою зону ответственности и даёт чёткое понимание, что использовано и доступно.

Заключение

Это подход я использовал в моих ботах, и он оказался вполне удобным для реализации от простых до сложных ботов. В лучшем случае пакет @my-project/core должен быть только алиасом библиотеки которая уже всё реализовала и протестировала, а файл выглядит следующим образом:

export { Bot, Command } from 'super-bot-library';

export { ViewerManager } from './middlewares';

В случаях изменений в библиотеке можно тогда будет заменить один из интерфейсов. Но никто не запрещает держать всё логику только для проекта.

Есть ещё интересный вариант реализации логики бота на хуках, который применяется допустим в React или Vue, потыкать вживую можно используя этот код.

AlexXanderGrib commented 3 years ago

Спасибо, очень хороший и развёрнутый ответ. И я бы хотел попросить, тебя оставить issue открытым, чтобы другие люди тоже смогли прочитать