Работа с TypeScript, внедрением зависимостей и ботами Discord

Опубликовано: 2022-03-11

Типы и тестируемый код — два наиболее эффективных способа избежать ошибок, особенно когда код со временем меняется. Мы можем применить эти два метода к разработке JavaScript, используя TypeScript и шаблон проектирования внедрения зависимостей (DI) соответственно.

В этом руководстве по TypeScript мы не будем напрямую рассматривать основы TypeScript, за исключением компиляции. Вместо этого мы просто продемонстрируем лучшие практики TypeScript, когда рассмотрим, как создать бота Discord с нуля, подключить тесты и DI, а также создать образец сервиса. Мы будем использовать:

  • Node.js
  • Машинопись
  • Discord.js, оболочка для Discord API
  • InversifyJS, фреймворк внедрения зависимостей
  • Библиотеки для тестирования: Mocha, Chai и ts-mockito.
  • Бонус: Mongoose и MongoDB, чтобы написать интеграционный тест

Настройка вашего проекта Node.js

Во-первых, давайте создадим новый каталог с именем typescript-bot . Затем введите его и создайте новый проект Node.js, запустив:

 npm init

Примечание. Вы также можете использовать для этого yarn , но для краткости давайте придерживаться npm .

Откроется интерактивный мастер, который настроит файл package.json . Вы можете смело просто нажимать Enter для всех вопросов (или предоставить некоторую информацию, если хотите). Затем давайте установим наши зависимости и зависимости для разработчиков (те, которые нужны только для тестов).

 npm i --save typescript discord.js inversify dotenv @types/node reflect-metadata npm i --save-dev chai mocha ts-mockito ts-node @types/chai @types/mocha

Затем замените сгенерированный раздел "scripts" в package.json на:

 "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" },

Двойные кавычки вокруг tests/**/*.spec.ts необходимы для рекурсивного поиска файлов. (Примечание: синтаксис может отличаться для разработчиков, использующих Windows.)

Сценарий start будет использоваться для запуска бота, скрипт watch — для компиляции кода TypeScript, а test — для запуска тестов.

Теперь наш файл package.json должен выглядеть так:

 { "name": "typescript-bot", "version": "1.0.0", "description": "", "main": "index.js", "dependencies": { "@types/node": "^11.9.4", "discord.js": "^11.4.2", "dotenv": "^6.2.0", "inversify": "^5.0.1", "reflect-metadata": "^0.1.13", "typescript": "^3.3.3" }, "devDependencies": { "@types/chai": "^4.1.7", "@types/mocha": "^5.2.6", "chai": "^4.2.0", "mocha": "^5.2.0", "ts-mockito": "^2.3.1", "ts-node": "^8.0.3" }, "scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" }, "author": "", "license": "ISC" }

Создание нового приложения на панели инструментов Discord Apps

Для взаимодействия с Discord API нам нужен токен. Чтобы сгенерировать такой токен, нам нужно зарегистрировать приложение на панели инструментов разработчика Discord. Для этого вам нужно создать учетную запись Discord и перейти по ссылке https://discordapp.com/developers/applications/. Затем нажмите кнопку « Новое приложение »:

Кнопка Discord «Новое приложение».

Выберите имя и нажмите « Создать» . Затем нажмите БотДобавить бота , и все готово. Добавим бота на сервер. Но пока не закрывайте эту страницу, скоро нам потребуется скопировать токен.

Добавьте своего бота Discord на свой сервер

Чтобы протестировать нашего бота, нам нужен сервер Discord. Вы можете использовать существующий сервер или создать новый. Для этого скопируйте CLIENT_ID бота, который находится на вкладке «Общая информация», и используйте его как часть этого специального URL-адреса авторизации:

https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot

Когда вы нажмете этот URL-адрес в браузере, появится форма, в которой вы можете выбрать сервер, на который следует добавить бота.

Стандартное приветственное сообщение Discord в ответ на подключение нашего бота к серверу.

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

Создание файла .env

Нам нужен способ сохранить токен в нашем приложении. Для этого мы воспользуемся пакетом dotenv . Сначала получите токен с панели управления приложения Discord ( BotClick to Reveal Token ):

Ссылка «Нажмите, чтобы показать токен» в разделе «Бот Discord».

Теперь создайте файл .env , затем скопируйте и вставьте токен сюда:

 TOKEN=paste.the.token.here

Если вы используете Git, то этот файл нужно поместить в .gitignore , чтобы токен не был скомпрометирован. Кроме того, создайте файл .env.example , чтобы было известно, что TOKEN нуждается в определении:

 TOKEN=

Компиляция TypeScript

Чтобы скомпилировать TypeScript, вы можете использовать команду npm run watch . В качестве альтернативы, если вы используете PHPStorm (или другую IDE), просто используйте его средство просмотра файлов из плагина TypeScript и позвольте вашей IDE выполнять компиляцию. Давайте проверим нашу настройку, создав файл src/index.ts с содержимым:

 console.log('Hello')

Кроме того, давайте создадим файл tsconfig.json , как показано ниже. InversifyJS требует emitDecoratorMetadata experimentalDecorators es6 и Reflect reflect-metadata Metadata:

 { "compilerOptions": { "module": "commonjs", "moduleResolution": "node", "target": "es2016", "lib": [ "es6", "dom" ], "sourceMap": true, "types": [ // add node as an option "node", "reflect-metadata" ], "typeRoots": [ // add path to @types "node_modules/@types" ], "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true }, "exclude": [ "node_modules" ] }

Если файловый наблюдатель работает правильно, он должен сгенерировать файл src/index.js , а запуск npm start должен привести к следующему результату:

 > node src/index.js Hello

Создание класса бота

Теперь давайте, наконец, начнем использовать самую полезную функцию TypeScript: типы. Идем дальше и создаем следующий файл src/bot.ts :

 import {Client, Message} from "discord.js"; export class Bot { public listen(): Promise<string> { let client = new Client(); client.on('message', (message: Message) => {}); return client.login('token should be here'); } }

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

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

Кроме того, мы видим, что зависимость Client жестко запрограммирована. Мы собираемся ввести это также.

Настройка контейнера внедрения зависимостей

Контейнер внедрения зависимостей — это объект, который знает, как создавать экземпляры других объектов. Обычно мы определяем зависимости для каждого класса, а DI-контейнер заботится об их разрешении.

InversifyJS рекомендует размещать зависимости в файле inversify.config.ts , так что давайте добавим туда наш DI-контейнер:

 import "reflect-metadata"; import {Container} from "inversify"; import {TYPES} from "./types"; import {Bot} from "./bot"; import {Client} from "discord.js"; let container = new Container(); container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope(); container.bind<Client>(TYPES.Client).toConstantValue(new Client()); container.bind<string>(TYPES.Token).toConstantValue(process.env.TOKEN); export default container;

Кроме того, в документах InversifyJS рекомендуется создать файл types.ts и перечислить каждый тип, который мы собираемся использовать, вместе с соответствующим Symbol . Это довольно неудобно, но гарантирует отсутствие конфликтов имен по мере роста нашего приложения. Каждый Symbol является уникальным идентификатором, даже если его параметр описания одинаков (параметр предназначен только для целей отладки).

 export const TYPES = { Bot: Symbol("Bot"), Client: Symbol("Client"), Token: Symbol("Token"), };

Без использования Symbol s вот как это выглядит, когда происходит коллизия имен:

 Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder

На этом этапе еще более неудобно разобраться, какой MessageResponder следует использовать, особенно если наш контейнер DI становится большим. Использование Symbol позаботится об этом, и мы не придумаем странных строковых литералов в случае наличия двух классов с одинаковыми именами.

Использование контейнера в приложении Discord Bot

Теперь давайте изменим наш класс Bot , чтобы использовать контейнер. Для этого нам нужно добавить @injectable и @inject() . Вот новый класс Bot :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string ) { this.client = client; this.token = token; } public listen(): Promise < string > { this.client.on('message', (message: Message) => { console.log("Message received! Contents: ", message.content); }); return this.client.login(this.token); } }

Наконец, давайте создадим экземпляр нашего бота в файле index.ts :

 require('dotenv').config(); // Recommended way of loading dotenv import container from "./inversify.config"; import {TYPES} from "./types"; import {Bot} from "./bot"; let bot = container.get<Bot>(TYPES.Bot); bot.listen().then(() => { console.log('Logged in!') }).catch((error) => { console.log('Oh no! ', error) });

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

 > node src/index.js Logged in! Message received! Contents: Test

Наконец, у нас настроены основы: типы TypeScript и контейнер внедрения зависимостей внутри нашего бота.

Реализация бизнес-логики

Давайте перейдем непосредственно к сути этой статьи: созданию тестируемой кодовой базы. Короче говоря, наш код должен реализовывать лучшие практики (например, SOLID), а не скрывать зависимости, не использовать статические методы.

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

Для простоты наш бот будет делать только одну вещь: он будет искать входящие сообщения, и если одно из них содержит слово «ping», мы будем использовать одну из доступных команд бота Discord, чтобы бот ответил «pong! ” этому пользователю.

Чтобы показать, как внедрять пользовательские объекты в объект Bot и выполнять их модульное тестирование, мы создадим два класса: PingFinder и MessageResponder . Мы MessageResponder в класс Bot , а PingFinder в MessageResponder .

Вот файл src/services/ping-finder.ts :

 import {injectable} from "inversify"; @injectable() export class PingFinder { private regexp = 'ping'; public isPing(stringToSearch: string): boolean { return stringToSearch.search(this.regexp) >= 0; } }

Затем мы внедряем этот класс в файл src/services/message-responder.ts :

 import {Message} from "discord.js"; import {PingFinder} from "./ping-finder"; import {inject, injectable} from "inversify"; import {TYPES} from "../types"; @injectable() export class MessageResponder { private pingFinder: PingFinder; constructor( @inject(TYPES.PingFinder) pingFinder: PingFinder ) { this.pingFinder = pingFinder; } handle(message: Message): Promise<Message | Message[]> { if (this.pingFinder.isPing(message.content)) { return message.reply('pong!'); } return Promise.reject(); } }

Наконец, вот модифицированный класс Bot , который использует класс MessageResponder :

 import {Client, Message} from "discord.js"; import {inject, injectable} from "inversify"; import {TYPES} from "./types"; import {MessageResponder} from "./services/message-responder"; @injectable() export class Bot { private client: Client; private readonly token: string; private messageResponder: MessageResponder; constructor( @inject(TYPES.Client) client: Client, @inject(TYPES.Token) token: string, @inject(TYPES.MessageResponder) messageResponder: MessageResponder) { this.client = client; this.token = token; this.messageResponder = messageResponder; } public listen(): Promise<string> { this.client.on('message', (message: Message) => { if (message.author.bot) { console.log('Ignoring bot message!') return; } console.log("Message received! Contents: ", message.content); this.messageResponder.handle(message).then(() => { console.log("Response sent!"); }).catch(() => { console.log("Response not sent.") }) }); return this.client.login(this.token); } }

В этом состоянии приложение не запустится, поскольку для классов MessageResponder и PingFinder нет определений. Добавим в файл inversify.config.ts :

 container.bind<MessageResponder>(TYPES.MessageResponder).to(MessageResponder).inSingletonScope(); container.bind<PingFinder>(TYPES.PingFinder).to(PingFinder).inSingletonScope();

Кроме того, мы собираемся добавить символы типов в types.ts :

 MessageResponder: Symbol("MessageResponder"), PingFinder: Symbol("PingFinder"),

Теперь, после перезапуска нашего приложения, бот должен реагировать на каждое сообщение, содержащее «ping»:

Бот отвечает на сообщение, содержащее слово «ping».

А вот как это выглядит в логах:

 > node src/index.js Logged in! Message received! Contents: some message Response not sent. Message received! Contents: message with ping Ignoring bot message! Response sent!

Создание модульных тестов

Теперь, когда у нас правильно внедрены зависимости, писать модульные тесты несложно. Для этого мы будем использовать Chai и ts-mockito; тем не менее, есть много других средств запуска тестов и имитационных библиотек, которые вы могли бы использовать.

Синтаксис mockito в ts-mockito довольно многословен, но также прост для понимания. Вот как настроить службу MessageResponder и внедрить в нее макет PingFinder :

 let mockedPingFinderClass = mock(PingFinder); let mockedPingFinderInstance = instance(mockedPingFinderClass); let service = new MessageResponder(mockedPingFinderInstance);

Теперь, когда у нас настроены макеты, мы можем определить, каким должен быть результат isPing() , и проверить вызовы reply() . Дело в том, что в юнит-тестах мы определяем результат isPing() : true или false . Содержание сообщения не имеет значения, поэтому в тестах мы просто используем "Non-empty string" .

 when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(true); await service.handle(mockedMessageInstance) verify(mockedMessageClass.reply('pong!')).once();

Вот как может выглядеть весь набор тестов:

 import "reflect-metadata"; import 'mocha'; import {expect} from 'chai'; import {PingFinder} from "../../../src/services/ping-finder"; import {MessageResponder} from "../../../src/services/message-responder"; import {instance, mock, verify, when} from "ts-mockito"; import {Message} from "discord.js"; describe('MessageResponder', () => { let mockedPingFinderClass: PingFinder; let mockedPingFinderInstance: PingFinder; let mockedMessageClass: Message; let mockedMessageInstance: Message; let service: MessageResponder; beforeEach(() => { mockedPingFinderClass = mock(PingFinder); mockedPingFinderInstance = instance(mockedPingFinderClass); mockedMessageClass = mock(Message); mockedMessageInstance = instance(mockedMessageClass); setMessageContents(); service = new MessageResponder(mockedPingFinderInstance); }) it('should reply', async () => { whenIsPingThenReturn(true); await service.handle(mockedMessageInstance); verify(mockedMessageClass.reply('pong!')).once(); }) it('should not reply', async () => { whenIsPingThenReturn(false); await service.handle(mockedMessageInstance).then(() => { // Successful promise is unexpected, so we fail the test expect.fail('Unexpected promise'); }).catch(() => { // Rejected promise is expected, so nothing happens here }); verify(mockedMessageClass.reply('pong!')).never(); }) function setMessageContents() { mockedMessageInstance.content = "Non-empty string"; } function whenIsPingThenReturn(result: boolean) { when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(result); } });

Тесты для PingFinder довольно тривиальны, поскольку нет никаких зависимостей, которые нужно имитировать. Вот пример тестового случая:

 describe('PingFinder', () => { let service: PingFinder; beforeEach(() => { service = new PingFinder(); }) it('should find "ping" in the string', () => { expect(service.isPing("ping")).to.be.true }) });

Создание интеграционных тестов

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

 import container from "../../inversify.config"; import {TYPES} from "../../src/types"; // ... describe('Bot', () => { let discordMock: Client; let discordInstance: Client; let bot: Bot; beforeEach(() => { discordMock = mock(Client); discordInstance = instance(discordMock); container.rebind<Client>(TYPES.Client) .toConstantValue(discordInstance); bot = container.get<Bot>(TYPES.Bot); }); // Test cases here });

Это подводит нас к концу нашего руководства по ботам Discord. Поздравляем, вы построили его чисто, используя TypeScript и DI с самого начала! Этот пример внедрения зависимостей TypeScript — это шаблон, который вы можете добавить в свой репертуар для использования с любым проектом.

TypeScript и внедрение зависимостей: не только для разработки ботов Discord

Внедрение объектно-ориентированного мира TypeScript в JavaScript — это большое улучшение, независимо от того, работаем ли мы над внешним или внутренним кодом. Использование одних только типов позволяет нам избежать многих ошибок. Внедрение зависимостей в TypeScript подталкивает еще больше передовых методов объектно-ориентированного программирования к разработке на основе JavaScript.

Конечно, из-за ограничений языка это никогда не будет так просто и естественно, как в статически типизированных языках. Но одно можно сказать наверняка: TypeScript, модульные тесты и внедрение зависимостей позволяют нам писать более читаемый, слабосвязанный и удобный для сопровождения код — независимо от того, какое приложение мы разрабатываем.

Связанный: Создайте чат-бот WhatsApp, а не приложение