讓前端也能使用後端常用的開發技巧

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
2
3
4
5
6
7
8
9
10
11
12
13
14
import {SMSService} from './sms.service';

export class NotificationService {

private smsService : SMSService;

constructor() {
this.smsService = new SMSService();
}

showMessage() : string {
return this.smsService.sendMessage();
}
}

NotificationServiceshowMessage() 需要使用到 SMSServicesendMessage(),因此我們在 constructor 內建立 SMSService

SMSService

1
2
3
4
5
6
7
8
9
10
export class SMSService {

printMessage(): void {
console.log('Print Message');
}

sendMessage(): string {
return 'Send Message';
}
}

SMSService 提供了 printMessage()sendMessage() 兩個 method。

AppComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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,但因為 SMSServiceNotificationService 建立在內部,我們沒有任何管道由外部將 假SMSService傳入,進而取代真SMSService

在一個 service 內直接去 new 其他 service,在單元測試時會無法在外部以假 service 取代,因此說其為 Hard to Test

有 DI 版本

我們該怎麼使 NotificationService Robust、Flexible 與 Testable 呢?

其實很簡單,只要將 new 改成用 contructor 參數,這就是 DI 了。

NotificationService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {SMSService} from './sms.service';

export class NotificationService {

private smsService : SMSService;

constructor(smsService: SMSService) {
this.smsService = smsService;
}

showMessage() : string {
return this.smsService.sendMessage();
}
}

第 7 行

1
2
3
constructor(smsService: SMSService) {
this.smsService = smsService;
}

從 new 改成用 constructor 參數。

AppComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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();
}
}

16 行

1
2
3
constructor() {
this.notificationService = new NotificationService(new SMSService)
}

因為改用 DI,所以在 new NotificationService 時,就必須將 SMSService 帶入。

當使用 DI 後,NotificationServiceSMSService 就解耦合了,NotificatonService 不再由內部直接相依 service,而是由外部傳入的 service 所決定,這就是物件導向的依賴反轉原則

依賴反轉原則

抽象不要依賴細節,細節要依賴抽象。

高階模組不應該依賴低階模組,低階模組應該由高階模組決定其依賴。

NotificationService 使用 DI 這種寫法有 3 大優點:Robust、Flexible、Testable

Robust

SMSService 的 constructor 新增了參數 :

SMSService

1
2
3
4
5
6
7
8
export class SMSService {

private logService: LogService;

constructor(logService: LogService) {
this.logService = logService;
}
}

constructor 增加了 logService: LogService 參數。

AppComponent

1
2
3
4
5
6
7
8
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

1
2
3
4
5
6
7
8
9
10
class AzureSMSService extends SMSService {

printMessage(): void {
console.log('Print Azure Message');
}

sendMessage(): string {
return 'Send Azure Message';
}
}

新增 AzureSMSService ,並繼承原本的 SMSService,將原本的 printMessage()sendMessage() 加以 override,換成 Azure 簡訊服務。

AppComponent

1
2
3
4
5
6
7
8
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

1
2
3
4
5
6
7
8
9
10
class MockSMSService extends SMSService {

printMessage(): void {
console.log('Print Mock Message');
}

sendMessage(): string {
return 'Send Mock Message';
}
}

建立假的 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
2
3
4
5
6
7
8
export class AppComponent implements OnInit{

private notificationService: NotificationService;

constructor() {
this.notificationService = new NotificationService(new SMSService);
}
}

Component 必須使用 new NotificationService(new SMSService) 這種巢狀的方式才能建立 service,若相依 service 有更多層,則 new 的寫法將相當恐怖。

若使用設計模式的工廠模式,則狀況會好一點。

NotificationServiceFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
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。

其中包含建立 NotificationServiceSMSService

AppComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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();
}
}

16 行

1
2
3
constructor() {
this.notificationService = NotificationServiceFactory.createNotificationService();
}

原本在 constructor 的 new 改用 NotificationServiceFactory 取代,最少日後其他 component 要建立 NotificationService 都改用 NotificationServiceFactory,不用再使用很恐怖的 new。

不過這樣寫法仍有些問題。

NotificationService 所依賴的 service 很多,或依賴的 service 層數很深,則 NotificationServiceFactory 的 method 數量將會爆炸,所以工廠模式也不算最完美的解決方案。

Angular 的 Provider


Angular 提供了 Provider,專門替我們建立 service,解決工廠模式所面臨的問題。

di000

原本我們透過工廠模式自己處理 service 的 DI,現在改由 Angular 的 provider 接手。

NotificationService

1
2
3
4
5
6
7
8
9
10
11
12
13
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 會展開成為

1
2
3
4
5
6
7
8
export class NotificationService {

private smsService: SMSService;

constructor(smsService: SMSService) {
this.smsService = smsService;
}
}

這算是 TypeScript 的 syntax sugar,讓我們不用宣告 private field 與寫 field 的 initialization code,讓程式碼更加乾淨。

AppComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

1
2
3
4
providers: [
{provide: NotificationService, useClass: NotificationService},
{provide: SMSService, useClass: SMSService}
]

Provider 的目的,在於 DI 時,幫我們自動注入 service,但是 Angular 要怎麼知道該注入什麼 service 呢?這裏沒有黑魔法,我們要實際告訴 Angular 一個 mapping table,讓 provider 在自動注入時有所依據,providers 的陣列,就是 provider 所仰賴的 mapping table。

有幾個 service,就要提供幾筆 mapping 資料,這裡我們有 NotificationServiceSMSService,所以 providers 就有兩筆資料。

NotificationService 為例,我們希望 provider 幫我們:

  • 當遇到型別為 NotificationService時,請注入NotiticationService 這個 service。
  • provide 為 service 宣告的型別,useClass 則為 service 要實際注入的型別。

大部分狀況下,若沒有使用 interface 或 abstract class,provideuseClass 會相同,也就是直接注入該型別的 service。

此時可簡寫為

1
2
3
4
providers: [
NotificationService,
SMSService
]

Angular 會自動展開成

1
2
3
4
providers: [
{provide: NotificationService, useClass: NotificationService},
{provide: SMSService, useClass: SMSService}
]

當改用 provider 後,無論有相依的 service 個數有多少,相依的 service 層數有多深,我們不用為很多層的 new 傷腦筋,也不用為工廠模式的爆炸傷腦筋,只要記住一件事情:

有用到幾個 service,就在 providers 註冊幾個 provider。

剩下就交給 Angular 的 provider 幫我們 DI 了。

Provider 搭配 Interface


為了讓 service 實現不同的角色,且讓 service 與 service 之間的耦合降低,讓 service 不要直接相依某個 service,而是僅相依於 interface,實務上需要讓 provider 根據 interface 注入 service。

重構前

SMSService

1
2
3
4
5
6
7
8
9
10
11
12
13
import {Injectable} from '@angular/core';

@Injectable()
export class SMSService {

printMessage(): void {
console.log('Print Message');
}

sendMessage(): string {
return 'Send Message';
}
}

NotificationService

1
2
3
4
5
6
7
8
9
10
11
12
13
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() 只使用了 SMSServicesendMessage(),卻需要相依整個 SMSService,若能讓 NotificationServiceSMSService 之間的相依僅限於 ISendable interface,將大大降低 NotificationServiceSMSService 之間的耦合,也就是物件導向的介面隔離原則

介面隔離原則

用戶端程式碼不應該依賴它用不到的介面。

重構後

IPrintable

1
2
3
export interface IPrintable {
printMessage();
}

定義 IPrintable interface,其中只有 printMessage()

ISendable

1
2
3
export interface ISendable {
sendMessage(): string;
}

定義 ISendable interface,其中只有 sendMessage()

SMSService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 IPrintableISendable

NotificationService

1
2
3
4
5
6
7
8
9
10
11
12
13
import {Injectable} from '@angular/core';
import {ISendable} from './isendable';

@Injectable()
export class NotificationService {

constructor(private smsService: ISendable) {
}

showMessage() : string {
return this.smsService.sendMessage();
}
}

因為 NotificationService 事實上只需要 SMSServicesendMessage(),因此只需相依 ISendable interface 即可,不需去相依 SMSService 整個 service,如此 NotificationServiceSMSService 的耦合將降低到只有 ISendable interface 而已。

AppComponent 的 provider 該怎麼寫呢?

AppComponent

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
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();
}
}

10 行

1
2
3
4
providers: [
NotificationService,
{provide: ISendable, useClass: SMSService}
]

原來的 NotificationService 不變,但 SMSService 需改成 {provide: ISendable, useClass: SMSService},告訴 Angular 當遇到 ISendable interface 時,請注入 SMSService

NotificationServiceSMSService 的相依僅限於 ISendable interface 時,大大降低 NotificationServiceSMService 之間的耦合,也就是設計模式一書所說的:

根據 interface 寫程式,不要根據 class 寫程式。

白話就是

若要降低 service 之間的耦合程度,讓 service 之間方便抽換與組合,就讓 service 與 service 之間僅相依於 interface,而不要直接相依於 service。

更白話就是

黑貓白貓,能抓老鼠的就是好貓。

黑貓白貓就是 service,能抓老鼠就是 interface。

不過最後實際存檔時,卻出現編譯錯誤。

di001

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
2
3
export abstract class IPrintable {
abstract printMessage();
}

ISenable

1
2
3
export abstract class ISendable {
abstract sendMessage(): string;
}

將 interface 全改成 abstract class。

存檔後 provider 就正常了。

這並不是什奇技淫巧,在我們天天在用的 OnInit,在 Angular 內部事實上就是個 abstract class。

lifycycle_hooks.ts

1
export abstract class OnInit { abstract ngOnInit(): void; }

因為 JavaScript 沒有 interface,我們只能拿 abstract class 當 interface 用。

Provider 搭配 useFactory


因需求改變,原本使用的是 AWS 簡訊服務,但需求端想改用 Azure 簡訊服務,且希望原來 AWS 簡訊服務暫時留著,由設定檔決定要使用 AWS 或 Azure,因為日後有可能又會從 Azure 改成 AWS。

AWSSMSService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {Injectable} from '@angular/core';
import {IPrintable} from './iprintable';
import {ISendable} from './isendable';

@Injectable()
export class AWSSMSService implements IPrintable, ISendable {

printMessage(): void {
console.log('Print AWS Message');
}

sendMessage(): string {
return 'Send AWS Message';
}
}

先將原本的 SMSService 重構成 AWSSMSService

AzureSMSService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {Injectable} from '@angular/core';
import {IPrintable} from './iprintable';
import {ISendable} from './isendable';

@Injectable()
export class AzureSMSService implements IPrintable, ISendable {

printMessage() {
console.log('Print Azure Message');
}

sendMessage(): string {
return 'Send Azure Message';
}
}

建立 AzureSMSService,一樣 implement IPrintableISendable interface。

environments.ts

1
2
3
4
5
6
7
import {AzureSMSService} from "../app/azure-sms.service";
import {AWSSMSService} from "../app/aws-sms.service";

export const environment = {
production: false,
SMSService: AzureSMSService,
};

environment.ts 增加 SMSService 設定,可在此設定要使用 AzureSMSServiceAWSSMSService

一般我們會在設定檔使用字串做設定,因為 provider 主要就是用來建立 service,所以可以直接在設定檔內使用 service 的 class,就不必靠 reflection 由字串建立 service,且還可受到 TypeScript 強型別的編譯保護。

SMSServiceProvider

1
2
3
4
5
6
7
import {ISendable} from "./isendable";
import {environment} from "../environments/environment";

export let SMSServiceProvider = {
provide: Sendable,
useFactory: () => new (environment.SMSService)
};

為了將來其他 componet 要共用此 provider 方便,特別獨立出 SMSServiceProvider

使用 useFactory,依 environment.SMSService 的設定 new 出 AzureSMSServiceAWSSMSService

AppComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {Component, OnInit} from '@angular/core';
import {NotificationService} from './notification.service';
import {SMSServiceProvider} from './smsservice-provider';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
providers: [
NotificationService,
SMSServiceProvider,
]
})
export class AppComponent implements OnInit {

title = 'app works!';

constructor(private notificationService: NotificationService) {
}

ngOnInit(): void {
this.title = this.notificationService.showMessage();
}
}

providers 改成使用 SMSServiceProvider

如此之後,儘管有新增新的 SMSService,只要也 implement IPrintableISendable interface,並在 environments.ts 設定新的 SMSService 即可,NotificattionServiceAppComponentSMSServiceProvider 都不用修改,達成物件導向的開放封閉原則

重構 Service


目前全部的 service 都在 app 目錄下有點亂,透過 WebStorm,我們可以將所有 service 加以重構,完全不用人工修改任何程式碼。

di002

重構前,所有 service 都在 app 目錄下。

di003

重構之後井井有條:

  • NotificationService 放在 app/services/notification 目錄下。
  • AWSSMSServiceAzureSMSServiceSMSServiceProvider 放在 app/services/sms 目錄下。
  • IPrintableISenable 放在 app/services/interfaces 目錄下。

WebStorm 會幫我們修改所有的 import 路徑,完全不用我們操心。

Conclusion


  • DI 雖然好用,但要搭配 provider 才能發揮全部威力,達成物件導向的終極目標:開放封閉原則
  • Angular 提供完整的 DI + provider,讓我們在後端的開發經驗能繼續在前端使用。

Reference


Google, Angular/Guide/Dependency Injection
Google, Angular/Cookbook/Dependency Injection

2017-03-08