翻譯:瘋狂的技術宅
原文: https://www.toptal.com/typesc...
未經許可,禁止轉載!
本文首發微信公衆號:前端先鋒
歡迎關注,天天都給你推送新鮮的前端技術文章javascript
類型和可測試代碼是避免錯誤的兩種最有效方法,尤爲是代碼隨會時間而變化。咱們能夠分別經過利用 TypeScript 和依賴注入(DI)將這兩種技術應用於JavaScript開發。前端
在本 TypeScript 教程中,除編譯之外,咱們不會直接介紹 TypeScript 的基礎知識。相反,咱們將會演示 TypeScript 最佳實踐,由於咱們將介紹如何從頭開始製做 Discord bot、鏈接測試和 DI,以及建立示例服務。咱們將會使用:java
首先,讓咱們建立一個名爲 typescript-bot
的新目錄。而後輸入並經過運行如下命令建立一個新的 Node.js 項目:node
npm init
注意:你也能夠用 yarn
,但爲了簡潔起見,咱們用了 npm
。git
這將會打開一個交互式嚮導,對 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
部分替換爲:es6
"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 下的語法可能會有所不一樣。)github
start
腳本將用於啓動機器人,watch
腳本用於編譯 TypeScript 代碼,test
用於運行測試。面試
如今,咱們的 package.json
文件應以下所示:typescript
{ "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 API進 行交互,咱們須要一個令牌。要生成這樣的令牌,須要在 Discord 開發面板中註冊一個應用。爲此,你須要建立一個 Discord 賬戶並轉到 https://discordapp.com/develo...。而後,單擊 New Application 按鈕:
選擇一個名稱,而後單擊建立。而後,單擊 Bot → Add 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,可使用 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
如今,咱們終於要開始使用 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
來處理這個問題,在有兩個具備相同名稱的類的狀況下,就不會出現這些奇怪的文字。
如今,讓咱們經過修改 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 的面向對象引入 JavaScript 都是一個很大的改進。僅僅使用類型就能夠避免許多錯誤。在 TypeScript 中進行依賴注入會將更多面向對象的最佳實踐推向基於 JavaScript 的開發。
固然因爲語言的侷限性,它永遠不會像靜態類型語言那樣容易和天然。但有一件事是確定的:TypeScript、單元測試和依賴注入容許咱們編寫更易讀、鬆散耦合和可維護的代碼 —— 不管咱們正在開發什麼類型的應用。