个性化阅读
专注于IT技术分析

使用TypeScript,依赖注入和Discord Bot

点击下载

本文概述

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

在本TypeScript教程中, 除编译外, 我们将不直接介绍TypeScript基础。取而代之的是, 我们将逐步演示如何使用TypeScript最佳实践, 从头开始制作Discord机器人, 连接测试和DI以及创建示例服务。我们将使用:

  • Node.js
  • TypeScript
  • 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": {
  "start": "node src/index.js", "watch": "tsc -p tsconfig.json -w", "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\""
}, 

要递归查找文件, 需要在tests / ** / *。spec.ts周围加上双引号。 (注意:语法可能会因使用Windows的开发人员而异。)

启动脚本将用于启动bot, 监视脚本将用于编译TypeScript代码, 并进行测试以运行测试。

现在, 我们的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 Apps资讯主页中建立新的应用程式

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

Discord的"新应用程序"按钮。

选择一个名称, 然后单击创建。然后, 单击Bot→添加Bot, 你已完成。让我们将机器人添加到服务器。但请不要关闭此页面, 我们需要尽快复制令牌。

将Discord Bot添加到服务器

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

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

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

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

将漫游器添加到服务器后, 你应该会看到类似以上的消息。

创建.env文件

我们需要某种方式将令牌保存在我们的应用程序中。为此, 我们将使用dotenv软件包。首先, 从Discord Application Dashboard获取令牌(Bot→单击以显示令牌):

Discord的Bot部分中的"单击以显示令牌"链接。

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

TOKEN=paste.the.token.here

如果使用Git, 则应将此文件放置在.gitignore中, 以使令牌不会受到损害。另外, 创建一个.env.example文件, 以便知道令牌需要定义:

TOKEN=

编译TypeScript

为了编译TypeScript, 可以使用npm run watch命令。另外, 如果你使用PHPStorm(或其他IDE), 则只需使用其TypeScript插件中的文件监视程序, 然后让你的IDE处理编译即可。让我们通过创建包含以下内容的src / index.ts文件来测试设置:

console.log('Hello')

另外, 让我们创建一个tsconfig.json文件, 如下所示。 InversifyJS需要experimentalDecorators, emitDecoratorMetadata, es6和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

创建Bot类

现在, 让我们最后开始使用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。这是很不方便的, 但是可以确保在我们的应用程序增长时不会发生命名冲突。每个符号都是唯一的标识符, 即使其描述参数相同(该参数仅用于调试目的)也是如此。

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

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

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

在这一点上, 要选择应该使用哪种MessageResponder更加不便, 尤其是当我们的DI容器变大时。使用Symbols可以解决这一问题, 并且在具有两个相同名称的类的情况下, 我们不会想出奇怪的字符串文字。

在Discord Bot App中使用容器

现在, 让我们修改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();

另外, 我们将向type.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()调用的结果:true或false。邮件的内容无关紧要, 因此在测试中, 我们只使用”非空字符串”。

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连接。在这种情况下, 我们可以创建模拟并将其重新绑定到容器, 以便注入模拟。这是有关此操作的示例:

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和依赖注入:不仅用于不和谐的Bot开发

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

当然, 由于语言的限制, 它永远不会像静态类型的语言那样简单自然。但是可以肯定的是:无论我们开发的是哪种应用程序, TypeScript, 单元测试和依赖项注入都使我们能够编写更具可读性, 松耦合和可维护的代码。

赞(0)
未经允许不得转载:srcmini » 使用TypeScript,依赖注入和Discord Bot

评论 抢沙发

评论前必须登录!