Travailler avec TypeScript, l'injection de dépendances et les bots Discord

Publié: 2022-03-11

Les 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 :

Bouton "Nouvelle application" de Discord.

Choisissez un nom et cliquez sur Créer . Ensuite, cliquez sur BotAjouter 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é.

Message de bienvenue Discord standard en réponse à l'arrivée de notre bot sur le serveur.

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 ( BotCliquez pour révéler le jeton ):

Le lien "Cliquez pour révéler le jeton" dans la section Bot de Discord.

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":

Le bot répondant à un message contenant le mot "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.

En relation : Créer un chatbot WhatsApp, pas une application