使用 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、單元測試和依賴注入使我們能夠編寫更易讀、松耦合和可維護的代碼——無論我們正在開發什麼樣的應用程序。