Arbeiten mit TypeScript, Dependency Injection und Discord Bots

Veröffentlicht: 2022-03-11

Typen und testbarer Code sind zwei der effektivsten Methoden zur Vermeidung von Fehlern, insbesondere wenn sich der Code im Laufe der Zeit ändert. Wir können diese beiden Techniken auf die JavaScript-Entwicklung anwenden, indem wir TypeScript bzw. das Dependency Injection (DI)-Entwurfsmuster nutzen.

In diesem TypeScript-Tutorial werden die Grundlagen von TypeScript nicht direkt behandelt, mit Ausnahme der Kompilierung. Stattdessen werden wir einfach Best Practices für TypeScript demonstrieren, während wir durchgehen, wie man einen Discord-Bot von Grund auf neu erstellt, Tests und DI verbindet und einen Beispieldienst erstellt. Wir werden verwenden:

  • Node.js
  • Typoskript
  • Discord.js, ein Wrapper für die Discord-API
  • InversifyJS, ein Abhängigkeitsinjektionsframework
  • Testbibliotheken: Mocha, Chai und ts-mockito
  • Bonus: Mongoose und MongoDB, um einen Integrationstest zu schreiben

Einrichten Ihres Node.js-Projekts

Erstellen wir zunächst ein neues Verzeichnis namens typescript-bot . Geben Sie es dann ein und erstellen Sie ein neues Node.js-Projekt, indem Sie Folgendes ausführen:

 npm init

Hinweis: Sie könnten dafür auch yarn verwenden, aber bleiben wir der Kürze halber bei npm .

Dadurch wird ein interaktiver Assistent geöffnet, der die Datei package.json “ einrichtet. Sie können sicher für alle Fragen einfach die Eingabetaste drücken (oder einige Informationen angeben, wenn Sie möchten). Lassen Sie uns dann unsere Abhängigkeiten und Entwicklerabhängigkeiten installieren (diejenigen, die nur für die Tests benötigt werden).

 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

Ersetzen Sie dann den generierten Abschnitt "scripts" in package.json durch:

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

Die doppelten Anführungszeichen um tests/**/*.spec.ts werden benötigt, um Dateien rekursiv zu finden. (Hinweis: Die Syntax kann für Entwickler, die Windows verwenden, abweichen.)

Das start wird verwendet, watch den Bot zu starten, das Überwachungsskript, um den TypeScript-Code zu kompilieren, und test , um die Tests auszuführen.

Nun sollte unsere package.json -Datei so aussehen:

 { "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" }

Erstellen einer neuen Anwendung im Discord Apps Dashboard

Um mit der Discord-API zu interagieren, benötigen wir einen Token. Um ein solches Token zu generieren, müssen wir eine App im Discord Developer Dashboard registrieren. Dazu müssen Sie ein Discord-Konto erstellen und zu https://discordapp.com/developers/applications/ gehen. Klicken Sie dann auf die Schaltfläche Neue Anwendung :

Discords „Neue Anwendung“-Schaltfläche.

Wählen Sie einen Namen und klicken Sie auf Erstellen . Klicken Sie dann auf BotBot hinzufügen , und Sie sind fertig. Fügen wir den Bot einem Server hinzu. Aber schließen Sie diese Seite noch nicht, wir müssen bald ein Token kopieren.

Fügen Sie Ihren Discord-Bot zu Ihrem Server hinzu

Um unseren Bot zu testen, benötigen wir einen Discord-Server. Sie können einen vorhandenen Server verwenden oder einen neuen erstellen. Kopieren Sie dazu die CLIENT_ID des Bots – zu finden auf der Registerkarte „Allgemeine Informationen“ – und verwenden Sie sie als Teil dieser speziellen Autorisierungs-URL:

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

Wenn Sie diese URL in einem Browser aufrufen, erscheint ein Formular, in dem Sie den Server auswählen können, auf dem der Bot hinzugefügt werden soll.

Discord-Standard-Willkommensnachricht als Antwort darauf, dass unser Bot dem Server beitritt.

Nachdem Sie den Bot zu Ihrem Server hinzugefügt haben, sollten Sie eine Meldung wie die obige sehen.

Erstellen der .env Datei

Wir brauchen eine Möglichkeit, das Token in unserer App zu speichern. Dazu verwenden wir das Paket dotenv . Holen Sie sich zuerst das Token vom Discord Application Dashboard ( BotClick to Reveal Token ):

Der Link „Click to Reveal Token“ im Bot-Bereich von Discord.

Erstellen Sie nun eine .env -Datei, kopieren Sie das Token und fügen Sie es hier ein:

 TOKEN=paste.the.token.here

Wenn Sie Git verwenden, sollte diese Datei in .gitignore , damit das Token nicht kompromittiert wird. Erstellen Sie außerdem eine .env.example -Datei, damit bekannt ist, dass TOKEN definiert werden muss:

 TOKEN=

Kompilieren von TypeScript

Um TypeScript zu kompilieren, können Sie den Befehl npm run watch verwenden. Wenn Sie alternativ PHPStorm (oder eine andere IDE) verwenden, verwenden Sie einfach den Dateibeobachter des TypeScript-Plugins und überlassen Sie die Kompilierung Ihrer IDE. Testen wir unser Setup, indem wir eine src/index.ts -Datei mit folgendem Inhalt erstellen:

 console.log('Hello')

Lassen Sie uns auch eine tsconfig.json -Datei wie unten erstellen. InversifyJS erfordert experimentalDecorators , emitDecoratorMetadata , es6 und reflect-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" ] }

Wenn der File Watcher ordnungsgemäß funktioniert, sollte er eine src/index.js -Datei generieren, und das Ausführen von npm start sollte zu Folgendem führen:

 > node src/index.js Hello

Erstellen einer Bot-Klasse

Beginnen wir nun endlich damit, die nützlichste Funktion von TypeScript zu verwenden: Typen. Fahren Sie fort und erstellen Sie die folgende src/bot.ts -Datei:

 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'); } }

Jetzt können wir sehen, was wir hier brauchen: ein Token! Werden wir es hier einfach kopieren und einfügen oder den Wert direkt aus der Umgebung laden?

Weder. Lassen Sie uns stattdessen besser wartbaren, erweiterbaren und testbaren Code schreiben, indem wir das Token mit unserem bevorzugten Abhängigkeitsinjektionsframework InversifyJS einfügen.

Außerdem können wir sehen, dass die Client -Abhängigkeit fest codiert ist. Wir werden das auch injizieren.

Konfigurieren des Abhängigkeitsinjektionscontainers

Ein Abhängigkeitsinjektionscontainer ist ein Objekt, das weiß, wie andere Objekte instanziiert werden. Normalerweise definieren wir Abhängigkeiten für jede Klasse, und der DI-Container kümmert sich um deren Auflösung.

InversifyJS empfiehlt, Abhängigkeiten in eine inversify.config.ts -Datei zu schreiben, also lasst uns fortfahren und unseren DI-Container dort hinzufügen:

 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;

Außerdem empfehlen die InversifyJS-Dokumente, eine Datei types.ts erstellen und jeden Typ, den wir verwenden werden, zusammen mit einem zugehörigen Symbol aufzulisten. Das ist ziemlich unpraktisch, stellt aber sicher, dass es keine Namenskollisionen gibt, wenn unsere App wächst. Jedes Symbol ist ein eindeutiger Bezeichner, selbst wenn sein Beschreibungsparameter derselbe ist (der Parameter dient nur zu Debugging-Zwecken).

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

Ohne die Verwendung von Symbol s sieht es so aus, wenn eine Namenskollision auftritt:

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

An dieser Stelle ist es noch unpraktischer herauszufinden, welcher MessageResponder verwendet werden soll, insbesondere wenn unser DI-Container groß wird. Die Verwendung von Symbol s erledigt dies, und wir haben keine seltsamen Zeichenfolgenliterale entwickelt, wenn zwei Klassen denselben Namen haben.

Verwenden des Containers in der Discord Bot App

Lassen Sie uns nun unsere Bot -Klasse ändern, um den Container zu verwenden. Dazu müssen wir die Annotationen @injectable und @inject() hinzufügen. Hier ist die neue Bot -Klasse:

 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); } }

Lassen Sie uns schließlich unseren Bot in der Datei index.ts instanziieren:

 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) });

Starten Sie nun den Bot und lassen Sie ihn zu Ihrem Server hinzufügen. Wenn Sie dann eine Nachricht in den Serverkanal eingeben, sollte sie in den Protokollen auf der Befehlszeile wie folgt erscheinen:

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

Schließlich haben wir die Grundlagen eingerichtet: TypeScript-Typen und einen Abhängigkeitsinjektionscontainer in unserem Bot.

Implementieren von Geschäftslogik

Kommen wir direkt zum Kern dessen, worum es in diesem Artikel geht: Erstellen einer testbaren Codebasis. Kurz gesagt, unser Code sollte Best Practices (wie SOLID) implementieren, Abhängigkeiten nicht verbergen und keine statischen Methoden verwenden.

Außerdem sollte es beim Ausführen keine Nebenwirkungen hervorrufen und leicht verspottbar sein.

Der Einfachheit halber macht unser Bot nur eine Sache: Er durchsucht eingehende Nachrichten, und wenn eine das Wort „ping“ enthält, verwenden wir einen der verfügbaren Discord-Bot-Befehle, damit der Bot mit „pong! ” zu diesem Benutzer.

Um zu zeigen, wie benutzerdefinierte Objekte in das Bot -Objekt eingefügt und auf Komponenten getestet werden, erstellen wir zwei Klassen: PingFinder und MessageResponder . Wir fügen MessageResponder in die Bot -Klasse und PingFinder in MessageResponder ein.

Hier ist die Datei 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; } }

Wir fügen diese Klasse dann in die Datei 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(); } }

Schließlich ist hier eine modifizierte Bot -Klasse, die die MessageResponder -Klasse verwendet:

 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); } }

In diesem Zustand kann die App nicht ausgeführt werden, da es keine Definitionen für die MessageResponder und PingFinder Klassen gibt. Fügen wir der Datei inversify.config.ts Folgendes hinzu:

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

Außerdem werden wir Typsymbole zu types.ts :

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

Jetzt, nach dem Neustart unserer App, sollte der Bot auf jede Nachricht antworten, die „ping“ enthält:

Der Bot antwortet auf eine Nachricht, die das Wort „ping“ enthält.

Und so sieht es in den Logs aus:

 > 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!

Unit-Tests erstellen

Jetzt, da wir Abhängigkeiten richtig eingefügt haben, ist das Schreiben von Unit-Tests einfach. Wir werden dafür Chai und ts-mockito verwenden; Es gibt jedoch viele andere Test-Runner und Mocking-Bibliotheken, die Sie verwenden könnten.

Die spöttische Syntax in ts-mockito ist recht ausführlich, aber auch leicht verständlich. So richten Sie den MessageResponder -Dienst ein und fügen den PingFinder Mock ein:

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

Nachdem wir nun Mocks eingerichtet haben, können wir definieren, was das Ergebnis von isPing() reply() sein soll, und answer()-Aufrufe überprüfen. Der Punkt ist, dass wir in Unit-Tests das Ergebnis des isPing() Aufrufs definieren: true oder false . Es spielt keine Rolle, was der Nachrichteninhalt ist, also verwenden wir in Tests einfach "Non-empty string" .

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

So könnte die gesamte Testsuite aussehen:

 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); } });

Die Tests für PingFinder sind recht trivial, da es keine Abhängigkeiten zu mocken gibt. Hier ein beispielhafter Testfall:

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

Integrationstests erstellen

Neben Unit-Tests können wir auch Integrationstests schreiben. Der Hauptunterschied besteht darin, dass die Abhängigkeiten in diesen Tests nicht verspottet werden. Es gibt jedoch einige Abhängigkeiten, die nicht getestet werden sollten, wie z. B. externe API-Verbindungen. In diesem Fall können wir Mocks erstellen und sie erneut an den Container rebind , sodass stattdessen das Mock injiziert wird. Hier ist ein Beispiel dafür:

 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 });

Dies bringt uns zum Ende unseres Discord-Bot-Tutorials. Herzlichen Glückwunsch, Sie haben es sauber erstellt, mit TypeScript und DI von Anfang an! Dieses TypeScript-Beispiel für die Abhängigkeitsinjektion ist ein Muster, das Sie Ihrem Repertoire für die Verwendung mit jedem Projekt hinzufügen können.

TypeScript und Dependency Injection: Nicht nur für die Entwicklung von Discord Bots

Die objektorientierte Welt von TypeScript in JavaScript zu bringen, ist eine großartige Verbesserung, egal ob wir an Front-End- oder Back-End-Code arbeiten. Nur die Verwendung von Typen allein ermöglicht es uns, viele Fehler zu vermeiden. Die Abhängigkeitsinjektion in TypeScript überträgt noch mehr objektorientierte Best Practices auf die JavaScript-basierte Entwicklung.

Aufgrund der Einschränkungen der Sprache wird es natürlich nie so einfach und natürlich sein wie in statisch typisierten Sprachen. Aber eines ist sicher: TypeScript, Komponententests und Abhängigkeitsinjektion ermöglichen es uns, besser lesbaren, lose gekoppelten und wartbaren Code zu schreiben – ganz gleich, welche Art von Anwendung wir entwickeln.

Verwandt: Erstellen Sie einen WhatsApp-Chatbot, keine App