TypeScript、依存性注入、Discordボットの操作
公開: 2022-03-11タイプとテスト可能なコードは、特にコードが時間の経過とともに変化する場合に、バグを回避するための最も効果的な2つの方法です。 TypeScriptと依存性注入(DI)デザインパターンをそれぞれ活用することで、これら2つの手法をJavaScript開発に適用できます。
このTypeScriptチュートリアルでは、コンパイルを除いて、TypeScriptの基本については直接説明しません。 代わりに、Discordボットを最初から作成し、テストとDIを接続し、サンプルサービスを作成する方法を説明しながら、TypeScriptのベストプラクティスを簡単に示します。 使用します:
- Node.js
- TypeScript
- Discord.js、DiscordAPIのラッパー
- 依存性注入フレームワークである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" }
DiscordAppsダッシュボードでの新しいアプリケーションの作成
Discord APIと対話するには、トークンが必要です。 このようなトークンを生成するには、DiscordDeveloperDashboardにアプリを登録する必要があります。 これを行うには、Discordアカウントを作成し、https://discordapp.com/developers/applications/にアクセスする必要があります。 次に、[新しいアプリケーション]ボタンをクリックします。
名前を選択し、[作成]をクリックします。 次に、[ボット]→[ボットの追加]をクリックすると、完了です。 ボットをサーバーに追加しましょう。 ただし、まだこのページを閉じないでください。すぐにトークンをコピーする必要があります。
Discordボットをサーバーに追加する
ボットをテストするには、Discordサーバーが必要です。 既存のサーバーを使用することも、新しいサーバーを作成することもできます。 これを行うには、ボットのCLIENT_ID
([一般情報]タブにあります)をコピーし、次の特別な認証URLの一部として使用します。
https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot
ブラウザでこのURLを押すと、ボットを追加するサーバーを選択できるフォームが表示されます。
ボットをサーバーに追加すると、上記のようなメッセージが表示されます。
.env
ファイルの作成
アプリにトークンを保存する方法が必要です。 そのために、 dotenv
パッケージを使用します。 まず、Discordアプリケーションダッシュボードからトークンを取得します(ボット→クリックしてトークンを表示します)。
次に、 .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には、 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
ボットクラスの作成
それでは、ついにTypeScriptの最も便利な機能であるtypesの使用を始めましょう。 先に進み、次の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
この時点で、特にDIコンテナが大きくなる場合は、どのMessageResponder
を使用するかを分類するのはさらに不便です。 Symbol
sを使用するとそれが処理され、同じ名前の2つのクラスがある場合に奇妙な文字列リテラルが発生することはありません。
DiscordBotアプリでのコンテナーの使用
それでは、コンテナを使用するように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など)を実装する必要があります。
また、実行時に副作用が発生しないようにし、簡単にモックできるようにする必要があります。
簡単にするために、ボットは1つのことだけを実行します。着信メッセージを検索し、「ping」という単語が含まれている場合は、使用可能なDiscordボットコマンドの1つを使用して、ボットに「pong! 」 そのユーザーに。
カスタムオブジェクトをBot
オブジェクトに挿入して単体テストする方法を示すために、 PingFinder
とMessageResponder
の2つのクラスを作成します。 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(); } }
最後に、 MessageResponder
クラスを使用する変更された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; 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ボット開発だけではありません
TypeScriptのオブジェクト指向の世界をJavaScriptに取り入れることは、フロントエンドコードとバックエンドコードのどちらで作業している場合でも、大きな拡張機能です。 型だけを使用するだけで、多くのバグを回避できます。 TypeScriptに依存性注入を行うと、さらにオブジェクト指向のベストプラクティスがJavaScriptベースの開発にプッシュされます。
もちろん、言語の制限のため、静的に型付けされた言語ほど簡単で自然なことはありません。 ただし、確かなことが1つあります。それは、TypeScript、単体テスト、依存性注入により、開発しているアプリの種類に関係なく、より読みやすく、疎結合で、保守しやすいコードを記述できることです。