TypeScript, 종속성 주입 및 Discord 봇 작업
게시 됨: 2022-03-11유형과 테스트 가능한 코드 는 특히 시간이 지남에 따라 코드가 변경될 때 버그를 피하는 가장 효과적인 두 가지 방법입니다. TypeScript와 DI(Dependency Injection) 디자인 패턴을 각각 활용하여 이 두 기술을 JavaScript 개발에 적용할 수 있습니다.
이 TypeScript 튜토리얼에서는 컴파일을 제외하고 TypeScript 기본 사항을 직접 다루지 않습니다. 대신, Discord 봇을 처음부터 만들고, 테스트와 DI를 연결하고, 샘플 서비스를 만드는 방법을 안내하면서 TypeScript 모범 사례를 간단히 시연할 것입니다. 우리는 다음을 사용할 것입니다:
- 노드.js
- 타입스크립트
- Discord API용 래퍼 Discord.js
- 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 개발자 대시보드에 앱을 등록해야 합니다. 그러려면 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 애플리케이션 대시보드에서 토큰을 가져옵니다( Bot → Click to Reveal Token ):
이제 .env
파일을 만든 다음 여기에 토큰을 복사하여 붙여넣습니다.
TOKEN=paste.the.token.here
Git을 사용하는 경우 토큰이 손상되지 않도록 이 파일을 .gitignore
에 배치해야 합니다. 또한 TOKEN
이 다음을 정의해야 함을 알 수 있도록 .env.example
파일을 생성합니다.
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
을 사용하지 않고 이름 충돌이 발생했을 때의 모습은 다음과 같습니다.
Error: Ambiguous match found for serviceIdentifier: MessageResponder Registered bindings: MessageResponder MessageResponder
이 시점에서 특히 DI 컨테이너가 커질 경우 어떤 MessageResponder
를 사용해야 하는지 정렬하는 것이 훨씬 더 불편합니다. 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(); } }
마지막으로 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 기반 개발에 더 많은 객체 지향 모범 사례가 적용됩니다.
물론 언어의 한계로 인해 정적으로 유형이 지정된 언어만큼 쉽고 자연스럽지는 않을 것입니다. 그러나 한 가지는 확실합니다. TypeScript, 단위 테스트 및 종속성 주입을 통해 개발 중인 앱의 종류에 관계없이 더 읽기 쉽고 느슨하게 연결되고 유지 관리 가능한 코드를 작성할 수 있습니다.