Lavorare con i robot TypeScript, Dependency Injection e Discord

Pubblicato: 2022-03-11

I tipi e il codice verificabile sono due dei modi più efficaci per evitare i bug, soprattutto quando il codice cambia nel tempo. Possiamo applicare queste due tecniche allo sviluppo di JavaScript sfruttando rispettivamente TypeScript e il modello di progettazione dell'iniezione di dipendenza (DI).

In questo tutorial di TypeScript, non tratteremo direttamente le basi di TypeScript, ad eccezione della compilazione. Invece, dimostreremo semplicemente le migliori pratiche di TypeScript mentre spieghiamo come creare un bot Discord da zero, collegare test e DI e creare un servizio di esempio. Useremo:

  • Node.js
  • Dattiloscritto
  • Discord.js, un wrapper per l'API Discord
  • InversifyJS, un framework di iniezione delle dipendenze
  • Librerie di test: Mocha, Chai e ts-mockito
  • Bonus: Mongoose e MongoDB, per scrivere un test di integrazione

Configurazione del progetto Node.js

Per prima cosa, creiamo una nuova directory chiamata typescript-bot . Quindi, inseriscilo e crea un nuovo progetto Node.js eseguendo:

 npm init

Nota: puoi anche usare il yarn per quello, ma restiamo su npm per brevità.

Si aprirà una procedura guidata interattiva, che imposterà il file package.json . Puoi tranquillamente premere Invio per tutte le domande (o fornire alcune informazioni se lo desideri). Quindi, installiamo le nostre dipendenze e le dipendenze dev (quelle necessarie solo per i test).

 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

Quindi, sostituisci la sezione "scripts" generata in package.json con:

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

Le virgolette intorno a tests/**/*.spec.ts sono necessarie per trovare i file in modo ricorsivo. (Nota: la sintassi può variare per gli sviluppatori che utilizzano Windows.)

Lo script di start verrà utilizzato per avviare il bot, lo script di watch per compilare il codice TypeScript e test per eseguire i test.

Ora, il nostro file package.json dovrebbe assomigliare a questo:

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

Creazione di una nuova applicazione nel dashboard di Discord Apps

Per interagire con l'API Discord, abbiamo bisogno di un token. Per generare un tale token, dobbiamo registrare un'app nella dashboard per sviluppatori Discord. Per fare ciò, devi creare un account Discord e andare su https://discordapp.com/developers/applications/. Quindi, fai clic sul pulsante Nuova applicazione :

Pulsante "Nuova applicazione" di Discord.

Scegli un nome e fai clic su Crea . Quindi, fai clic su BotAggiungi Bot e il gioco è fatto. Aggiungiamo il bot a un server. Ma non chiudere ancora questa pagina, dovremo copiare presto un token.

Aggiungi il tuo bot Discord al tuo server

Per testare il nostro bot, abbiamo bisogno di un server Discord. Puoi utilizzare un server esistente o crearne uno nuovo. A tale scopo, copia il CLIENT_ID del bot, che si trova nella scheda Informazioni generali, e utilizzalo come parte di questo URL di autorizzazione speciale:

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

Quando colpisci questo URL in un browser, viene visualizzato un modulo in cui puoi scegliere il server in cui aggiungere il bot.

Messaggio di benvenuto di Discord standard in risposta al nostro bot che si unisce al server.

Dopo aver aggiunto il bot al tuo server, dovresti vedere un messaggio come quello sopra.

Creazione del file .env

Abbiamo bisogno di un modo per salvare il token nella nostra app. Per fare ciò, useremo il pacchetto dotenv . Innanzitutto, ottieni il token dalla dashboard dell'applicazione Discord ( BotFai clic per rivelare il token ):

Il link "Click to Reveal Token" nella sezione Bot di Discord.

Ora crea un file .env , quindi copia e incolla il token qui:

 TOKEN=paste.the.token.here

Se usi Git, questo file dovrebbe essere posizionato in .gitignore , in modo che il token non sia compromesso. Inoltre, crea un file .env.example , in modo che sia noto che TOKEN deve essere definito:

 TOKEN=

Compilazione di TypeScript

Per compilare TypeScript, puoi usare il comando npm run watch . In alternativa, se usi PHPStorm (o un altro IDE), usa semplicemente il suo file watcher dal suo plug-in TypeScript e lascia che il tuo IDE gestisca la compilazione. Proviamo la nostra configurazione creando un file src/index.ts con il contenuto:

 console.log('Hello')

Inoltre, creiamo un file tsconfig.json come di seguito. InversifyJS richiede experimentalDecorators , emitDecoratorMetadata , es6 e 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" ] }

Se il file watcher funziona correttamente, dovrebbe generare un file src/index.js e l'esecuzione di npm start dovrebbe comportare:

 > node src/index.js Hello

Creazione di una classe bot

Ora, iniziamo finalmente a utilizzare la funzionalità più utile di TypeScript: i tipi. Vai avanti e crea il seguente file 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'); } }

Ora possiamo vedere di cosa abbiamo bisogno qui: un token! Lo faremo semplicemente copia-incolla qui o caricheremo il valore direttamente dall'ambiente?

Nessuno dei due. Invece, scriviamo codice più manutenibile, estendibile e testabile iniettando il token utilizzando il nostro framework di iniezione delle dipendenze scelto, InversifyJS.

Inoltre, possiamo vedere che la dipendenza del Client è codificata. Inietteremo anche questo.

Configurazione del contenitore di iniezione delle dipendenze

Un contenitore di iniezione delle dipendenze è un oggetto che sa come istanziare altri oggetti. In genere, definiamo le dipendenze per ogni classe e il contenitore DI si occupa di risolverle.

InversifyJS consiglia di inserire le dipendenze in un file inversify.config.ts , quindi andiamo avanti e aggiungiamo il nostro contenitore DI lì:

 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;

Inoltre, i documenti di InversifyJS consigliano di creare un file types.ts e di elencare ogni tipo che useremo, insieme a un relativo Symbol . Questo è abbastanza scomodo, ma assicura che non ci siano collisioni di nomi man mano che la nostra app cresce. Ogni Symbol è un identificatore univoco, anche quando il suo parametro di descrizione è lo stesso (il parametro è solo a scopo di debug).

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

Senza usare Symbol s, ecco come appare quando si verifica una collisione di denominazione:

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

A questo punto, è ancora più scomodo determinare quale MessageResponder dovrebbe essere utilizzato, soprattutto se il nostro contenitore DI diventa grande. L'uso di Symbol s si occupa di questo e non abbiamo escogitato strani letterali stringa nel caso di avere due classi con lo stesso nome.

Utilizzo del contenitore nell'app Discord Bot

Ora modifichiamo la nostra classe Bot per utilizzare il contenitore. Dobbiamo aggiungere @injectable e @inject() per farlo. Ecco la nuova classe 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); } }

Infine, istanziamo il nostro bot nel file 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) });

Ora avvia il bot e aggiungilo al tuo server. Quindi, se digiti un messaggio nel canale del server, dovrebbe apparire nei registri sulla riga di comando in questo modo:

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

Infine, abbiamo le basi impostate: tipi TypeScript e un contenitore di iniezione delle dipendenze all'interno del nostro bot.

Implementazione della logica aziendale

Andiamo direttamente al nocciolo dell'argomento di questo articolo: creare una base di codice testabile. In breve, il nostro codice dovrebbe implementare le migliori pratiche (come SOLID), non nascondere le dipendenze, non utilizzare metodi statici.

Inoltre, non dovrebbe introdurre effetti collaterali durante l'esecuzione ed essere facilmente deridebile.

Per semplicità, il nostro bot farà solo una cosa: cercherà i messaggi in arrivo e, se uno contiene la parola "ping", useremo uno dei comandi Discord bot disponibili per fare in modo che il bot risponda con "pong! " a quell'utente.

Per mostrare come iniettare oggetti personalizzati nell'oggetto Bot e testarli in unità, creeremo due classi: PingFinder e MessageResponder . MessageResponder nella classe Bot e PingFinder in MessageResponder .

Ecco il file 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; } }

Quindi iniettiamo quella classe nel file 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(); } }

Infine, ecco una classe Bot modificata, che utilizza la classe 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); } }

In tale stato, l'app non verrà eseguita perché non ci sono definizioni per le classi MessageResponder e PingFinder . Aggiungiamo quanto segue al file inversify.config.ts :

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

Inoltre, aggiungeremo i simboli di tipo a types.ts :

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

Ora, dopo aver riavviato la nostra app, il bot dovrebbe rispondere a ogni messaggio che contiene "ping":

Il bot che risponde a un messaggio contenente la parola "ping".

Ed ecco come appare nei log:

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

Creazione di unit test

Ora che abbiamo le dipendenze correttamente iniettate, scrivere unit test è facile. Useremo Chai e ts-mockito per questo; tuttavia, ci sono molti altri test runner e librerie beffarde che potresti usare.

La sintassi beffarda in ts-mockito è piuttosto prolissa, ma anche facile da capire. Ecco come configurare il servizio MessageResponder e iniettarvi il mock PingFinder :

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

Ora che abbiamo impostato i mock, possiamo definire quale dovrebbe essere il risultato delle chiamate isPing() e verificare le chiamate reply() . Il punto è che negli unit test definiamo il risultato della chiamata isPing() : true o false . Non importa quale sia il contenuto del messaggio, quindi nei test utilizziamo semplicemente "Non-empty string" .

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

Ecco come potrebbe apparire l'intera suite di test:

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

I test per PingFinder sono abbastanza banali poiché non ci sono dipendenze da prendere in giro. Ecco un esempio di caso di prova:

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

Creazione di test di integrazione

Oltre agli unit test, possiamo anche scrivere test di integrazione. La differenza principale è che le dipendenze in quei test non vengono derise. Tuttavia, ci sono alcune dipendenze che non dovrebbero essere testate, come le connessioni API esterne. In tal caso, possiamo creare mock e rebind al contenitore, in modo che venga invece iniettato il mock. Ecco un esempio di come farlo:

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

Questo ci porta alla fine del nostro tutorial sul bot Discord. Congratulazioni, l'hai costruito in modo pulito, con TypeScript e DI fin dall'inizio! Questo esempio di iniezione di dipendenza di TypeScript è un modello che puoi aggiungere al tuo repertorio per l'uso con qualsiasi progetto.

TypeScript e Dependency Injection: non solo per lo sviluppo di bot Discord

Portare il mondo orientato agli oggetti di TypeScript in JavaScript è un grande miglioramento, sia che lavoriamo su codice front-end o back-end. Il solo uso dei tipi da solo ci consente di evitare molti bug. L'inserimento delle dipendenze in TypeScript spinge ancora più best practice orientate agli oggetti nello sviluppo basato su JavaScript.

Naturalmente, a causa dei limiti della lingua, non sarà mai facile e naturale come nelle lingue tipizzate staticamente. Ma una cosa è certa: TypeScript, unit test e dependency injection ci consentono di scrivere codice più leggibile, ad accoppiamento lasco e gestibile, indipendentemente dal tipo di app che stiamo sviluppando.

Correlati: crea un chatbot WhatsApp, non un'app