探討 Angular 的 DI 與 Provider
DI (Dependency Injection) 對於很多前端開發者是個陌生的名詞,畢竟以前沒有 DI 時,也沒有什麼東西寫不出來,為什麼 Angular 要全面提供 DI 與 provider 呢?
SMSService
為什麼需要 DI?
我們先來建立兩個簡單的 service,來觀察有用 DI 與沒用 DI 的差異。
無 DI 版本
NotificationService
import {SMSService} from './sms.service'; export class NotificationService { private smsService : SMSService; constructor() { this.smsService = new SMSService(); } showMessage() : string { return this.smsService.sendMessage(); } }
NotificationService
的 showMessage()
需要使用到 SMSService
的 sendMessage()
,因此我們在 constructor 內建立 SMSService
。SMSService
export class SMSService { printMessage(): void { console.log('Print Message'); } sendMessage(): string { return 'Send Message'; } }
SMSService
提供了 printMessage()
與 sendMessage()
兩個 method。
AppComponent
import {Component, OnInit} from '@angular/core'; import {NotificationService} from './notification.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { private notificationService: NotificationService; title = 'app works!'; constructor() { this.notificationService = new NotificationService(); } ngOnInit(): void { this.title = this.notificationService.showMessage(); } }
AppComponent
因為需要用到 NotificationService
,所以也在 constructor 內建立 notificationService
。
目前看起來沒什麼問題,程式也能正常執行,大家之前也都是這樣寫程式。
但是在
NotificationService
使用 new SMSService()
這種寫法有 3 大缺點:Brittle、Inflexible、Hard to Test。Brittle
由於我們是在
NotificationService
內去 new SMSService
,而 new 是透過 constructor 去建立 service,若今天 SMSService
的 constructor 參數增加或減少,勢必 NotificationService
也必須跟著修改,如 this.smsService = new SMSService(theNewParameter)
。一個 service 會因為其相依 service 的 constructor 參數修改,而連帶必須跟著修改,因此說其為 Brittle。
Inflexible
由於我們是在
NotificationService
內去 new SMSService
,若將來需求改變,想要更換其他簡訊服務商,如原本為 AWS 簡訊服務,想換成 Azure 簡訊服務,目前無法做到,因為 NotificationService
已經直接在內部與 SMSService
耦合在一起,無法由外部更換。在一個 service 內直接去 new 其他 service,就類似主機板將記憶體直接焊死 on board,想要擴充都無法擴充,因此說其為 Inflexible。
Hard to Test
當我們想對
NotificationService
做單元測試時,由於 NotificationService
相依了 SMSService
,因此 SMSService
的所有行為將會影響 NotificationService
,如 SMSServce
可能是非同步,可能每次都要收費,但這些都不是我們對 NotificationService
單元測試想做的,因此希望對 SMSService
加以隔離,單純測試 NotificationService
的行為。
儘管我們建立了
假SMSService
想取代 真SMSService
,但因為 SMSService
被 NotificationService
建立在內部,我們沒有任何管道由外部將 假SMSService
傳入,進而取代真SMSService
。在一個 service 內直接去 new 其他 service,在單元測試時會無法在外部以假 service 取代,因此說其為 Hard to Test。
有 DI 版本
我們該怎麼使
NotificationService
Robut、Flexible 與 Testable 呢?
其實很簡單,只要將 new 改成用 contructor 參數,這就是 DI 了。
NotificationService
import {SMSService} from './sms.service'; export class NotificationService { private smsService : SMSService; constructor(smsService: SMSService) { this.smsService = smsService; } showMessage() : string { return this.smsService.sendMessage(); }
constructor(smsService: SMSService) { this.smsService = smsService; }
從 new 改成用 constructor 參數。
AppComponent
import {Component, OnInit} from '@angular/core'; import {NotificationService} from './notification.service'; import {SMSService} from './sms.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { private notificationService: notificationService; title = 'app works!'; constructor() { this.notificationService = new NotificationService(new SMSService) } ngOnInit(): void { this.title = this.notificationService.showMessage(); } }
constructor() { this.notificationService = new NotificationService(new SMSService) }
因為改用 DI,所以在 new
NotificationService
時,就必須將 SMSService
帶入。
當使用 DI 後,
NotificationService
與 SMSService
就解耦合了,NotificatonService
不再由內部直接相依 service,而是由外部傳入的 service 所決定,這就是物件導向的依賴反轉原則。
依賴反轉原則
抽象不要依賴細節,細節要依賴抽象。
高階模組不應該依賴低階模組,低階模組應該由高階模組決定其依賴。
NotificationService
使用 DI 這種寫法有 3 大優點:Robust、Flexible、TestableRobust
若
SMSService
的 constructor 新增了參數 :
SMSService
export class SMSService { private logService: LogService; constructor(logService: LogService) { this.logService = logService; } }
constructor 增加了
logService: LogService
參數。
AppComponent
export class AppComponent implements OnInit{ private notificatoinService: NotificationService; constructor() { this.notificationService = new NotificationService(new SMSService(new LogService)); } }
AppComponent
新增加 new LogService
。
但
NotificationService
完全不用做任何修改。將來無論相依 service 怎麼修改,原 service 都不用修改,因此說其為 Robust。
Flexible
若原本為 AWS 簡訊服務,想要換成 Azure 簡訊服務。
AzureSMSService
class AzureSMSService extends SMSService { printMessage(): void { console.log('Print Azure Message'); } sendMessage(): string { return 'Send Azure Message'; } }
新增
AzureSMSService
,並繼承原本的 SMSService
,將原本的 printMessage()
與 sendMessage()
加以 override,換成 Azure 簡訊服務。
AppComponent
export class AppComponent implements OnInit { private notificationService: NotificationService; constructor() { this.notificationService = new NotificationService(new AzureSMSService); } }
因為物件導向的里氏替換原則,
AppComponent
可改注入 AzureSMSService
。
里氏替換原則
所有的父類別都可以由子類別代替,但子類別不一定能用父類別代替。
NotificationService
完全不用做任何修改。將來若有新的需求,只要新增 service 注入即可,原 service 都不用修改,因此說其為 Flexible。
Testable
若要對
NotificationService
做單元測試,可建立 假SMSService
取代原來的 真SMSService
。
MockSMSService
class MockSMSService extends SMSService { printMessage(): void { console.log('Print Mock Message'); } sendMessage(): string { return 'Send Mock Message'; } }
建立假的
MockSMSService
,並繼承原本的 SMSService
,將原本的 printMessage()
與 sendMessage()
加以 override,換成假的簡訊服務。
NotificationServiceTest
let notificationService = new NotificationService(new MockSMSService());
單元測試時,因為物件導向的里氏替換原則,可使用
MockService
取代 SMSService
,如此就能完全隔離原本 SMSService
,只執行我們的假 service 的所有功能。
我們來對 DI 做個小結:
DI 只是一種程式風格,將相依 service 改由外界透過 constructor 注入,而不是在內部自己用 new 產生。
DI 搭配工廠模式
DI 雖然對於 service 本身很有利,無論其相依的 service 如何修改,service 本身都不用修改,也就是物件導向的開放封閉原則,但對於使用 service 的 component 卻很辛苦。
開放封閉原則
對於擴展是開放的,對於修改是封閉的。
AppComponent
export class AppComponent implements OnInit{ private notificationService: NotificationService; constructor() { this.notificationService = new NotificationService(new SMSService); } }
Component 必須使用
new NotificationService(new SMSService)
這種巢狀的方式才能建立 service,若相依 service 有更多層,則 new 的寫法將相當恐怖。
若使用設計模式的工廠模式,則狀況會好一點。
NotificationServiceFactory
import {NotificationService} from './notification.service'; import {SMSService} from './sms.service'; export class NotificationServiceFactory { static createNotificationService(): NotificationService { return new NotificationService(this.createSMSService()); } static createSMSService() : SMSService { return new SMSService(); } }
建立一個
NotificationServiceFactory
專門負責建立各 service。
其中包含建立
NotificationService
與 SMSService
。
AppComponent
import {Component, OnInit} from '@angular/core'; import {NotificationService} from './notification.service'; import {NotificationServiceFactory} from './notification-service-factory'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { private notificationService: NotificationService; title = 'app works!'; constructor() { this.notificationService = NotificationServiceFactory.createNotificationService(); } ngOnInit(): void { this.title = this.notificationService.showMessage(); } }
constructor() { this.notificationService = NotificationServiceFactory.createNotificationService(); }
原本在 constructor 的 new 改用
NotificationServiceFactory
取代,最少日後其他 component 要建立 NotificationService
都改用 NotificationServiceFactory
,不用再使用很恐怖的 new。
不過這樣寫法仍有些問題。
若
NotificationService
所依賴的 service 很多,或依賴的 service 層數很深,則 NotificationServiceFactory
的 method 數量將會爆炸,所以工廠模式也不算最完美的解決方案。Angular 的 Provider
Angular 提供了 Provider,專門替我們建立 service,解決工廠模式所面臨的問題。
原本我們透過工廠模式自己處理 service 的 DI,現在改由 Angular 的 provider 接手。
NotificationService
import {Injectable} from '@angular/core'; import {SMSService} from './sms.service'; @Injectable() export class NotificationService { constructor(private smsService: SMSService) { } showMessage() : string { return this.smsService.sendMessage(); } }
NotificationService
相依SMSService
部分,全部改由 DI 方式。- 加上
@Injectable()
decorator,記得要加上()
。 - 加上
import {Injectable} from '@angular/core';
constructor 參數直接加上
private
,TypeScript 會展開成為export class NotificationService { private smsService: SMSService; constructor(smsService: SMSService) { this.smsService = smsService; } }
這算是 TypeScript 的 syntax sugar,讓我們不用宣告 private field 與寫 field 的 initialization code,讓程式碼更加乾淨。
AppComponent
import {Component, OnInit} from '@angular/core'; import {NotificationService} from './notification.service'; import {SMSService} from './sms.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], providers: [ {provide: NotificationService, useClass: NotificationService}, {provide: SMSService, useClass: SMSService} ] }) export class AppComponent implements OnInit{ title = 'app works!'; constructor(private notificationService: NotificationService) { } ngOnInit(): void { this.title = this.notificationService.showMessage(); } }
AppComponent
相依 NotificationService
部分,全部改由 DI 方式。
除此之外,要在 component 的 decorator 加上
providers
。providers: [ {provide: NotificationService, useClass: NotificationService}, {provide: SMSService, useClass: SMSService} ]
Provider 的目的,在於 DI 時,幫我們自動注入 service,但是 Angular 要怎麼知道該注入什麼 service 呢?這裏沒有黑魔法,我們要實際告訴 Angular 一個 mapping table,讓 provider 在自動注入時有所依據,
providers
的陣列,就是 provider 所仰賴的 mapping table。
有幾個 service,就要提供幾筆 mapping 資料,這裡我們有
NotificationService
與 SMSService
,所以 providers
就有兩筆資料。
以
NotificationService
為例,我們希望 provider 幫我們:- 當遇到型別為
NotificationService
時,請注入NotiticationService
這個 service。 provide
為 service 宣告的型別,useClass
則為 service 要實際注入的型別。
大部分狀況下,若沒有使用 interface 或 abstract class,
provide
與 useClass
會相同,也就是直接注入該型別的 service。
此時可簡寫為
providers: [ NotificationService, SMSService ]
Angular 會自動展開成
providers: [ {provide: NotificationService, useClass: NotificationService}, {provide: SMSService, useClass: SMSService} ]
當改用 provider 後,無論有相依的 service 個數有多少,相依的 service 層數有多深,我們不用為很多層的 new 傷腦筋,也不用為工廠模式的爆炸傷腦筋,只要記住一件事情:
剩下就交給 Angular 的 provider 幫我們 DI 了。有用到幾個 service,就在 providers 註冊幾個 provider。
Provider 搭配 Interface
為了讓 service 實現不同的角色,且讓 service 與 service 之間的耦合降低,讓 service 不要直接相依某個 service,而是僅相依於 interface,實務上需要讓 provider 根據 interface 注入 service。
重構前
SMSService
import {Injectable} from '@angular/core'; @Injectable() export class SMSService { printMessage(): void { console.log('Print Message'); } sendMessage(): string { return 'Send Message'; } }
NotificationService
import {Injectable} from '@angular/core'; import {SMSService} from './sms.service'; @Injectable() export class NotificationService { constructor(private smsService: SMSService) { } showMessage() : string { return this.smsService.sendMessage(); } }
在
NotificationService
中,showMessage()
只使用了 SMSService
的 sendMessage()
,卻需要相依整個 SMSService
,若能讓 NotificationService
與 SMSService
之間的相依僅限於 ISendable
interface,將大大降低 NotificationService
與 SMSService
之間的耦合,也就是物件導向的介面隔離原則。
介面隔離原則
用戶端程式碼不應該依賴它用不到的介面。
重構後
IPrintable
export interface IPrintable { printMessage(); }
定義
IPrintable
interface,其中只有 printMessage()
。
ISendable
export interface ISendable { sendMessage(): string; }
定義
ISendable
interface,其中只有 sendMessage()
。
SMSService
import {Injectable} from '@angular/core'; import {IPrintable} from "./iprintable"; import {ISendable} from "./isendable"; @Injectable() export class SMSService implements IPrintable, ISendable { printMessage(): void { console.log('Print Message'); } sendMessage(): string { return 'Send Message'; } }
SMSService
去 implement IPrintable
與 ISendable
。
NotificationService
import {Injectable} from '@angular/core'; import {ISendable} from './isendable'; @Injectable() export class NotificationService { constructor(private smsService: ISendable) { } showMessage() : string { return this.smsService.sendMessage(); } }
因為
NotificationService
事實上只需要 SMSService
的 sendMessage()
,因此只需相依 ISendable
interface 即可,不需去相依 SMSService
整個 service,如此 NotificationService
與 SMSService
的耦合將降低到只有 ISendable
interface 而已。
但
AppComponent
的 provider 該怎麼寫呢?
AppComponent
import {Component, OnInit} from "@angular/core"; import {NotificationService} from "./notification.service"; import {SMSService} from "./sms.service"; import {ISendable} from "./isendable"; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], providers: [ NotificationService, {provide: ISendable, useClass: SMSService} ] }) export class AppComponent implements OnInit { title = 'app works!'; constructor(private notificationService: NotificationService) { } ngOnInit(): void { this.title = this.notificationService.showMessage(); } }
providers: [ NotificationService, {provide: ISendable, useClass: SMSService} ]
原來的
NotificationService
不變,但 SMSService
需改成 {provide: ISendable, useClass: SMSService}
,告訴 Angular 當遇到 ISendable
interface 時,請注入 SMSService
。
當
NotificationService
與 SMSService
的相依僅限於 ISendable
interface 時,大大降低 NotificationService
與 SMService
之間的耦合,也就是設計模式一書所說的:根據 interface 寫程式,不要根據 class 寫程式。
白話就是
若要降低 service 之間的耦合程度,讓 service 之間方便抽換與組合,就讓 service 與 service 之間僅相依於 interface,而不要直接相依於 service。
留言
張貼留言