Praca z TypeScript, Dependency Injection i Discord botami

Opublikowany: 2022-03-11

Typy i testowalny kod to dwa najskuteczniejsze sposoby unikania błędów, zwłaszcza gdy kod zmienia się w czasie. Możemy zastosować te dwie techniki do programowania w języku JavaScript, wykorzystując odpowiednio wzorzec projektowy TypeScript i wstrzykiwanie zależności (DI).

W tym samouczku TypeScript nie omówimy bezpośrednio podstaw TypeScript, z wyjątkiem kompilacji. Zamiast tego po prostu zademonstrujemy najlepsze praktyki TypeScript, gdy przejdziemy przez proces tworzenia bota Discord od podstaw, podłączania testów i DI oraz tworzenia przykładowej usługi. Będziemy używać:

  • Node.js
  • Maszynopis
  • Discord.js, wrapper dla Discord API
  • InversifyJS, framework do wstrzykiwania zależności
  • Biblioteki testowe: Mocha, Chai i ts-mockito
  • Bonus: Mongoose i MongoDB, w celu napisania testu integracyjnego

Konfigurowanie projektu Node.js

Najpierw utwórzmy nowy katalog o nazwie typescript-bot . Następnie wprowadź go i utwórz nowy projekt Node.js, uruchamiając:

 npm init

Uwaga: Możesz również użyć do tego yarn , ale trzymajmy się npm dla zwięzłości.

Spowoduje to otwarcie interaktywnego kreatora, który skonfiguruje plik package.json . Możesz bezpiecznie nacisnąć Enter w przypadku wszystkich pytań (lub podać kilka informacji, jeśli chcesz). Następnie zainstalujmy nasze zależności i zależności deweloperskie (te, które są potrzebne tylko do testów).

 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

Następnie zamień wygenerowaną sekcję "scripts" w package.json na:

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

Podwójne cudzysłowy wokół tests/**/*.spec.ts są potrzebne do rekursywnego wyszukiwania plików. (Uwaga: składnia może się różnić w przypadku programistów korzystających z systemu Windows.)

Skrypt start zostanie użyty do uruchomienia bota, skrypt watch do skompilowania kodu TypeScript i test do uruchomienia testów.

Teraz nasz plik package.json powinien wyglądać tak:

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

Tworzenie nowej aplikacji w panelu aplikacji Discord

Do interakcji z Discord API potrzebujemy tokena. Aby wygenerować taki token, musimy zarejestrować aplikację w panelu programisty Discord. Aby to zrobić, musisz utworzyć konto Discord i przejść do https://discordapp.com/developers/applications/. Następnie kliknij przycisk Nowa aplikacja :

Przycisk „Nowa aplikacja” Discorda.

Wybierz nazwę i kliknij Utwórz . Następnie kliknij BotDodaj bota i gotowe. Dodajmy bota do serwera. Ale nie zamykaj jeszcze tej strony, wkrótce będziemy musieli skopiować token.

Dodaj swojego bota Discord do swojego serwera

Aby przetestować naszego bota, potrzebujemy serwera Discord. Możesz użyć istniejącego serwera lub utworzyć nowy. Aby to zrobić, skopiuj CLIENT_ID bota — znajdujący się na karcie Informacje ogólne — i użyj go jako części tego specjalnego adresu URL autoryzacji:

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

Gdy klikniesz ten adres URL w przeglądarce, pojawi się formularz, w którym możesz wybrać serwer, na którym ma zostać dodany bot.

Standardowa wiadomość powitalna Discord w odpowiedzi na dołączenie naszego bota do serwera.

Po dodaniu bota do serwera powinieneś zobaczyć komunikat podobny do powyższego.

Tworzenie pliku .env

Potrzebujemy sposobu na zapisanie tokena w naszej aplikacji. W tym celu użyjemy pakietu dotenv . Najpierw pobierz token z panelu aplikacji Discord ( BotKliknij, aby odsłonić token ):

Link „Kliknij, aby odsłonić token” w sekcji Bot Discorda.

Teraz utwórz plik .env , a następnie skopiuj i wklej token tutaj:

 TOKEN=paste.the.token.here

Jeśli używasz Git, ten plik powinien zostać umieszczony w .gitignore , aby token nie został naruszony. Utwórz również plik .env.example , aby było wiadomo, że TOKEN wymaga zdefiniowania:

 TOKEN=

Kompilowanie TypeScript

Aby skompilować TypeScript, możesz użyć polecenia npm run watch . Alternatywnie, jeśli używasz PHPStorm (lub innego IDE), po prostu użyj jego obserwatora plików z wtyczki TypeScript i pozwól IDE obsługiwać kompilację. Przetestujmy naszą konfigurację, tworząc plik src/index.ts z zawartością:

 console.log('Hello')

Stwórzmy również plik tsconfig.json , jak poniżej. InversifyJS wymaga experimentalDecorators , emitDecoratorMetadata , es6 i 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" ] }

Jeśli watcher plików działa poprawnie, powinien wygenerować plik src/index.js , a uruchomienie npm start powinno spowodować:

 > node src/index.js Hello

Tworzenie klasy bota

Teraz zacznijmy wreszcie korzystać z najbardziej przydatnej funkcji TypeScript: typów. Śmiało i utwórz następujący plik 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'); } }

Teraz możemy zobaczyć, czego potrzebujemy: token! Czy zamierzamy po prostu skopiować i wkleić go tutaj, czy załadować wartość prosto ze środowiska?

Ani. Zamiast tego napiszmy bardziej utrzymywalny, rozszerzalny i testowalny kod, wstrzykując token przy użyciu naszego wybranego frameworka wstrzykiwania zależności, InversifyJS.

Widzimy również, że zależność od Client jest zakodowana na sztywno. To też zamierzamy wstrzyknąć.

Konfigurowanie kontenera wstrzykiwania zależności

Kontener iniekcji zależności to obiekt, który wie, jak tworzyć instancje innych obiektów. Zazwyczaj definiujemy zależności dla każdej klasy, a kontener DI zajmuje się ich rozwiązywaniem.

InversifyJS zaleca umieszczanie zależności w pliku inversify.config.ts , więc śmiało dodajmy tam nasz kontener 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;

Ponadto dokumentacja InversifyJS zaleca utworzenie pliku types.ts i wymienienie każdego typu, którego będziemy używać, wraz z powiązanym Symbol . Jest to dość niewygodne, ale zapewnia brak kolizji nazewnictwa w miarę rozwoju naszej aplikacji. Każdy Symbol jest unikalnym identyfikatorem, nawet jeśli jego parametr opisu jest taki sam (parametr służy tylko do debugowania).

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

Bez użycia Symbol s, oto jak to wygląda w przypadku kolizji nazw:

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

W tym momencie jeszcze bardziej niewygodne jest ustalenie, który MessageResponder powinien być używany, zwłaszcza jeśli nasz kontener DI jest duży. Używanie Symbol s dba o to i nie wymyśliliśmy dziwnych literałów łańcuchowych w przypadku posiadania dwóch klas o tej samej nazwie.

Korzystanie z kontenera w aplikacji Discord Bot

Teraz zmodyfikujmy naszą klasę Bot , aby korzystała z kontenera. W tym celu musimy dodać @injectable i @inject() . Oto nowa klasa 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); } }

Na koniec stwórzmy instancję naszego bota w pliku 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) });

Teraz uruchom bota i dodaj go do swojego serwera. Następnie, jeśli wpiszesz wiadomość w kanale serwera, powinna pojawić się ona w logach w wierszu poleceń w następujący sposób:

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

Na koniec mamy skonfigurowane podstawy: typy TypeScript i kontener do wstrzykiwania zależności wewnątrz naszego bota.

Wdrażanie logiki biznesowej

Przejdźmy od razu do sedna tego, o czym jest ten artykuł: tworzenia testowalnego kodu. Krótko mówiąc, nasz kod powinien implementować najlepsze praktyki (jak SOLID), nie ukrywać zależności, nie używać metod statycznych.

Ponadto nie powinien powodować skutków ubocznych podczas uruchamiania i być łatwym do wykpiwania.

Dla uproszczenia nasz bot zrobi tylko jedną rzecz: będzie przeszukiwał przychodzące wiadomości, a jeśli zawiera słowo „ping”, użyjemy jednego z dostępnych poleceń bota Discord, aby bot odpowiedział „pong! ” do tego użytkownika.

Aby pokazać, jak wstrzykiwać niestandardowe obiekty do obiektu Bot i testować je jednostkowo, stworzymy dwie klasy: PingFinder i MessageResponder . Wstawimy MessageResponder do klasy Bot , a PingFinder do MessageResponder .

Oto plik 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; } }

Następnie wstrzykujemy tę klasę do pliku 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(); } }

Na koniec zmodyfikowana klasa Bot , która korzysta z klasy 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); } }

W tym stanie aplikacja nie będzie działać, ponieważ nie ma definicji klas MessageResponder i PingFinder . Dodajmy do pliku inversify.config.ts :

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

Ponadto zamierzamy dodać symbole typów do types.ts :

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

Teraz, po ponownym uruchomieniu naszej aplikacji, bot powinien odpowiadać na każdą wiadomość zawierającą „ping”:

Bot odpowiada na wiadomość zawierającą słowo „ping”.

A oto jak to wygląda w logach:

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

Tworzenie testów jednostkowych

Teraz, gdy mamy odpowiednio wstrzyknięte zależności, pisanie testów jednostkowych jest łatwe. W tym celu użyjemy Chai i ts-mockito; istnieje jednak wiele innych programów do uruchamiania testów i szyderczych bibliotek, z których możesz skorzystać.

Prześmiewcza składnia w ts-mockito jest dość szczegółowa, ale także łatwa do zrozumienia. Oto jak skonfigurować usługę MessageResponder i wstrzyknąć do niej makieta PingFinder :

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

Teraz, gdy mamy już skonfigurowane mocki, możemy zdefiniować, jaki powinien być wynik isPing() i zweryfikować wywołania response( reply() . Chodzi o to, że w testach jednostkowych definiujemy wynik isPing() : true lub false . Nie ma znaczenia, jaka jest treść wiadomości, więc w testach używamy po prostu "Non-empty string" .

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

Oto jak mógłby wyglądać cały zestaw testów:

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

Testy dla PingFinder są dość trywialne, ponieważ nie ma zależności, które można by kpić. Oto przykładowy przypadek testowy:

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

Tworzenie testów integracyjnych

Oprócz testów jednostkowych możemy również pisać testy integracyjne. Główna różnica polega na tym, że zależności w tych testach nie są naśladowane. Istnieją jednak zależności, których nie należy testować, np. zewnętrzne połączenia API. W takim przypadku możemy stworzyć rebind i ponownie powiązać je z kontenerem, tak aby mock został wstrzyknięty. Oto przykład, jak to zrobić:

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

To prowadzi nas do końca naszego samouczka bota Discord. Gratulacje, zbudowałeś go czysto, z TypeScript i DI od samego początku! Ten przykład wstrzykiwania zależności TypeScript to wzorzec, który można dodać do swojego repertuaru w celu użycia w dowolnym projekcie.

TypeScript i Dependency Injection: nie tylko do tworzenia Discord Bot

Wprowadzenie zorientowanego obiektowo świata TypeScript do JavaScriptu jest wspaniałym ulepszeniem, niezależnie od tego, czy pracujemy nad kodem typu front-end, czy back-end. Samo używanie typów pozwala nam uniknąć wielu błędów. Posiadanie wstrzykiwania zależności w TypeScript wypycha jeszcze bardziej zorientowane obiektowo najlepsze praktyki na programowanie oparte na JavaScript.

Oczywiście, ze względu na ograniczenia języka, nigdy nie będzie to tak łatwe i naturalne, jak w językach statycznie typowanych. Ale jedno jest pewne: TypeScript, testy jednostkowe i wstrzykiwanie zależności pozwalają nam pisać bardziej czytelny, luźno powiązany i łatwy w utrzymaniu kod — bez względu na rodzaj aplikacji, którą tworzymy.

Powiązane: Utwórz chatbota WhatsApp, a nie aplikację