探討 Angular 的 DI 與 Provider

DI (Dependency Injection) 對於很多前端開發者是個陌生的名詞,畢竟以前沒有 DI 時,也沒有什麼東西寫不出來,為什麼 Angular 要全面提供 DI 與 provider 呢?


為什麼需要 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、Testable

Robust

若 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 傷腦筋,也不用為工廠模式的爆炸傷腦筋,只要記住一件事情:
有用到幾個 service,就在 providers 註冊幾個 provider。
剩下就交給 Angular 的 provider 幫我們 DI 了。

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 之間的相依僅限於 ISendableinterface,將大大降低 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(),因此只需相依 ISendableinterface 即可,不需去相依 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。




留言

這個網誌中的熱門文章

Json概述以及python對json的相關操作

Docker容器日誌查看與清理

遠程控制管理工具ipmitool