使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

瘋狂的技術宅 前端先鋒 前端

每日前端夜話0x76
每日前端夜話,陪你聊前端。
天天晚上18:00準時推送。
正文共:3509 字
預計閱讀時間: 10 分鐘
翻譯:瘋狂的技術宅
來源:toptalnode

使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

類型和可測試代碼是避免錯誤的兩種最有效方法,尤爲是代碼隨會時間而變化。咱們能夠分別經過利用 TypeScript 和依賴注入(DI)將這兩種技術應用於JavaScript開發。git

在本 TypeScript 教程中,除編譯之外,咱們不會直接介紹 TypeScript 的基礎知識。相反,咱們將會演示 TypeScript 最佳實踐,由於咱們將介紹如何從頭開始製做 Discord bot、鏈接測試和 DI,以及建立示例服務。咱們將會使用:es6

  • Node.js
  • TypeScript
  • Discord.js,Discord API的包裝器
  • InversifyJS,一個依賴注入框架
  • 測試庫:Mocha,Chai和ts-mockito
  • Mongoose和MongoDB,以編寫集成測試

    設置 Node.js 項目

首先,讓咱們建立一個名爲 typescript-bot 的新目錄。而後輸入並經過運行如下命令建立一個新的 Node.js 項目:typescript

1npm init

注意:你也能夠用 yarn,但爲了簡潔起見,咱們用了 npm。npm

這將會打開一個交互式嚮導,對 package.json 文件進行配置。對於全部問題,你只需簡單的按回車鍵(或者若是須要,能夠提供一些信息)。而後,安裝咱們的依賴項和 dev 依賴項(這些是測試所需的)。json

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 下的語法可能會有所不一樣。)promise

start 腳本將用於啓動機器人,watch 腳本用於編譯 TypeScript 代碼,test用於運行測試。瀏覽器

如今,咱們的 package.json 文件應以下所示:

1{
 2  "name": "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 按鈕:
使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

Discord的 "New Application" 按鈕
選擇一個名稱,而後單擊建立。而後,單擊 Bot → Add 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時,會出現一個表單,你能夠在其中選擇應添加機器人的服務器。
使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

標準Discord歡迎消息
將bot添加到服務器後,你應該會看到如上所示的消息。

建立 .env 文件

咱們須要一種可以在本身的程序中保存令牌的方法。爲了作到這一點,咱們將使用 dotenv 包。首先,從Discord Application Dashboard獲取令牌(Bot → Click to Reveal Token):
使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

「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 須要experimentalDecorators,emitDecoratorMetadata,es6和reflect-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 對象並對它們進行單元測試,咱們將建立兩個類: PingFinder 和 MessageResponder。咱們將 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}

在當前狀態下,程序還沒法運行,由於沒有 MessageResponder 和 PingFinder 類的定義。讓咱們將如下內容添加到 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」 的每條消息:
使用 TypeScript 和依賴注入實現一個聊天機器人[每日前端夜話0x76]

機器人響應包含「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
4letservice=newMessageResponder(mockedPingFinderInstance);

如今咱們已經設置好了mocks ,咱們能夠定義 isPing() 調用的結果應該是什麼,並驗證 reply() 調用。在單元測試中的關鍵是定義 isPing():true 或 false 的結果。消息內容是什麼並不重要,因此在測試中咱們只使用 "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

相關文章
相關標籤/搜索