深入探討 Angular 的 DI 與 Provider
DI (Dependency Injection) 對於很多前端開發者是個陌生的名詞,畢竟以前沒有 DI 時,也沒有什麼東西寫不出來,為什麼 Angular 要全面提供 DI 與 provider 呢?
Version
Angular CLI 1.0.0-rc.0
Angular 2.4.9
WebStorm 2016.3.3
為什麼需要 DI?
我們先來建立兩個簡單的 service,來觀察有用 DI 與沒用 DI 的差異。
無 DI 版本
NotificationService
1 | import {SMSService} from './sms.service'; |
NotificationService
的 showMessage()
需要使用到 SMSService
的 sendMessage()
,因此我們在 constructor 內建立 SMSService
。
SMSService
1 | export class SMSService { |
SMSService
提供了 printMessage()
與 sendMessage()
兩個 method。
AppComponent
1 | import {Component, OnInit} from '@angular/core'; |
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
Robust、Flexible 與 Testable 呢?
其實很簡單,只要將 new 改成用 contructor 參數,這就是 DI 了。
NotificationService
1 | import {SMSService} from './sms.service'; |
第 7 行
1 | constructor(smsService: SMSService) { |
從 new 改成用 constructor 參數。
AppComponent
1 | import {Component, OnInit} from '@angular/core'; |
16 行
1 | constructor() { |
因為改用 DI,所以在 new NotificationService
時,就必須將 SMSService
帶入。
當使用 DI 後,NotificationService
與 SMSService
就解耦合了,NotificatonService
不再由內部直接相依 service,而是由外部傳入的 service 所決定,這就是物件導向的依賴反轉原則。
依賴反轉原則
抽象不要依賴細節,細節要依賴抽象。
高階模組不應該依賴低階模組,低階模組應該由高階模組決定其依賴。
NotificationService
使用 DI 這種寫法有 3 大優點:Robust、Flexible、Testable。
Robust
若 SMSService
的 constructor 新增了參數 :
SMSService
1 | export class SMSService { |
constructor 增加了 logService: LogService
參數。
AppComponent
1 | export class AppComponent implements OnInit{ |
AppComponent
新增加 new LogService
。
但 NotificationService
完全不用做任何修改。
將來無論相依 service 怎麼修改,原 service 都不用修改,因此說其為 Robust。
Flexible
若原本為 AWS 簡訊服務,想要換成 Azure 簡訊服務。
AzureSMSService
1 | class AzureSMSService extends SMSService { |
新增 AzureSMSService
,並繼承原本的 SMSService
,將原本的 printMessage()
與 sendMessage()
加以 override,換成 Azure 簡訊服務。
AppComponent
1 | export class AppComponent implements OnInit { |
因為物件導向的里氏替換原則,AppComponent
可改注入 AzureSMSService
。
里氏替換原則
所有的父類別都可以由子類別代替,但子類別不一定能用父類別代替。
NotificationService
完全不用做任何修改。
將來若有新的需求,只要新增 service 注入即可,原 service 都不用修改,因此說其為 Flexible。
Testable
若要對 NotificationService
做單元測試,可建立 假SMSService
取代原來的 真SMSService
。
MockSMSService
1 | class MockSMSService extends SMSService { |
建立假的 MockSMSService
,並繼承原本的 SMSService
,將原本的 printMessage()
與 sendMessage()
加以 override,換成假的簡訊服務。
NotificationServiceTest
1 | let notificationService = new NotificationService(new MockSMSService()); |
單元測試時,因為物件導向的里氏替換原則,可使用 MockService
取代 SMSService
,如此就能完全隔離原本 SMSService
,只執行我們的假 service 的所有功能。
我們來對 DI 做個小結:
DI 只是一種程式風格,將相依 service 改由外界透過 constructor 注入,而不是在內部自己用 new 產生。
DI 搭配工廠模式
DI 雖然對於 service 本身很有利,無論其相依的 service 如何修改,service 本身都不用修改,也就是物件導向的開放封閉原則,但對於使用 service 的 component 卻很辛苦。
開放封閉原則
對於擴展是開放的,對於修改是封閉的。
AppComponent
1 | export class AppComponent implements OnInit{ |
Component 必須使用 new NotificationService(new SMSService)
這種巢狀的方式才能建立 service,若相依 service 有更多層,則 new 的寫法將相當恐怖。
若使用設計模式的工廠模式,則狀況會好一點。
NotificationServiceFactory
1 | import {NotificationService} from './notification.service'; |
建立一個 NotificationServiceFactory
專門負責建立各 service。
其中包含建立 NotificationService
與 SMSService
。
AppComponent
1 | import {Component, OnInit} from '@angular/core'; |
16 行
1 | constructor() { |
原本在 constructor 的 new 改用 NotificationServiceFactory
取代,最少日後其他 component 要建立 NotificationService
都改用 NotificationServiceFactory
,不用再使用很恐怖的 new。
不過這樣寫法仍有些問題。
若 NotificationService
所依賴的 service 很多,或依賴的 service 層數很深,則 NotificationServiceFactory
的 method 數量將會爆炸,所以工廠模式也不算最完美的解決方案。
Angular 的 Provider
Angular 提供了 Provider,專門替我們建立 service,解決工廠模式所面臨的問題。
原本我們透過工廠模式自己處理 service 的 DI,現在改由 Angular 的 provider 接手。
NotificationService
1 | import {Injectable} from '@angular/core'; |
NotificationService
相依SMSService
部分,全部改由 DI 方式。- 加上
@Injectable()
decorator,記得要加上()
。 - 加上
import {Injectable} from '@angular/core';
constructor 參數直接加上 private
,TypeScript 會展開成為
1 | export class NotificationService { |
這算是 TypeScript 的 syntax sugar,讓我們不用宣告 private field 與寫 field 的 initialization code,讓程式碼更加乾淨。
AppComponent
1 | import {Component, OnInit} from '@angular/core'; |
AppComponent
相依 NotificationService
部分,全部改由 DI 方式。
除此之外,要在 component 的 decorator 加上 providers
。
1 | providers: [ |
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。
此時可簡寫為
1 | providers: [ |
Angular 會自動展開成
1 | providers: [ |
當改用 provider 後,無論有相依的 service 個數有多少,相依的 service 層數有多深,我們不用為很多層的 new 傷腦筋,也不用為工廠模式的爆炸傷腦筋,只要記住一件事情:
有用到幾個 service,就在 providers 註冊幾個 provider。
剩下就交給 Angular 的 provider 幫我們 DI 了。
Provider 搭配 Interface
為了讓 service 實現不同的角色,且讓 service 與 service 之間的耦合降低,讓 service 不要直接相依某個 service,而是僅相依於 interface,實務上需要讓 provider 根據 interface 注入 service。
重構前
SMSService
1 | import {Injectable} from '@angular/core'; |
NotificationService
1 | import {Injectable} from '@angular/core'; |
在 NotificationService
中,showMessage()
只使用了 SMSService
的 sendMessage()
,卻需要相依整個 SMSService
,若能讓 NotificationService
與 SMSService
之間的相依僅限於 ISendable
interface,將大大降低 NotificationService
與 SMSService
之間的耦合,也就是物件導向的介面隔離原則。
介面隔離原則
用戶端程式碼不應該依賴它用不到的介面。
重構後
IPrintable
1 | export interface IPrintable { |
定義 IPrintable
interface,其中只有 printMessage()
。
ISendable
1 | export interface ISendable { |
定義 ISendable
interface,其中只有 sendMessage()
。
SMSService
1 | import {Injectable} from '@angular/core'; |
SMSService
去 implement IPrintable
與 ISendable
。
NotificationService
1 | import {Injectable} from '@angular/core'; |
因為 NotificationService
事實上只需要 SMSService
的 sendMessage()
,因此只需相依 ISendable
interface 即可,不需去相依 SMSService
整個 service,如此 NotificationService
與 SMSService
的耦合將降低到只有 ISendable
interface 而已。
但 AppComponent
的 provider 該怎麼寫呢?
AppComponent
1 | import {Component, OnInit} from "@angular/core"; |
10 行
1 | providers: [ |
原來的 NotificationService
不變,但 SMSService
需改成 {provide: ISendable, useClass: SMSService}
,告訴 Angular 當遇到 ISendable
interface 時,請注入 SMSService
。
當 NotificationService
與 SMSService
的相依僅限於 ISendable
interface 時,大大降低 NotificationService
與 SMService
之間的耦合,也就是設計模式一書所說的:
根據 interface 寫程式,不要根據 class 寫程式。
白話就是
若要降低 service 之間的耦合程度,讓 service 之間方便抽換與組合,就讓 service 與 service 之間僅相依於 interface,而不要直接相依於 service。
更白話就是
黑貓白貓,能抓老鼠的就是好貓。
黑貓白貓就是 service,能抓老鼠就是 interface。
不過最後實際存檔時,卻出現編譯錯誤。
Angular CLI 編譯時抱怨找不到 ISendable
interface。
在 Angular 官網的 Angular/Guide/Dependency Injection 一文中特別強調 :
TypeScript interfaces aren’t valid tokens.
That seems strange if we’re used to dependency injection in strongly typed languages, where an interface is the preferred dependency lookup key.
It’s not Angular’s fault. An interface is a TypeScript design-time artifact. JavaScript doesn’t have interfaces. The TypeScript interface disappears from the generated JavaScript. There is no interface type information left for Angular to find at runtime.
這並不是 Angular 或 TypeScript 的錯,因為 JavaScript 本來就沒 interface,interface 為 TypeScript 所擴充,因此編譯後會找不到 interface。
既然 interface 不能用,我們只能用些小技巧。
IPrintable
1 | export abstract class IPrintable { |
ISenable
1 | export abstract class ISendable { |
將 interface 全改成 abstract class。
存檔後 provider 就正常了。
這並不是什奇技淫巧,在我們天天在用的 OnInit
,在 Angular 內部事實上就是個 abstract class。
1 | export abstract class OnInit { abstract ngOnInit(): void; } |
因為 JavaScript 沒有 interface,我們只能拿 abstract class 當 interface 用。
Provider 搭配 useFactory
因需求改變,原本使用的是 AWS 簡訊服務,但需求端想改用 Azure 簡訊服務,且希望原來 AWS 簡訊服務暫時留著,由設定檔決定要使用 AWS 或 Azure,因為日後有可能又會從 Azure 改成 AWS。
AWSSMSService
1 | import {Injectable} from '@angular/core'; |
先將原本的 SMSService
重構成 AWSSMSService
。
AzureSMSService
1 | import {Injectable} from '@angular/core'; |
建立 AzureSMSService
,一樣 implement IPrintable
與 ISendable
interface。
environments.ts
1 | import {AzureSMSService} from "../app/azure-sms.service"; |
在 environment.ts
增加 SMSService
設定,可在此設定要使用 AzureSMSService
或 AWSSMSService
。
一般我們會在設定檔使用字串做設定,因為 provider 主要就是用來建立 service,所以可以直接在設定檔內使用 service 的 class,就不必靠 reflection 由字串建立 service,且還可受到 TypeScript 強型別的編譯保護。
SMSServiceProvider
1 | import {ISendable} from "./isendable"; |
為了將來其他 componet 要共用此 provider 方便,特別獨立出 SMSServiceProvider
。
使用 useFactory
,依 environment.SMSService
的設定 new 出 AzureSMSService
或 AWSSMSService
。
AppComponent
1 | import {Component, OnInit} from '@angular/core'; |
providers
改成使用 SMSServiceProvider
。
如此之後,儘管有新增新的 SMSService
,只要也 implement IPrintable
與 ISendable
interface,並在 environments.ts
設定新的 SMSService
即可,NotificattionService
、 AppComponent
與 SMSServiceProvider
都不用修改,達成物件導向的開放封閉原則。
重構 Service
目前全部的 service 都在 app 目錄下有點亂,透過 WebStorm,我們可以將所有 service 加以重構,完全不用人工修改任何程式碼。
重構前,所有 service 都在 app
目錄下。
重構之後井井有條:
NotificationService
放在app/services/notification
目錄下。AWSSMSService
、AzureSMSService
與SMSServiceProvider
放在app/services/sms
目錄下。IPrintable
與ISenable
放在app/services/interfaces
目錄下。
WebStorm 會幫我們修改所有的 import 路徑,完全不用我們操心。
Conclusion
- DI 雖然好用,但要搭配 provider 才能發揮全部威力,達成物件導向的終極目標:開放封閉原則。
- Angular 提供完整的 DI + provider,讓我們在後端的開發經驗能繼續在前端使用。
Reference
Google, Angular/Guide/Dependency Injection
Google, Angular/Cookbook/Dependency Injection