Lucrul cu TypeScript, Dependency Injection și Discord Bots

Publicat: 2022-03-11

Tipurile și codul testabil sunt două dintre cele mai eficiente modalități de a evita erorile, mai ales pe măsură ce codul se modifică în timp. Putem aplica aceste două tehnici dezvoltării JavaScript utilizând TypeScript și, respectiv, modelul de proiectare cu injecție de dependență (DI).

În acest tutorial TypeScript, nu vom acoperi în mod direct elementele de bază ale TypeScript, cu excepția compilării. În schimb, vom demonstra pur și simplu cele mai bune practici TypeScript pe măsură ce vom parcurge cum să facem un bot Discord de la zero, să conectăm teste și DI și să creăm un serviciu de probă. Vom folosi:

  • Node.js
  • TypeScript
  • Discord.js, un wrapper pentru API-ul Discord
  • InversifyJS, un cadru de injectare a dependenței
  • Biblioteci de testare: Mocha, Chai și ts-mockito
  • Bonus: Mongoose și MongoDB, pentru a scrie un test de integrare

Configurarea proiectului Node.js

Mai întâi, să creăm un nou director numit typescript-bot . Apoi, introduceți-l și creați un nou proiect Node.js rulând:

 npm init

Notă: puteți folosi și yarn pentru asta, dar să rămânem la npm pentru concizie.

Aceasta va deschide un expert interactiv, care va configura fișierul package.json . Puteți apăsa în siguranță doar Enter pentru toate întrebările (sau furnizați câteva informații dacă doriți). Apoi, să instalăm dependențele noastre și dependențele de dezvoltare (cele care sunt necesare doar pentru teste).

 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

Apoi, înlocuiți secțiunea "scripts" generată în package.json cu:

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

Ghilimelele duble din jurul tests/**/*.spec.ts sunt necesare pentru a găsi fișiere recursiv. (Notă: Sintaxa poate varia pentru dezvoltatorii care utilizează Windows.)

Scriptul de start va fi folosit pentru a porni bot-ul, scriptul de watch pentru a compila codul TypeScript și test pentru a rula testele.

Acum, fișierul nostru package.json ar trebui să arate astfel:

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

Crearea unei noi aplicații în tabloul de bord Discord Apps

Pentru a interacționa cu API-ul Discord, avem nevoie de un token. Pentru a genera un astfel de simbol, trebuie să înregistrăm o aplicație în Tabloul de bord pentru dezvoltatori Discord. Pentru a face acest lucru, trebuie să creați un cont Discord și să accesați https://discordapp.com/developers/applications/. Apoi, faceți clic pe butonul Aplicație nouă :

Butonul „Aplicație nouă” al Discord.

Alegeți un nume și faceți clic pe Creare . Apoi, faceți clic pe BotAdăugați Bot și ați terminat. Să adăugăm bot-ul la un server. Dar nu închideți încă această pagină, va trebui să copiem un token în curând.

Adăugați robotul Discord pe serverul dvs

Pentru a ne testa botul, avem nevoie de un server Discord. Puteți utiliza un server existent sau puteți crea unul nou. Pentru a face acest lucru, copiați CLIENT_ID -ul botului — aflat în fila Informații generale — și utilizați-l ca parte a acestei adrese URL speciale de autorizare:

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

Când accesați această adresă URL într-un browser, apare un formular în care puteți alege serverul pe care ar trebui să fie adăugat botul.

Mesaj standard de bun venit Discord ca răspuns la intrarea botului nostru pe server.

După ce adăugați botul pe server, ar trebui să vedeți un mesaj ca cel de mai sus.

Crearea fișierului .env

Avem nevoie de o modalitate de a salva simbolul în aplicația noastră. Pentru a face asta, vom folosi pachetul dotenv . Mai întâi, obțineți jetonul din Tabloul de bord al aplicației Discord ( BotFaceți clic pentru a dezvălui jeton ):

Linkul „Click to Reveal Token” din secțiunea Discord’s Bot.

Acum, creați un fișier .env , apoi copiați și lipiți simbolul aici:

 TOKEN=paste.the.token.here

Dacă utilizați Git, atunci acest fișier ar trebui să fie plasat în .gitignore , astfel încât tokenul să nu fie compromis. De asemenea, creați un fișier .env.example , astfel încât să se știe că TOKEN are nevoie de definire:

 TOKEN=

Compilarea TypeScript

Pentru a compila TypeScript, puteți utiliza comanda npm run watch . Ca alternativă, dacă utilizați PHPStorm (sau un alt IDE), trebuie doar să utilizați programul de urmărire a fișierelor din pluginul său TypeScript și lăsați IDE-ul să se ocupe de compilare. Să ne testăm configurația creând un fișier src/index.ts cu conținutul:

 console.log('Hello')

De asemenea, să creăm un fișier tsconfig.json ca mai jos. InversifyJS necesită 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" ] }

Dacă urmăritorul de fișiere funcționează corect, ar trebui să genereze un fișier src/index.js , iar rularea npm start ar trebui să aibă ca rezultat:

 > node src/index.js Hello

Crearea unei clase de bot

Acum, să începem în sfârșit să folosim cea mai utilă caracteristică a TypeScript: tipuri. Continuați și creați următorul fișier 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'); } }

Acum, putem vedea de ce avem nevoie aici: un simbol! Îl vom copia și lipi aici sau vom încărca valoarea direct din mediu?

Nici. În schimb, să scriem mai mult cod care poate fi întreținut, extensibil și testabil prin injectarea jetonului utilizând cadrul de injecție de dependență ales, InversifyJS.

De asemenea, putem vedea că dependența de Client este codificată. Vom injecta și asta.

Configurarea containerului de injectare a dependenței

Un container de injecție de dependență este un obiect care știe să instanțieze alte obiecte. De obicei, definim dependențe pentru fiecare clasă, iar containerul DI se ocupă de rezolvarea lor.

InversifyJS recomandă să puneți dependențe într-un fișier inversify.config.ts , așa că haideți să adăugăm containerul nostru DI acolo:

 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;

De asemenea, documentele InversifyJS recomandă crearea unui fișier types.ts și listarea fiecărui tip pe care urmează să-l folosim, împreună cu un Symbol asociat. Acest lucru este destul de incomod, dar asigură că nu există coliziuni de denumire pe măsură ce aplicația noastră crește. Fiecare Symbol este un identificator unic, chiar și atunci când parametrul său de descriere este același (parametrul este doar pentru depanare).

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

Fără a utiliza Symbol s, iată cum arată când are loc o coliziune de denumire:

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

În acest moment, este și mai incomod să alegeți ce MessageResponder ar trebui utilizat, mai ales dacă containerul nostru DI crește. Folosirea Symbol s are grijă de asta și nu am venit cu literale ciudate de șir în cazul în care avem două clase cu același nume.

Utilizarea Containerului în aplicația Discord Bot

Acum, să modificăm clasa noastră Bot pentru a folosi containerul. Trebuie să adăugăm @injectable și @inject() pentru a face asta. Iată noua clasă 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); } }

În cele din urmă, să instanțiem botul nostru în fișierul 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) });

Acum, porniți botul și adăugați-l pe server. Apoi, dacă introduceți un mesaj pe canalul serverului, acesta ar trebui să apară în jurnalele de pe linia de comandă astfel:

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

În cele din urmă, avem bazele configurate: tipuri TypeScript și un container de injecție de dependență în interiorul botului nostru.

Implementarea logicii de afaceri

Să mergem direct la miezul despre ce este vorba în acest articol: crearea unei baze de cod testabile. Pe scurt, codul nostru ar trebui să implementeze cele mai bune practici (cum ar fi SOLID), să nu ascundă dependențele, să nu folosească metode statice.

De asemenea, nu ar trebui să introducă efecte secundare atunci când rulează și să fie ușor de batjocorit.

De dragul simplității, botul nostru va face un singur lucru: va căuta mesajele primite, iar dacă unul conține cuvântul „ping”, vom folosi una dintre comenzile disponibile pentru bot Discord pentru ca botul să răspundă cu „pong! ” la acel utilizator.

Pentru a arăta cum să injectăm obiecte personalizate în obiectul Bot și să le testăm unitar, vom crea două clase: PingFinder și MessageResponder . Vom injecta MessageResponder în clasa Bot și PingFinder în MessageResponder .

Iată fișierul 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; } }

Apoi injectăm acea clasă în fișierul 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(); } }

În cele din urmă, iată o clasă Bot modificată, care utilizează clasa 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); } }

În această stare, aplicația nu va rula deoarece nu există definiții pentru clasele MessageResponder și PingFinder . Să adăugăm următoarele la fișierul inversify.config.ts :

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

De asemenea, vom adăuga simboluri de tip la types.ts :

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

Acum, după repornirea aplicației noastre, bot-ul ar trebui să răspundă la fiecare mesaj care conține „ping”:

Botul care răspunde la un mesaj care conține cuvântul „ping”.

Și iată cum arată în jurnale:

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

Crearea testelor unitare

Acum că avem dependențe injectate corect, scrierea testelor unitare este ușoară. Vom folosi Chai și ts-mockito pentru asta; cu toate acestea, există mulți alți alergători de testare și biblioteci batjocoritoare pe care le-ați putea folosi.

Sintaxa batjocoritoare din ts-mockito este destul de verbosă, dar și ușor de înțeles. Iată cum să configurați serviciul MessageResponder și să injectați simularea PingFinder în el:

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

Acum că avem simulate configurate, putem defini care ar trebui să fie rezultatul apelurilor isPing() și să verificăm apelurile reply() . Ideea este că în testele unitare, definim rezultatul isPing() : true sau false . Nu contează care este conținutul mesajului, așa că în teste folosim doar "Non-empty string" .

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

Iată cum ar putea arăta întreaga suită de teste:

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

Testele pentru PingFinder sunt destul de banale, deoarece nu există dependențe de batjocorit. Iată un exemplu de caz de testare:

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

Crearea Testelor de Integrare

Pe lângă testele unitare, putem scrie și teste de integrare. Principala diferență este că dependențele din acele teste nu sunt batjocorite. Cu toate acestea, există unele dependențe care nu ar trebui testate, cum ar fi conexiunile API externe. În acest caz, putem crea imitații și le rebind la container, astfel încât simularea să fie injectată în schimb. Iată un exemplu despre cum să faci asta:

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

Acest lucru ne duce la sfârșitul tutorialului nostru despre bot Discord. Felicitări, ați construit-o curat, cu TypeScript și DI la loc de la început! Acest exemplu de injectare a dependenței TypeScript este un model pe care îl puteți adăuga la repertoriul dvs. pentru a fi utilizat cu orice proiect.

TypeScript și injecție de dependențe: nu doar pentru dezvoltarea Discord Bot

Aducerea lumii orientate pe obiecte a TypeScript în JavaScript este o îmbunătățire excelentă, indiferent dacă lucrăm la cod front-end sau back-end. Doar folosirea tipurilor ne permite să evităm multe erori. Injectarea dependenței în TypeScript împinge și mai multe bune practici orientate pe obiecte către dezvoltarea bazată pe JavaScript.

Desigur, din cauza limitărilor limbajului, nu va fi niciodată la fel de ușor și natural ca în limbile tipizate static. Dar un lucru este sigur: TypeScript, testele unitare și injecția de dependențe ne permit să scriem cod mai ușor de citit, mai ușor cuplat și mai ușor de întreținut, indiferent de ce fel de aplicație dezvoltăm.

Înrudit : Creați un chatbot WhatsApp, nu o aplicație