一開始學寫程式,很習慣依照執行順序,把低層次的物件寫死在高層次的物件中,更甚者直接讀取資料庫沒有任何的抽象化,例如
1
2
3
4
5
6
7
8
9
10
11
12
| class PaymentService {
constructor() {
this.orderCollection = mongoClient.collection('order');
this.s3Service = new S3Service();
}
async pay(orderId) {
const order = this.orderCollection.find({ id: orderId });
.....
this.s3Service.uploadResult(....);
}
}
|
可能會覺得資料庫、雲端服務本身不太會有變動,所以寫死沒差,但除了服務被綁死外,另一個難題是寫測試
,沒辦法將第三方服務 mock 寫出乾淨的 unit test,或是必須 mock 整個第三方 module 在寫測試前就先花了一小時在 mocking 非常浪費時間
當我們把外部相依抽出來,透過建構式注入,就解決了以上的問題,但帶來的新問題是呼叫方需要花很多時間先建構出需要的服務,例如
1
2
3
4
5
| function main() {
const orderStorageService = new OrderStorageService();
const s3Service = new S3Service();
const paymentService = new PaymentService(orderStorageService, s3Service);
}
|
在每一個使用 paymentService 的地方,都需要手動建立相依的服務,即使有用 Factory Pattern 再多一層抽象化,管理起來也是十分的麻煩
此時可以用 InvertifyJS 解決依賴注入的麻煩
原始碼 sj82516/inversify-js-example
靜態相依
InversifyJS 概念大概是
- 初始化 Container,將可以被注入的 Class 都打上標記,可以把 Container 當作是 Namespace
- 在要注入的地方,透過標記決定初始化並注入相依的 Class
- 如果要動態取得物件,可以從 Container 拿取
讓我們先看一個簡單的範例,假設我們有一個 Payment Service,會依賴於第三方付款平台 PaymentGatewayService 以及基本的 LogService 蒐集 log
靜態相依是只說 Payment Service 在初始化就決定相依的物件,而不會動態的決定,第一步將 LogService 標記 @injectable
,需注意要先定義 interface,接著把實作設定為 injectable,讓服務相依於抽象介面而不是實作,符合 ISP - 介面隔離原則
,例如說 LogService 實作上可以是儲存於本地端檔案、上傳到 Slack 等等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| export interface LogService {
log(message: string): void;
error(message: string): void;
}
export const LogServiceTypes = {
slack: Symbol("slack"),
local: Symbol("local")
}
export type LogServiceTypeValueTypes = typeof LogServiceTypes[keyof typeof LogServiceTypes];
@injectable()
export class LocalLogService implements LogService {
static NORMAL_FILE = './normal.log'
static ERROR_FILE = './error.log'
async log(message: string) {
await fs.appendFile(LocalLogService.NORMAL_FILE, message);
}
async error(message: string) {
await fs.appendFile(LocalLogService.ERROR_FILE, message);
}
}
@injectable()
export class SlackLogService implements LogService {
async log(message: string) {
console.log("send log to slack", message)
}
async error(message: string) {
console.log("send error log to slack", message)
}
}
|
LogServiceTypes
是為了讓使用者在指定 logService 時有型別的提示LogServiceTypeValueTypes
主要是為了指向 LogServiceTypes 的 values type
在 PaymentGatewayService 做類似的事情,接著定義我們 PaymentService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| @injectable()
export class StaticPaymentService {
constructor(
@inject("slackLog") private logService: LogService,
@inject("stripePaymentGateway") private paymentGatewayService: PaymentGatewayService
) {
}
pay(client: Client, order: Order): string|void {
const totalFee = this.paymentGatewayService.totalFee(order)
if (totalFee > client.balance) {
return this.logService.error(`${client.name} doesn't have enough money`);
}
this.logService.log(`${client.name} paid ${totalFee}`)
return this.paymentGatewayService.generateLink(client, totalFee);
}
}
|
重點在 constructor 中主動宣告了相依的物件,這邊我們指定要注入 slackLog
、stripePaymentGateway
最後是定義這些資源的地方 invertify.config.ts
1
2
3
4
5
6
| const paymentServiceContainer = new Container();
paymentServiceContainer.bind<LogService>("localLog").to(LocalLogService);
paymentServiceContainer.bind<LogService>("slackLog").to(SlackLogService);
paymentServiceContainer.bind<StaticPaymentService>("staticPaymentService").to(StaticPaymentService);
paymentServiceContainer.bind<StripePaymentGatewayService>("stripePaymentGateway").to(StripePaymentGatewayService);
paymentServiceContainer.bind<PaypalPaymentGatewayService>("paypalPaymentGateway").to(PaypalPaymentGatewayService);
|
我們定義一個 paymentServiceContainer,接著將物件都註冊到 Container 之中,方便後續的調用,上一步 inject() 的名稱 slackLog
就是在這邊定義,可以自由替換,只要單個 Container 中不重複就好
最後是 PaymentService 的初始化與調用
1
2
| const staticPaymentService = paymentServiceContainer.get<StaticPaymentService>("staticPaymentService");
staticPaymentService.pay(client, order);
|
這樣就完成了
小結
透過以上的案例,可以發現 InvertifyJS 做的事情也不複雜,讓開發者顯式的註冊對應的物件與其介面,接著在需要注入的地方直接用註冊名稱呼叫,如果需要動態初始化物件與其相依的服務使用 container.get("name")
即可
讓呼叫端要做的工作少了非常多
動態相依
如果我們希望動態一些,在初始化時才決定要選擇,可以採用工廠模式
1
2
3
4
5
6
7
8
9
10
11
| export class DynamicPaymentService {
constructor(
private logService: LogService,
private paymentGatewayService: PaymentGatewayService
) {
}
pay(client: Client, order: Order): string|void {
....
}
}
|
我們不用在宣告的地方加上 Injectable,而是透過 Container 建立 Factor
1
2
3
4
5
6
7
8
9
10
11
12
| paymentServiceContainer.bind<PaymentGatewayService>("PaymentGatewayService").to(StripePaymentGatewayService).whenTargetNamed("stripe")
paymentServiceContainer.bind<PaymentGatewayService>("PaymentGatewayService").to(PaypalPaymentGatewayService).whenTargetNamed("paypal")
type PaymentServiceFatory = (logType: LogServiceTypeValueTypes, paymentPlatform: string) => DynamicPaymentService;
paymentServiceContainer.bind<PaymentServiceFatory>("DynamicPaymentService").toFactory<DynamicPaymentService>((context: interfaces.Context) => {
return (logType: string, paymentPlatform: string) => {
let paymentGatewayService = context.container.getNamed<PaymentGatewayService>("PaymentGatewayService", paymentPlatform);
let logService = context.container.get<LogService>(logType);
return new DynamicPaymentService(logService, paymentGatewayService);
};
});
|
看起來有點嚇人,分成幾段
<(logType: symbol, paymentPlatform: string) => DynamicPaymentService>
bind 裏面放的是回傳的型別,我們的 Factor 是有兩個參數 logType / paymentPlatform 並且回傳 DynamicPaymentService 的函式("DynamicPaymentService")
在 Container 他對應的名稱是 “DynamicPaymentService”,可以用 String,更謹慎可以用 Symbol.toFactory<DynamicPaymentService>
定義 Factory 回傳 DynamicPaymentService 型別的物件(context: interfaces.Context) => {}
透過當前上下文取得相關資訊,並返回實際 Factory 函式的實作- 為了更方便取得對應的服務,可以加上
whenTargetNamed
直接指定名稱
最後使用
1
2
3
| const factory = paymentServiceContainer.get<PaymentServiceFatory>("DynamicPaymentService");
const paymentService = factory(LogServiceTypes.slack, "paypal");
paymentService.pay(client, order);
|
結語
Inversify 還有其他有趣的功能,包含使用 tag / 定義 middleware 等等,整體看起來是個擴充性非常良好的設計,有機會再來研究內部的實作