การทำงานกับ TypeScript, Dependency Injection และ Discord Bots
เผยแพร่แล้ว: 2022-03-11ประเภทและโค้ดที่ทดสอบได้ เป็นสองวิธีที่มีประสิทธิภาพที่สุดในการหลีกเลี่ยงจุดบกพร่อง โดยเฉพาะอย่างยิ่งเมื่อโค้ดมีการเปลี่ยนแปลงเมื่อเวลาผ่านไป เราสามารถใช้สองเทคนิคนี้ในการพัฒนา JavaScript โดยใช้ประโยชน์จาก TypeScript และรูปแบบการออกแบบการพึ่งพา (DI) ตามลำดับ
ในบทช่วยสอนของ TypeScript นี้ เราจะไม่พูดถึงพื้นฐานของ TypeScript โดยตรง ยกเว้นการรวบรวม แต่เราจะแสดงวิธีปฏิบัติที่ดีที่สุดของ TypeScript ในขณะที่เราแนะนำวิธีสร้างบอท Discord ตั้งแต่เริ่มต้น เชื่อมต่อการทดสอบและ 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
คุณสามารถกด Enter สำหรับคำถามทั้งหมดได้อย่างปลอดภัย (หรือให้ข้อมูลบางอย่างหากต้องการ) จากนั้น มาติดตั้งการพึ่งพาและการพึ่งพา 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
จากนั้นแทนที่ส่วน "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
ในการโต้ตอบกับ Discord API เราจำเป็นต้องมีโทเค็น ในการสร้างโทเค็นดังกล่าว เราจำเป็นต้องลงทะเบียนแอพใน Discord Developer Dashboard ในการทำเช่นนั้น คุณต้องสร้างบัญชี Discord และไปที่ https://discordapp.com/developers/applications/ จากนั้นคลิกปุ่ม แอปพลิเคชันใหม่ :
เลือกชื่อแล้วคลิก สร้าง จากนั้นคลิก Bot → Add Bot และทำเสร็จแล้ว มาเพิ่มบอทในเซิร์ฟเวอร์กันเถอะ แต่อย่าเพิ่งปิดหน้านี้ เราจำเป็นต้องคัดลอกโทเค็นในเร็วๆ นี้
เพิ่ม Discord Bot ของคุณไปยังเซิร์ฟเวอร์ของคุณ
ในการทดสอบบอท เราจำเป็นต้องมีเซิร์ฟเวอร์ Discord คุณสามารถใช้เซิร์ฟเวอร์ที่มีอยู่หรือสร้างเซิร์ฟเวอร์ใหม่ ในการดำเนินการนี้ ให้คัดลอก CLIENT_ID
ของบอท ซึ่งอยู่ในแท็บข้อมูลทั่วไป และใช้เป็นส่วนหนึ่งของ URL การอนุญาตพิเศษนี้:
https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot
เมื่อคุณกด URL นี้ในเบราว์เซอร์ แบบฟอร์มจะปรากฏขึ้นซึ่งคุณสามารถเลือกเซิร์ฟเวอร์ที่จะเพิ่มบอทได้
หลังจากที่คุณเพิ่มบอทในเซิร์ฟเวอร์ของคุณแล้ว คุณควรเห็นข้อความในลักษณะข้างต้น
การสร้างไฟล์ .env
เราต้องการวิธีบันทึกโทเค็นในแอปของเรา ในการทำเช่นนั้น เราจะใช้แพ็คเกจ dotenv
ขั้นแรก รับโทเค็นจาก Discord Application Dashboard ( Bot → คลิกเพื่อเปิดเผย 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 ต้องการ 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
s ต่อไปนี้คือลักษณะเมื่อเกิดการชนกันของการตั้งชื่อ:
Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder
ณ จุดนี้ ไม่สะดวก มากกว่า ที่จะแยกแยะว่าควรใช้ MessageResponder
ใด โดยเฉพาะอย่างยิ่งถ้าคอนเทนเนอร์ DI ของเรามีขนาดใหญ่ขึ้น การใช้ Symbol
นั้นดูแลเรื่องนี้ และเราไม่ได้สร้างตัวอักษรสตริงแปลก ๆ ในกรณีที่มีสองคลาสที่มีชื่อเดียวกัน
การใช้คอนเทนเนอร์ในแอป 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 และคอนเทนเนอร์การฉีดพึ่งพาภายในบอทของเรา
การใช้ตรรกะทางธุรกิจ
ไปที่แกนหลักของบทความนี้: การสร้าง codebase ที่ทดสอบได้ กล่าวโดยย่อ โค้ดของเราควรใช้แนวทางปฏิบัติที่ดีที่สุด (เช่น 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
Mock เข้าไป:
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 และ Dependency Injection: ไม่ใช่แค่สำหรับ Discord Bot Development
การนำโลกเชิงวัตถุของ TypeScript มาไว้ใน JavaScript เป็นการเพิ่มประสิทธิภาพที่ยอดเยี่ยม ไม่ว่าเราจะทำงานกับโค้ดส่วนหน้าหรือส่วนหลัง การใช้ประเภทเพียงอย่างเดียวทำให้เราสามารถหลีกเลี่ยงข้อบกพร่องต่างๆ ได้ การฉีดการพึ่งพาใน TypeScript จะช่วยผลักดันแนวทางปฏิบัติที่ดีที่สุดเชิงวัตถุไปสู่การพัฒนาบน JavaScript
แน่นอน เนื่องจากข้อจำกัดของภาษา มันไม่มีทางง่ายและเป็นธรรมชาติเหมือนในภาษาที่พิมพ์แบบสแตติก แต่สิ่งหนึ่งที่แน่นอนคือ TypeScript, การทดสอบหน่วย และการฉีดพึ่งพาช่วยให้เราเขียนโค้ดที่อ่านง่าย จับคู่แบบหลวมๆ และบำรุงรักษาได้ ไม่ว่าเราจะพัฒนาแอปประเภทใด