Travailler avec TypeScript, l'injection de dépendances et les bots Discord
Publié: 2022-03-11Les types et le code testable sont deux des moyens les plus efficaces d'éviter les bogues, en particulier lorsque le code change au fil du temps. Nous pouvons appliquer ces deux techniques au développement JavaScript en exploitant respectivement TypeScript et le modèle de conception par injection de dépendances (DI).
Dans ce didacticiel TypeScript, nous n'aborderons pas directement les bases de TypeScript, à l'exception de la compilation. Au lieu de cela, nous allons simplement démontrer les meilleures pratiques TypeScript en expliquant comment créer un bot Discord à partir de zéro, connecter des tests et DI, et créer un exemple de service. Nous utiliserons :
- Node.js
- Manuscrit
- Discord.js, un wrapper pour l'API Discord
- InversifyJS, un framework d'injection de dépendances
- Bibliothèques de test : Mocha, Chai et ts-mockito
- Bonus : Mongoose et MongoDB, afin d'écrire un test d'intégration
Configuration de votre projet Node.js
Commençons par créer un nouveau répertoire appelé typescript-bot
. Ensuite, saisissez-le et créez un nouveau projet Node.js en exécutant :
npm init
Remarque : vous pouvez également utiliser du yarn
pour cela, mais restons-en à npm
pour plus de brièveté.
Cela ouvrira un assistant interactif, qui configurera le fichier package.json
. Vous pouvez simplement appuyer sur Entrée pour toutes les questions (ou fournir des informations si vous le souhaitez). Ensuite, installons nos dépendances et dépendances dev (celles qui ne sont nécessaires que pour les tests).
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
Ensuite, remplacez la section "scripts"
générée dans package.json
par :
"scripts": { "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\"" },
Les guillemets autour de tests/**/*.spec.ts
sont nécessaires pour rechercher des fichiers de manière récursive. (Remarque : la syntaxe peut varier pour les développeurs utilisant Windows.)
Le script de start
sera utilisé pour démarrer le bot, le script watch
pour compiler le code TypeScript et test
pour exécuter les tests.
Maintenant, notre fichier package.json
devrait ressembler à ceci :
{ "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" }
Créer une nouvelle application dans le tableau de bord des applications Discord
Pour interagir avec l'API Discord, nous avons besoin d'un jeton. Pour générer un tel jeton, nous devons enregistrer une application dans le tableau de bord du développeur Discord. Pour ce faire, vous devez créer un compte Discord et vous rendre sur https://discordapp.com/developers/applications/. Cliquez ensuite sur le bouton Nouvelle application :
Choisissez un nom et cliquez sur Créer . Ensuite, cliquez sur Bot → Ajouter un bot et vous avez terminé. Ajoutons le bot à un serveur. Mais ne fermez pas encore cette page, nous aurons bientôt besoin de copier un jeton.
Ajoutez votre bot Discord à votre serveur
Afin de tester notre bot, nous avons besoin d'un serveur Discord. Vous pouvez utiliser un serveur existant ou en créer un nouveau. Pour ce faire, copiez le CLIENT_ID
du bot, qui se trouve dans l'onglet Informations générales, et utilisez-le dans le cadre de cette URL d'autorisation spéciale :
https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot
Lorsque vous cliquez sur cette URL dans un navigateur, un formulaire apparaît dans lequel vous pouvez choisir le serveur sur lequel le bot doit être ajouté.
Après avoir ajouté le bot à votre serveur, vous devriez voir un message comme celui ci-dessus.
Création du fichier .env
Nous avons besoin d'un moyen de sauvegarder le jeton dans notre application. Pour ce faire, nous allons utiliser le package dotenv
. Tout d'abord, obtenez le jeton à partir du tableau de bord de l'application Discord ( Bot → Cliquez pour révéler le jeton ):
Maintenant, créez un fichier .env
, puis copiez et collez le jeton ici :
TOKEN=paste.the.token.here
Si vous utilisez Git, ce fichier doit être placé dans .gitignore
, afin que le jeton ne soit pas compromis. Créez également un fichier .env.example
, afin que l'on sache que TOKEN
doit être défini :
TOKEN=
Compiler TypeScript
Pour compiler TypeScript, vous pouvez utiliser la commande npm run watch
. Alternativement, si vous utilisez PHPStorm (ou un autre IDE), utilisez simplement son observateur de fichiers à partir de son plugin TypeScript et laissez votre IDE gérer la compilation. Testons notre configuration en créant un fichier src/index.ts
avec le contenu :
console.log('Hello')
Créons également un fichier tsconfig.json
comme ci-dessous. InversifyJS nécessite experimentalDecorators
, emitDecoratorMetadata
, es6
et 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" ] }
Si le file watcher fonctionne correctement, il devrait générer un fichier src/index.js
, et l'exécution de npm start
devrait donner :
> node src/index.js Hello
Création d'une classe de bot
Maintenant, commençons enfin à utiliser la fonctionnalité la plus utile de TypeScript : les types. Allez-y et créez le fichier 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'); } }
Maintenant, nous pouvons voir ce dont nous avons besoin ici : un jeton ! Allons-nous simplement copier-coller ici, ou charger la valeur directement depuis l'environnement ?
Non plus. Au lieu de cela, écrivons un code plus maintenable, extensible et testable en injectant le jeton à l'aide de notre framework d'injection de dépendances de choix, InversifyJS.
De plus, nous pouvons voir que la dépendance Client
est codée en dur. Nous allons également l'injecter.
Configuration du conteneur d'injection de dépendances
Un conteneur d'injection de dépendances est un objet qui sait instancier d'autres objets. En règle générale, nous définissons des dépendances pour chaque classe et le conteneur DI se charge de les résoudre.
InversifyJS recommande de placer les dépendances dans un fichier inversify.config.ts
, alors allons-y et ajoutons-y notre conteneur 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;
En outre, la documentation InversifyJS recommande de créer un fichier types.ts
et de répertorier chaque type que nous allons utiliser, ainsi qu'un Symbol
. C'est assez gênant, mais cela garantit qu'il n'y a pas de collisions de noms à mesure que notre application se développe. Chaque Symbol
est un identifiant unique, même lorsque son paramètre de description est le même (le paramètre est uniquement à des fins de débogage).
export const TYPES = { Bot: Symbol("Bot"), Client: Symbol("Client"), Token: Symbol("Token"), };
Sans utiliser Symbol
s, voici à quoi cela ressemble lorsqu'une collision de noms se produit :
Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder
À ce stade, il est encore plus difficile de déterminer quel MessageResponder
doit être utilisé, surtout si notre conteneur DI devient volumineux. L'utilisation de Symbol
s s'occupe de cela, et nous n'avons pas trouvé de littéraux de chaîne étranges dans le cas où deux classes portent le même nom.
Utilisation du conteneur dans l'application Discord Bot
Maintenant, modifions notre classe Bot
pour utiliser le conteneur. Nous devons ajouter @injectable
et @inject()
pour ce faire. Voici la nouvelle 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); } }
Enfin, instancions notre bot dans le fichier 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) });
Maintenant, démarrez le bot et ajoutez-le à votre serveur. Ensuite, si vous tapez un message dans le canal du serveur, il devrait apparaître dans les journaux sur la ligne de commande comme suit :
> node src/index.js Logged in! Message received! Contents: Test
Enfin, nous avons mis en place les fondations : les types TypeScript et un conteneur d'injection de dépendances à l'intérieur de notre bot.
Implémentation de la logique métier
Allons directement au cœur du sujet de cet article : créer une base de code testable. En bref, notre code doit implémenter les meilleures pratiques (comme SOLID), ne pas masquer les dépendances, ne pas utiliser de méthodes statiques.
De plus, il ne devrait pas introduire d'effets secondaires lors de son exécution et être facilement simulable.
Par souci de simplicité, notre bot ne fera qu'une chose : il recherchera les messages entrants, et si l'un contient le mot "ping", nous utiliserons l'une des commandes de bot Discord disponibles pour que le bot réponde par "pong ! ” à cet utilisateur.
Afin de montrer comment injecter des objets personnalisés dans l'objet Bot
et les tester unitairement, nous allons créer deux classes : PingFinder
et MessageResponder
. Nous allons injecter MessageResponder
dans la classe Bot
et PingFinder
dans MessageResponder
.
Voici le fichier 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; } }
Nous injectons ensuite cette classe dans le fichier 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(); } }
Enfin, voici une classe Bot
modifiée, qui utilise 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); } }
Dans cet état, l'application ne pourra pas s'exécuter car il n'y a pas de définitions pour les classes MessageResponder
et PingFinder
. Ajoutons ce qui suit au fichier inversify.config.ts
:
container.bind<MessageResponder>(TYPES.MessageResponder).to(MessageResponder).inSingletonScope(); container.bind<PingFinder>(TYPES.PingFinder).to(PingFinder).inSingletonScope();
De plus, nous allons ajouter des symboles de type à types.ts
:
MessageResponder: Symbol("MessageResponder"), PingFinder: Symbol("PingFinder"),
Maintenant, après avoir redémarré notre application, le bot devrait répondre à chaque message contenant "ping":
Et voici à quoi cela ressemble dans les journaux :
> 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!
Création de tests unitaires
Maintenant que nous avons correctement injecté les dépendances, écrire des tests unitaires est facile. Nous allons utiliser Chai et ts-mockito pour cela ; cependant, il existe de nombreux autres exécuteurs de test et bibliothèques fictives que vous pouvez utiliser.
La syntaxe moqueuse dans ts-mockito est assez verbeuse, mais aussi facile à comprendre. Voici comment configurer le service MessageResponder
et y injecter le mock PingFinder
:
let mockedPingFinderClass = mock(PingFinder); let mockedPingFinderInstance = instance(mockedPingFinderClass); let service = new MessageResponder(mockedPingFinderInstance);
Maintenant que nous avons mis en place des simulations, nous pouvons définir le résultat des isPing()
et vérifier les appels de reply()
. Le fait est que dans les tests unitaires, nous définissons le résultat de l'appel isPing()
: true
ou false
. Peu importe le contenu du message, donc dans les tests, nous utilisons simplement "Non-empty string"
.
when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(true); await service.handle(mockedMessageInstance) verify(mockedMessageClass.reply('pong!')).once();
Voici à quoi pourrait ressembler toute la suite de tests :
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); } });
Les tests pour PingFinder
sont assez triviaux car il n'y a pas de dépendances à se moquer. Voici un exemple de cas de test :
describe('PingFinder', () => { let service: PingFinder; beforeEach(() => { service = new PingFinder(); }) it('should find "ping" in the string', () => { expect(service.isPing("ping")).to.be.true }) });
Création de tests d'intégration
Outre les tests unitaires, nous pouvons également écrire des tests d'intégration. La principale différence est que les dépendances dans ces tests ne sont pas moquées. Cependant, certaines dépendances ne doivent pas être testées, comme les connexions API externes. Dans ce cas, nous pouvons créer des mocks et les rebind
au conteneur, de sorte que le mock soit injecté à la place. Voici un exemple de la façon de procéder :
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 });
Cela nous amène à la fin de notre tutoriel sur le bot Discord. Félicitations, vous l'avez construit proprement, avec TypeScript et DI en place dès le départ ! Cet exemple d'injection de dépendance TypeScript est un modèle que vous pouvez ajouter à votre répertoire pour l'utiliser avec n'importe quel projet.
TypeScript et injection de dépendances : pas seulement pour le développement de bots Discord
L'introduction du monde orienté objet de TypeScript dans JavaScript est une grande amélioration, que nous travaillions sur du code frontal ou principal. Le simple fait d'utiliser les types seuls nous permet d'éviter de nombreux bogues. L'injection de dépendances dans TypeScript pousse encore plus les meilleures pratiques orientées objet vers le développement basé sur JavaScript.
Bien sûr, à cause des limitations du langage, cela ne sera jamais aussi simple et naturel que dans les langages à typage statique. Mais une chose est sûre : TypeScript, les tests unitaires et l'injection de dépendances nous permettent d'écrire un code plus lisible, faiblement couplé et maintenable, quel que soit le type d'application que nous développons.