使用 TypeScript 和依賴注入實現一個聊天機器人

翻譯:瘋狂的技術宅
原文:https://www.toptal.com/typesc...

本文首發微信公衆號:前端先鋒
歡迎關注,每天都給你推送新鮮的前端技術文章


clipboard.png

類型和可測試代碼是避免錯誤的兩種最有效方法,尤其是代碼隨會時間而變化。我們可以分別通過利用 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 項目:

npm init

注意:你也可以用 yarn,但爲了簡潔起見,我們用了 npm

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

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 開發面板中註冊一個應用。爲此,你需要創建一個 Discord 帳戶並轉到 https://discordapp.com/develo...。然後,單擊 New Application 按鈕:

clipboard.png

選擇一個名稱,然後單擊創建。然後,單擊 BotAdd Bot,你就完成了。讓我們將機器人添加到服務器。但是不要關閉此頁面,我們需要儘快複製令牌。

將你的 Discord Bot 添加到你的服務器

爲了測試我們的機器人,需要一臺Discord服務器。你可以使用現有服務器或創建新服務器。複製機器人的 CLIENT_ID 並將其作爲這個特殊授權URL (https://discordapp.com/develo...) 的一部分使用:

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

當你在瀏覽器中點擊此URL時,會出現一個表單,你可以在其中選擇應添加機器人的服務器。

clipboard.png

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

創建 .env 文件

我們需要一種能夠在自己的程序中保存令牌的方法。爲了做到這一點,我們將使用 dotenv 包。首先,從Discord Application Dashboard獲取令牌(BotClick to Reveal Token):

clipboard.png

現在創建一個 .env 文件,然後在此處複製並粘貼令牌:

TOKEN=paste.the.token.here

如果你使用了 Git,則該文件應標註在 .gitignore 中,以事令牌不會被泄露。另外,創建一個 .env.example 文件,提醒你 TOKEN 需要定義:

TOKEN=

編譯TypeScript

要編譯 TypeScript,可以使用 npm run watch 命令。或者,如果你用了其他 IDE,只需使用 TypeScript 插件中的文件監視器,讓你的 IDE 去處理編譯。讓我們通過創建一個帶有內容的 src/index.ts 文件來測試自己設置:

console.log('Hello')

另外,讓我們創建一個 tsconfig.json 文件,如下所示。 InversifyJS 需要experimentalDecoratorsemitDecoratorMetadataes6reflect-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');
  }
}

現在可以看到我們需要的東西:一個 token!我們是不是只需要將其複製粘貼到此處,或直接從環境中加載值就可以了呢?

都不是。相反,讓我們用依賴注入框架 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 來處理這個問題,在有兩個具有相同名稱的類的情況下,就不會出現這些奇怪的文字。

在 Discord Bot App 中使用 Container

現在,讓我們通過修改 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 文件中實例化 bot:

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 bot 命令讓機器人對那個用戶響應“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” 的每條消息:

clipboard.png

這是它在日誌中的樣子:

> 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 mock 注入其中:

let mockedPingFinderClass = mock(PingFinder);
let mockedPingFinderInstance = instance(mockedPingFinderClass);

let service = new MessageResponder(mockedPingFinderInstance);

現在我們已經設置好了mocks ,我們可以定義 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” 的測試非常簡單,因爲沒有依賴項被mock。這是一個測試用例的例子:

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 bot 教程就結束了。恭喜你乾淨利落地用 TypeScript 和 DI 完成了它!這裏的 TypeScript 依賴項注入示例是一種模式,你可以將其添加到你的知識庫中一遍在其他項目中使用。

TypeScript 和依賴注入:不僅僅用於 Discord Bot 開發

無論我們是處理前端還是後端代碼,將 TypeScript 的面向對象引入 JavaScript 都是一個很大的改進。僅僅使用類型就可以避免許多錯誤。在 TypeScript 中進行依賴注入會將更多面向對象的最佳實踐推向基於 JavaScript 的開發。

當然由於語言的侷限性,它永遠不會像靜態類型語言那樣容易和自然。但有一件事是肯定的:TypeScript、單元測試和依賴注入允許我們編寫更易讀、鬆散耦合和可維護的代碼 —— 無論我們正在開發什麼類型的應用。


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,每天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,每天都給你推送新鮮的前端技術文章


歡迎繼續閱讀本專欄其它高贊文章:


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章