Lucrul cu TypeScript, Dependency Injection și Discord Bots
Publicat: 2022-03-11Tipurile ș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ă :
Alegeți un nume și faceți clic pe Creare . Apoi, faceți clic pe Bot → Adă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.
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 ( Bot → Faceți clic pentru a dezvălui jeton ):
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”:
Ș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.