使用 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/。 然后,单击新建应用程序按钮:
选择一个名称并单击创建。 然后,单击Bot → Add Bot ,您就完成了。 让我们将机器人添加到服务器。 但是不要关闭这个页面,我们很快就需要复制一个令牌。
将您的 Discord 机器人添加到您的服务器
为了测试我们的机器人,我们需要一个 Discord 服务器。 您可以使用现有服务器或创建新服务器。 为此,请复制机器人的CLIENT_ID
(位于“常规信息”选项卡上)并将其用作此特殊授权 URL 的一部分:
https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot
当您在浏览器中点击此 URL 时,会出现一个表单,您可以在其中选择应添加机器人的服务器。
将机器人添加到服务器后,您应该会看到类似上面的消息。
创建.env
文件
我们需要一些方法将令牌保存在我们的应用程序中。 为此,我们将使用dotenv
包。 首先,从 Discord Application Dashboard 获取令牌( Bot → Click to Reveal Token ):
现在,创建一个.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 需要es6
、 experimentalDecorators
、 emitDecoratorMetadata
和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" ] }
如果文件观察程序正常工作,它应该生成一个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
对象并对其进行单元测试,我们将创建两个类: PingFinder
和MessageResponder
。 我们将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); } }
在这种状态下,应用程序将无法运行,因为没有MessageResponder
和PingFinder
类的定义。 让我们将以下内容添加到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”的消息:
这是它在日志中的样子:
> 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()
调用的结果: true
或false
。 消息内容是什么并不重要,所以在测试中我们只使用"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、单元测试和依赖注入使我们能够编写更易读、松耦合和可维护的代码——无论我们正在开发什么样的应用程序。