العمل مع TypeScript و Dependency Injection و Discord Bots

نشرت: 2022-03-11

تعد الأنواع والكود القابل للاختبار من أكثر الطرق فعالية لتجنب الأخطاء ، خاصة مع تغير الكود بمرور الوقت. يمكننا تطبيق هاتين الطريقتين لتطوير JavaScript من خلال الاستفادة من TypeScript ونمط تصميم حقن التبعية (DI) ، على التوالي.

في هذا البرنامج التعليمي TypeScript ، لن نغطي أساسيات TypeScript مباشرة ، باستثناء الترجمة. بدلاً من ذلك ، سنعرض ببساطة أفضل ممارسات TypeScript بينما نتعرف على كيفية إنشاء روبوت Discord من البداية ، وربط الاختبارات و DI ، وإنشاء خدمة نموذجية. سوف نستخدم:

  • Node.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

بعد ذلك ، استبدل قسم "scripts" المُنشأة في package.json التالي:

 "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 Apps Dashboard

من أجل التفاعل مع Discord API ، نحتاج إلى رمز مميز. لإنشاء مثل هذا الرمز المميز ، نحتاج إلى تسجيل تطبيق في Discord Developer Dashboard. للقيام بذلك ، تحتاج إلى إنشاء حساب Discord والانتقال إلى https://discordapp.com/developers/applications/. بعد ذلك ، انقر فوق الزر تطبيق جديد :

زر "التطبيق الجديد" الخاص بـ Discord.

اختر اسمًا وانقر فوق إنشاء . ثم ، انقر فوق BotAdd Bot ، وبذلك تكون قد انتهيت. دعونا نضيف الروبوت إلى الخادم. لكن لا تغلق هذه الصفحة حتى الآن ، سنحتاج إلى نسخ رمز قريبًا.

أضف ديسكورد بوت إلى الخادم الخاص بك

من أجل اختبار الروبوت الخاص بنا ، نحتاج إلى خادم Discord. يمكنك استخدام خادم موجود أو إنشاء خادم جديد. للقيام بذلك ، انسخ CLIENT_ID الخاص بالروبوت - الموجود في علامة التبويب معلومات عامة - واستخدمه كجزء من عنوان URL الخاص بهذا الترخيص:

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

عندما تضغط على عنوان URL هذا في المستعرض ، يظهر نموذج يمكنك من خلاله اختيار الخادم الذي يجب إضافة الروبوت إليه.

رسالة ترحيب من Standard Discord ردًا على انضمام روبوتنا إلى الخادم.

بعد إضافة الروبوت إلى الخادم الخاص بك ، يجب أن ترى رسالة مثل المذكورة أعلاه.

إنشاء ملف .env

نحتاج إلى طريقة ما لحفظ الرمز المميز في تطبيقنا. للقيام بذلك ، سنستخدم حزمة dotenv . أولاً ، احصل على الرمز المميز من لوحة معلومات تطبيق Discord ( بوتانقر للكشف عن الرمز ):

رابط "انقر للكشف عن الرمز المميز" في قسم برنامج Discord's Bot.

الآن ، قم بإنشاء ملف .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: الأنواع. انطلق وأنشئ ملف 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 s ، إليك كيفية ظهورها عند حدوث تضارب في التسمية:

 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 bot المتاحة لجعل الروبوت يستجيب بـ "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":

يستجيب الروبوت لرسالة تحتوي على كلمة "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 bot. تهانينا ، لقد قمت ببنائه بطريقة نظيفة ، مع وجود TypeScript و DI في مكانه منذ البداية! مثال حقن تبعية TypeScript هو نمط يمكنك إضافته إلى ذخيرتك لاستخدامه مع أي مشروع.

TypeScript و Dependency Injection: ليس فقط لتطوير Discord Bot

يعد جلب عالم TypeScript الموجه للكائنات إلى JavaScript تحسينًا رائعًا ، سواء كنا نعمل على كود الواجهة الأمامية أو الخلفية. مجرد استخدام الأنواع وحدها يسمح لنا بتجنب العديد من الأخطاء. يؤدي إدخال التبعية في TypeScript إلى دفع أفضل الممارسات الموجهة للكائنات إلى التطوير المستند إلى JavaScript.

بالطبع ، بسبب قيود اللغة ، لن تكون أبدًا سهلة وطبيعية كما هو الحال في اللغات المكتوبة بشكل ثابت. ولكن هناك شيء واحد مؤكد: يتيح لنا TypeScript واختبارات الوحدة وإدخال التبعية كتابة كود أكثر قابلية للقراءة ، وقابل للإقران بشكل غير محكم ، ويمكن صيانته - بغض النظر عن نوع التطبيق الذي نطوره.

ذات صلة: إنشاء WhatsApp Chatbot ، وليس تطبيقًا