翻译:疯狂的技术宅
原文:https://www.toptal.com/typesc…
本文首发微信公众号:前端先锋
欢迎关注,每天都给你推送新鲜的前端技术文章
类型和可测试代码 是避免错误的两种最有效方法,尤其是代码随会时间而变化。我们可以分别通过利用 TypeScript 和依赖注入(DI)将这两种技术应用于 JavaScript 开发。
在本 TypeScript 教程中,除编译以外,我们不会直接介绍 TypeScript 的基础知识。相反,我们将会演示 TypeScript 最佳实践,因为我们将介绍如何从头开始制作 Discord bot、连接测试和 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
文件进行配置。对于所有问题,你只需简单的按 回车键(或者如果需要,可以提供一些信息)。然后,安装我们的依赖项和 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
然后,将 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/develo…。然后,单击 New Application 按钮:
选择一个名称,然后单击 创建。然后,单击 Bot → Add Bot,你就完成了。让我们将机器人添加到服务器。但是不要关闭此页面,我们需要尽快复制令牌。
将你的 Discord Bot 添加到你的服务器
为了测试我们的机器人,需要一台 Discord 服务器。你可以使用现有服务器或创建新服务器。复制机器人的 CLIENT_ID
并将其作为这个特殊授权 URL(https://discordapp.com/develo…)的一部分使用:
https://discordapp.com/oauth2/authorize?client_id=<CLIENT_ID>&scope=bot
当你在浏览器中点击此 URL 时,会出现一个表单,你可以在其中选择应添加机器人的服务器。
将 bot 添加到服务器后,你应该会看到如上所示的消息。
创建 .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
命令。或者,如果你用了其他 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
创建一个 Bot 类
现在,我们终于要开始使用 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');
}
}
现在可以看到我们需要的东西:一个 token!我们是不是只需要将其复制粘贴到此处,或直接从环境中加载值就可以了呢?
都不是。相反,让我们用依赖注入框架 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
来处理这个问题,在有两个具有相同名称的类的情况下,就不会出现这些奇怪的文字。
在 Discord Bot App 中使用 Container
现在,让我们通过修改 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
文件中实例化 bot:
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”的每条消息:
这是它在日志中的样子:
> 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);
现在我们已经设置好了 mocks,我们可以定义 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”的测试非常简单,因为没有依赖项被 mock。这是一个测试用例的例子:
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 和依赖注入:不仅仅用于 Discord Bot 开发
无论我们是处理前端还是后端代码,将 TypeScript 的面向对象引入 JavaScript 都是一个很大的改进。仅仅使用类型就可以避免许多错误。在 TypeScript 中进行依赖注入会将更多面向对象的最佳实践推向基于 JavaScript 的开发。
当然由于语言的局限性,它永远不会像静态类型语言那样容易和自然。但有一件事是肯定的:TypeScript、单元测试和依赖注入允许我们编写更易读、松散耦合和可维护的代码 —— 无论我们正在开发什么类型的应用。
本文首发微信公众号:前端先锋
欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章
欢迎继续阅读本专栏其它高赞文章:
- 12 个令人惊叹的 CSS 实验项目
- 必须要会的 50 个 React 面试题
- 世界顶级公司的前端面试都问些什么
- 11 个最好的 JavaScript 动态效果库
- CSS Flexbox 可视化手册
- 从设计者的角度看 React
- 过节很无聊?还是用 JavaScript 写一个脑力小游戏吧!
- CSS 粘性定位是怎样工作的
- 一步步教你用 HTML5 SVG 实现动画效果
- 程序员 30 岁前月薪达不到 30K,该何去何从
- 14 个最好的 JavaScript 数据可视化库
- 8 个给前端的顶级 VS Code 扩展插件
- Node.js 多线程完全指南
- 把 HTML 转成 PDF 的 4 个方案及实现
- 更多文章 …