前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用 TypeScript 和依赖注入实现一个聊天机器人[每日前端夜话0x76]

使用 TypeScript 和依赖注入实现一个聊天机器人[每日前端夜话0x76]

作者头像
疯狂的技术宅
发布2019-05-31 20:52:29
11K0
发布2019-05-31 20:52:29
举报
文章被收录于专栏:京程一灯京程一灯

翻译:疯狂的技术宅

来源:toptal

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

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

  • Node.js
  • TypeScript
  • Discord.js,Discord API的包装器
  • InversifyJS,一个依赖注入框架
  • 测试库:Mocha,Chai和ts-mockito
  • Mongoose和MongoDB,以编写集成测试

设置 Node.js 项目

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

1npm init

注意:你也可以用 yarn,但为了简洁起见,我们用了 npm

这将会打开一个交互式向导,对 package.json 文件进行配置。对于所有问题,你只需简单的按回车键(或者如果需要,可以提供一些信息)。然后,安装我们的依赖项和 dev 依赖项(这些是测试所需的)。

1npm i --save typescript discord.js inversify dotenv @types/node reflect-metadata
2npm i --save-dev chai mocha ts-mockito ts-node @types/chai @types/mocha

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

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

为了能够递归地查找文件,需要在tests/**/*.spec.ts周围加上双引号。 (注意:在 Windows 下的语法可能会有所不同。)

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

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

typescript-bot",
 3  "version": "1.0.0",
 4  "description": "",
 5  "main": "index.js",
 6  "dependencies": {
 7    "@types/node": "^11.9.4",
 8    "discord.js": "^11.4.2",
 9    "dotenv": "^6.2.0",
10    "inversify": "^5.0.1",
11    "reflect-metadata": "^0.1.13",
12    "typescript": "^3.3.3"
13  },
14  "devDependencies": {
15    "@types/chai": "^4.1.7",
16    "@types/mocha": "^5.2.6",
17    "chai": "^4.2.0",
18    "mocha": "^5.2.0",
19    "ts-mockito": "^2.3.1",
20    "ts-node": "^8.0.3"
21  },
22  "scripts": {
23    "start": "node src/index.js",
24    "watch": "tsc -p tsconfig.json -w",
25    "test": "mocha -r ts-node/register \"tests/**/*.spec.ts\""
26  },
27  "author": "",
28  "license": "ISC"
29}

在 Discord 的控制面板中创建新应用程序

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

Discord的 "New Application" 按钮

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

将你的 Discord Bot 添加到你的服务器

为了测试我们的机器人,需要一台Discord服务器。你可以使用现有服务器或创建新服务器。复制机器人的 CLIENT_ID 并将其作为这个特殊授权URL (https://discordapp.com/developers/docs/topics/oauth2#bot-authorization-flow) 的一部分使用:

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

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

标准Discord欢迎消息

将bot添加到服务器后,你应该会看到如上所示的消息。

创建 .env 文件

我们需要一种能够在自己的程序中保存令牌的方法。为了做到这一点,我们将使用 dotenv 包。首先,从Discord Application Dashboard获取令牌(BotClick to Reveal Token):

“Click to Reveal Token”链接

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

1TOKEN=paste.the.token.here

如果你使用了 Git,则该文件应标注在 .gitignore 中,以事令牌不会被泄露。另外,创建一个 .env.example 文件,提醒你 TOKEN 需要定义:

1TOKEN=

编译TypeScript

要编译 TypeScript,可以使用 npm run watch 命令。或者,如果你用了其他 IDE,只需使用 TypeScript 插件中的文件监视器,让你的 IDE 去处理编译。让我们通过创建一个带有内容的 src/index.ts 文件来测试自己设置:

1console.log('Hello')

另外,让我们创建一个 tsconfig.json 文件,如下所示。 InversifyJS 需要experimentalDecoratorsemitDecoratorMetadataes6reflect-metadata

 1{
 2  "compilerOptions": {
 3    "module": "commonjs",
 4    "moduleResolution": "node",
 5    "target": "es2016",
 6    "lib": [
 7      "es6",
 8      "dom"
 9    ],
10    "sourceMap": true,
11    "types": [
12      // add node as an option
13      "node",
14      "reflect-metadata"
15    ],
16    "typeRoots": [
17      // add path to @types
18      "node_modules/@types"
19    ],
20    "experimentalDecorators": true,
21    "emitDecoratorMetadata": true,
22    "resolveJsonModule": true
23  },
24  "exclude": [
25    "node_modules"
26  ]
27}

如果文件观监视器正常工作,它应该生成一个 src/index.js文件,并运行 npm start

1> node src/index.js
2Hello

创建一个Bot类

现在,我们终于要开始使用 TypeScript 最有用的功能了:类型。继续创建以下 src/bot.ts 文件:

1import {Client, Message} from "discord.js";
2export class Bot {
3  public listen(): Promise<string> {
4    let client = new Client();
5    client.on('message', (message: Message) => {});
6    return client.login('token should be here');
7  }
8}

现在可以看到我们需要的东西:一个 token!我们是不是只需要将其复制粘贴到此处,或直接从环境中加载值就可以了呢?

都不是。相反,让我们用依赖注入框架 InversifyJS 来注入令牌,这样可以编写更易于维护、可扩展和可测试的代码。

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

配置依赖注入容器

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

InversifyJS 建议将依赖项放在 inversify.config.ts 文件中,所以让我们在那里添加 DI 容器:

 1import "reflect-metadata";
 2import {Container} from "inversify";
 3import {TYPES} from "./types";
 4import {Bot} from "./bot";
 5import {Client} from "discord.js";
 6
 7let container = new Container();
 8
 9container.bind<Bot>(TYPES.Bot).to(Bot).inSingletonScope();
10container.bind<Client>(TYPES.Client).toConstantValue(new Client());
11container.bind<string>(TYPES.Token).toConstantValue(process.env.TOKEN);
12
13export default container;

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

1export const TYPES = {
2  Bot: Symbol("Bot"),
3  Client: Symbol("Client"),
4  Token: Symbol("Token"),
5};

如果不使用 Symbol,将会发生以下命名冲突:

1Error: Ambiguous match found for serviceIdentifier: MessageResponder
2Registered bindings:
3 MessageResponder
4 MessageResponder

在这一点上,甚至更难以理清应该使用哪个 MessageResponder,特别是当我的 DI 容器扩展到很大时。如果使用 Symbol 来处理这个问题,在有两个具有相同名称的类的情况下,就不会出现这些奇怪的文字。

在 Discord Bot App 中使用 Container

现在,让我们通过修改 Bot 类来使用容器。我们需要添加 @injectable@inject() 注释来做到这一点。这是新的 Bot 类:

 1import {Client, Message} from "discord.js";
 2import {inject, injectable} from "inversify";
 3import {TYPES} from "./types";
 4import {MessageResponder} from "./services/message-responder";
 5
 6@injectable()
 7export class Bot {
 8  private client: Client;
 9  private readonly token: string;
10
11  constructor(
12    @inject(TYPES.Client) client: Client,
13    @inject(TYPES.Token) token: string
14  ) {
15    this.client = client;
16    this.token = token;
17  }
18
19  public listen(): Promise < string > {
20    this.client.on('message', (message: Message) => {
21      console.log("Message received! Contents: ", message.content);
22    });
23
24    return this.client.login(this.token);
25  }
26}

最后,让我们在 index.ts 文件中实例化 bot:

 1require('dotenv').config(); // Recommended way of loading dotenv
 2import container from "./inversify.config";
 3import {TYPES} from "./types";
 4import {Bot} from "./bot";
 5let bot = container.get<Bot>(TYPES.Bot);
 6bot.listen().then(() => {
 7  console.log('Logged in!')
 8}).catch((error) => {
 9  console.log('Oh no! ', error)
10});

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

1> node src/index.js
2
3Logged in!
4Message received! Contents:  Test

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

实现业务逻辑

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

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

为了简单起见,我们的机器人只做一件事:它将扫描传入的消息,如果其中包含单词“ping”,我们将用一个 Discord bot 命令让机器人对那个用户响应“pong! “。

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

这是 src/services/ping-finder.ts 文件:

 1import {injectable} from "inversify";
 2
 3@injectable()
 4export class PingFinder {
 5
 6  private regexp = 'ping';
 7
 8  public isPing(stringToSearch: string): boolean {
 9    return stringToSearch.search(this.regexp) >= 0;
10  }
11}

然后我们将该类注入 src/services/message-responder.ts 文件:

 1import {Message} from "discord.js";
 2import {PingFinder} from "./ping-finder";
 3import {inject, injectable} from "inversify";
 4import {TYPES} from "../types";
 5
 6@injectable()
 7export class MessageResponder {
 8  private pingFinder: PingFinder;
 9
10  constructor(
11    @inject(TYPES.PingFinder) pingFinder: PingFinder
12  ) {
13    this.pingFinder = pingFinder;
14  }
15
16  handle(message: Message): Promise<Message | Message[]> {
17    if (this.pingFinder.isPing(message.content)) {
18      return message.reply('pong!');
19    }
20
21    return Promise.reject();
22  }
23}

最后,这是一个修改过的 Bot 类,它使用 MessageResponder 类:

 1import {Client, Message} from "discord.js";
 2import {inject, injectable} from "inversify";
 3import {TYPES} from "./types";
 4import {MessageResponder} from "./services/message-responder";
 5
 6@injectable()
 7export class Bot {
 8  private client: Client;
 9  private readonly token: string;
10  private messageResponder: MessageResponder;
11
12  constructor(
13    @inject(TYPES.Client) client: Client,
14    @inject(TYPES.Token) token: string,
15    @inject(TYPES.MessageResponder) messageResponder: MessageResponder) {
16    this.client = client;
17    this.token = token;
18    this.messageResponder = messageResponder;
19  }
20
21  public listen(): Promise<string> {
22    this.client.on('message', (message: Message) => {
23      if (message.author.bot) {
24        console.log('Ignoring bot message!')
25        return;
26      }
27
28      console.log("Message received! Contents: ", message.content);
29
30      this.messageResponder.handle(message).then(() => {
31        console.log("Response sent!");
32      }).catch(() => {
33        console.log("Response not sent.")
34      })
35    });
36
37    return this.client.login(this.token);
38  }
39}

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

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

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

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

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

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

这是它在日志中的样子:

1> node src/index.js
2
3Logged in!
4Message received! Contents:  some message
5Response not sent.
6Message received! Contents:  message with ping
7Ignoring bot message!
8Response sent!

创建单元测试

现在我们已经正确地注入了依赖项,编写单元测试很容易。我们将使用 Chai 和 ts-mockito。不过你也可以使用其他测试器和模拟库。

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

1let mockedPingFinderClass = mock(PingFinder);
2let mockedPingFinderInstance = instance(mockedPingFinderClass);
3
4let service = new MessageResponder(mockedPingFinderInstance);

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

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

以下是整个测试代码:

 1import "reflect-metadata";
 2import 'mocha';
 3import {expect} from 'chai';
 4import {PingFinder} from "../../../src/services/ping-finder";
 5import {MessageResponder} from "../../../src/services/message-responder";
 6import {instance, mock, verify, when} from "ts-mockito";
 7import {Message} from "discord.js";
 8
 9describe('MessageResponder', () => {
10  let mockedPingFinderClass: PingFinder;
11  let mockedPingFinderInstance: PingFinder;
12  let mockedMessageClass: Message;
13  let mockedMessageInstance: Message;
14
15  let service: MessageResponder;
16
17  beforeEach(() => {
18    mockedPingFinderClass = mock(PingFinder);
19    mockedPingFinderInstance = instance(mockedPingFinderClass);
20    mockedMessageClass = mock(Message);
21    mockedMessageInstance = instance(mockedMessageClass);
22    setMessageContents();
23
24    service = new MessageResponder(mockedPingFinderInstance);
25  })
26
27  it('should reply', async () => {
28    whenIsPingThenReturn(true);
29
30    await service.handle(mockedMessageInstance);
31
32    verify(mockedMessageClass.reply('pong!')).once();
33  })
34
35  it('should not reply', async () => {
36    whenIsPingThenReturn(false);
37
38    await service.handle(mockedMessageInstance).then(() => {
39      // Successful promise is unexpected, so we fail the test
40      expect.fail('Unexpected promise');
41    }).catch(() => {
42     // Rejected promise is expected, so nothing happens here
43    });
44
45    verify(mockedMessageClass.reply('pong!')).never();
46  })
47
48  function setMessageContents() {
49    mockedMessageInstance.content = "Non-empty string";
50  }
51
52  function whenIsPingThenReturn(result: boolean) {
53    when(mockedPingFinderClass.isPing("Non-empty string")).thenReturn(result);
54  }
55});

“PingFinder” 的测试非常简单,因为没有依赖项被mock。这是一个测试用例的例子:

 1describe('PingFinder', () => {
 2  let service: PingFinder;
 3  beforeEach(() => {
 4    service = new PingFinder();
 5  })
 6
 7  it('should find "ping" in the string', () => {
 8    expect(service.isPing("ping")).to.be.true
 9  })
10});

创建集成测试

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

 1import container from "../../inversify.config";
 2import {TYPES} from "../../src/types";
 3// ...
 4
 5describe('Bot', () => {
 6  let discordMock: Client;
 7  let discordInstance: Client;
 8  let bot: Bot;
 9
10  beforeEach(() => {
11    discordMock = mock(Client);
12    discordInstance = instance(discordMock);
13    container.rebind<Client>(TYPES.Client)
14      .toConstantValue(discordInstance);
15    bot = container.get<Bot>(TYPES.Bot);
16  });
17
18  // Test cases here
19
20});

到这里我们的 Discord bot 教程就结束了。恭喜你干净利落地用 TypeScript 和 DI 完成了它!这里的 TypeScript 依赖项注入示例是一种模式,你可以将其添加到你的知识库中一遍在其他项目中使用。

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

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

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

原文:https://www.toptal.com/typescript/dependency-injection-discord-bot-tutorial

下面夹杂一些私货:也许你和高薪之间只差这一张图

2019年京程一灯课程体系上新,这是我们第一次将全部课程列表对外开放。

愿你有个好前程,愿你月薪30K。我们是认真的 !

长按二维码,加大鹏老师微信好友

拉你加入前端技术交流群

唠一唠怎样才能拿高薪

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-05-27,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端先锋 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 设置 Node.js 项目
  • 在 Discord 的控制面板中创建新应用程序
  • 将你的 Discord Bot 添加到你的服务器
  • 创建 .env 文件
  • 编译TypeScript
  • 创建一个Bot类
  • 配置依赖注入容器
  • 在 Discord Bot App 中使用 Container
  • 实现业务逻辑
  • 创建单元测试
  • 创建集成测试
  • TypeScript 和依赖注入:不仅仅用于 Discord Bot 开发
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档