使用 TypeScript、依赖注入和 Discord 机器人

已发表: 2022-03-11

类型和可测试代码是避免错误的两种最有效的方法,尤其是当代码随时间变化时。 我们可以分别利用 TypeScript 和依赖注入 (DI) 设计模式将这两种技术应用到 JavaScript 开发中。

在本 TypeScript 教程中,除了编译之外,我们不会直接介绍 TypeScript 基础知识。 相反,我们将简单地演示 TypeScript 最佳实践,因为我们将介绍如何从头开始制作 Discord 机器人、连接测试和 DI 以及创建示例服务。 我们将使用:

  • 节点.js
  • 打字稿
  • Discord.js,Discord API 的包装器
  • InversifyJS,一个依赖注入框架
  • 测试库:Mocha、Chai 和 ts-mockito
  • 奖励:Mongoose 和 MongoDB,以便编写集成测试

设置您的 Node.js 项目

首先,让我们创建一个名为typescript-bot的新目录。 然后,输入它并通过运行创建一个新的 Node.js 项目:

 npm init

注意:您也可以使用yarn ,但为了简洁起见,让我们坚持使用npm

这将打开一个交互式向导,它将设置package.json文件。 对于所有问题,您可以放心地按Enter 键(或者如果需要,也可以提供一些信息)。 然后,让我们安装我们的依赖项和开发依赖项(仅在测试中需要的那些)。

 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

然后,将package.json中生成的"scripts"部分替换为:

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

tests/**/*.spec.ts周围的双引号需要递归查找文件。 (注意:对于使用 Windows 的开发人员,语法可能会有所不同。)

start脚本将用于启动机器人, watch脚本将用于编译 TypeScript 代码,并使用test来运行测试。

现在,我们的package.json文件应该如下所示:

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

在 Discord 应用程序仪表板中创建新应用程序

为了与 Discord API 交互,我们需要一个令牌。 要生成这样的令牌,我们需要在 Discord Developer Dashboard 中注册一个应用程序。 为此,您需要创建一个 Discord 帐户并访问 https://discordapp.com/developers/applications/。 然后,单击新建应用程序按钮:

Discord 的“新应用程序”按钮。

选择一个名称并单击创建。 然后,单击BotAdd Bot ,您就完成了。 让我们将机器人添加到服务器。 但是不要关闭这个页面,我们很快就需要复制一个令牌。

将您的 Discord 机器人添加到您的服务器

为了测试我们的机器人,我们需要一个 Discord 服务器。 您可以使用现有服务器或创建新服务器。 为此,请复制机器人的CLIENT_ID (位于“常规信息”选项卡上)并将其用作此特殊授权 URL 的一部分:

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

当您在浏览器中点击此 URL 时,会出现一个表单,您可以在其中选择应添加机器人的服务器。

响应我们的机器人加入服务器的标准 Discord 欢迎消息。

将机器人添加到服务器后,您应该会看到类似上面的消息。

创建.env文件

我们需要一些方法将令牌保存在我们的应用程序中。 为此,我们将使用dotenv包。 首先,从 Discord Application Dashboard 获取令牌( BotClick to Reveal Token ):

Discord 的机器人部分中的“单击以显示令牌”链接。

现在,创建一个.env文件,然后在此处复制并粘贴令牌:

 TOKEN=paste.the.token.here

如果你使用 Git,那么这个文件应该放在.gitignore中,这样令牌就不会被泄露。 另外,创建一个.env.example文件,以便知道TOKEN需要定义:

 TOKEN=

编译 TypeScript

为了编译 TypeScript,你可以使用npm run watch命令。 或者,如果您使用 PHPStorm(或其他 IDE),只需使用其 TypeScript 插件中的文件观察器并让您的 IDE 处理编译。 让我们通过创建一个包含以下内容的src/index.ts文件来测试我们的设置:

 console.log('Hello')

另外,让我们创建一个tsconfig.json文件,如下所示。 InversifyJS 需要es6experimentalDecoratorsemitDecoratorMetadatareflect-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" ] }

如果文件观察程序正常工作,它应该生成一个src/index.js文件,并且运行npm start应该会导致:

 > node src/index.js Hello

创建机器人类

现在,让我们最终开始使用 TypeScript 最有用的特性:类型。 继续创建以下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'); } }

现在,我们可以在这里看到我们需要的东西:一个令牌! 我们是要直接复制粘贴到这里,还是直接从环境中加载值?

两者都不。 相反,让我们通过使用我们选择的依赖注入框架 InversifyJS 注入令牌来编写更可维护、可扩展和可测试的代码。

此外,我们可以看到Client依赖项是硬编码的。 我们也将注入它。

配置依赖注入容器

依赖注入容器是一个知道如何实例化其他对象的对象。 通常,我们为每个类定义依赖关系,DI 容器负责解决它们。

InversifyJS 建议将依赖项放在inversify.config.ts文件中,所以让我们继续在其中添加我们的 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;

此外,InversifyJS 文档建议创建一个types.ts文件,并列出我们将要使用的每种类型以及相关的Symbol 。 这很不方便,但它确保在我们的应用程序增长时不会发生命名冲突。 每个Symbol都是唯一的标识符,即使它的描述参数相同(该参数仅用于调试目的)。

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

在不使用Symbol的情况下,发生命名冲突时的外观如下:

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

此时,不方便理清应该使用哪个MessageResponder ,尤其是在我们的 DI 容器变大的情况下。 使用Symbol s 可以解决这个问题,并且在有两个具有相同名称的类的情况下,我们不会想出奇怪的字符串文字。

在 Discord Bot 应用程序中使用容器

现在,让我们修改我们的Bot类以使用容器。 我们需要添加@injectable@inject()注释来做到这一点。 这是新的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); } }

最后,让我们在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) });

现在,启动机器人并将其添加到您的服务器。 然后,如果您在服务器通道中键入一条消息,它应该会出现在命令行的日志中,如下所示:

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

最后,我们建立了基础:TypeScript 类型和机器人内部的依赖注入容器。

实现业务逻辑

让我们直接进入本文的核心内容:创建可测试的代码库。 简而言之,我们的代码应该实现最佳实践(如 SOLID),而不是隐藏依赖项,不使用静态方法。

此外,它在运行时不应引入副作用,并且易于模拟。

为简单起见,我们的机器人只做一件事:它将搜索传入的消息,如果其中包含“ping”一词,我们将使用可用的 Discord 机器人命令之一让机器人以“pong! ” 给那个用户。

为了展示如何将自定义对象注入Bot对象并对其进行单元测试,我们将创建两个类: PingFinderMessageResponder 。 我们将MessageResponder注入Bot类,并将PingFinder注入MessageResponder

这是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; } }

然后我们将该类注入到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(); } }

最后,这是一个修改后的Bot类,它使用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); } }

在这种状态下,应用程序将无法运行,因为没有MessageResponderPingFinder类的定义。 让我们将以下内容添加到inversify.config.ts文件中:

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

此外,我们将向types.ts添加类型符号:

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

现在,重新启动我们的应用程序后,机器人应该响应每条包含“ping”的消息:

机器人响应包含单词“ping”的消息。

这是它在日志中的样子:

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

创建单元测试

现在我们已经正确注入了依赖项,编写单元测试很容易。 我们将为此使用 Chai 和 ts-mockito; 但是,您可以使用许多其他测试运行程序和模拟库。

ts-mockito 中的模拟语法非常冗长,但也很容易理解。 以下是设置MessageResponder服务并将PingFinder模拟注入其中的方法:

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

现在我们已经设置了模拟,我们可以定义isPing()调用的结果应该是什么并验证reply()调用。 关键是在单元测试中,我们定义了isPing()调用的结果: truefalse 。 消息内容是什么并不重要,所以在测试中我们只使用"Non-empty string"

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

这是整个测试套件的样子:

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

PingFinder的测试非常简单,因为没有要模拟的依赖项。 这是一个示例测试用例:

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

创建集成测试

除了单元测试,我们还可以编写集成测试。 主要区别在于这些测试中的依赖关系没有被模拟。 但是,有些依赖项不应测试,例如外部 API 连接。 在这种情况下,我们可以创建模拟并将它们rebind到容器,以便注入模拟。 这是一个如何做到这一点的例子:

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

这使我们结束了 Discord 机器人教程。 恭喜,您从一开始就使用 TypeScript 和 DI 干净利落地构建了它! 这个 TypeScript 依赖注入示例是一种模式,您可以将其添加到您的曲目中以用于任何项目。

TypeScript 和依赖注入:不只是用于 Discord Bot 开发

将 TypeScript 的面向对象世界带入 JavaScript 是一个很大的改进,无论我们是在处理前端代码还是后端代码。 仅使用类型就可以避免许多错误。 在 TypeScript 中进行依赖注入将更多面向对象的最佳实践推向基于 JavaScript 的开发。

当然,由于语言的限制,它永远不会像静态类型语言那样简单自然。 但有一件事是肯定的:TypeScript、单元测试和依赖注入使我们能够编写更易读、松耦合和可维护的代码——无论我们正在开发什么样的应用程序。

相关:创建一个 WhatsApp 聊天机器人,而不是一个应用程序