朝更好的 OOP 走去: IOC 控制反轉與 DI 依賴注入
學習寫程式一段時間後,漸漸會明白除了寫出能 work 的 code 之外,更重要的是寫出一份好 code,而程式碼的品質不外乎可以從可讀性、可維護性、可擴展性等幾個面向去看,在物件導向程式設計中,如果能遵守 **SOLID** 原則,寫出來的 code 的品質也較有保障,也較易於進行測試。
所謂 SOLID 原則為:
- Single responsibility principle: a class should have one, and only one, reason to change
- Open-closed principle: it should be possible to extend the behavoir of a class without modifying it
- Liskov Substitution principle: subclasses should be substitutable for their superclasses
- Interface segregation principle: many small, client-specific interfaces are better than one general purpose interface
- Dependency inversion principle: depends on abstractions not concretions
而接下來的內容則是會聚焦在 Dependency inversion principle (依賴反轉原則)。
何謂控制反轉與依賴注入?
在物件導向中,難免會需要建立一大堆的 class(類別),而如果類別間的依賴很複雜(例如 A class 需要 new 一個 B class 去使用 B 的功能,那麼萬一 B class 改動了很可能 A 的邏輯也需要更改),那麼假使專案擴展後,就會造成高耦合性,此時離我們撰寫優質程式碼的目標似乎更加遙遠了。
而控制反轉(Inversion Of Control)就是代表了避免類別間產生依賴的設計原則與概念,依賴注入則是實現控制反轉的手段之一。
大體而言實現控制反轉可以獲得以下幾項好處:
- 解耦性
- 易於測試
- 快速開發
接下來我們用程式碼來體會一下
首先要來說個故事,老莫是一個剛畢業的社會新鮮人,他的夢想是成為一個頂尖的軟體工程師,而熱愛生活的他也培養出了很多興趣,用 class 可以如下表示:
export class Person {
name: string = '';
}class KyleMo extends Person {
name = 'Kyle Mo';
private age: number = 23;
// 一堆興趣
read() { console.log('read') }
watchMovie() { console.log('watchMovie') }
coding() { console.log('coding') }
// 還有很多...
}const kyle = new KyleMo()
kyle.coding();
買電腦
既然要成為一名軟體工程師,電腦可就是少不了的配備啊,老莫興奮的拿著外婆贊助的三萬元,買了一台 Asus 牌 Windows 系統的筆電,畢竟以前桌機也都是 Windows 系統,能夠快速上手沒有問題!
export class Notebook {
status: string = 'normal';
coding() {
console.log('coding');
} broken() {
this.status = 'broken';
}
}export class AsusNotebook extends Notebook {
name: string = 'AsusNotebook'; constructor() {
super();
console.log(this.name);
}coding() {
console.log(`coding using ${this.name}`);
}
}export class Person{
name: string = '';
}class KyleMo extends Person {
name = 'Kyle Mo';
private age: number = 23;
// 一堆興趣
read() { console.log('read') }
watchMovie() { console.log('watchMovie') }
coding() {
const notebook = new AsusNotebook();
notebook.coding();
}
// 還有很多...
}const kyle = new KyleMo()
kyle.coding();
跳槽到 MacOS
使用 Windows 筆電開發一段時間後,老莫發現使用較接近 Linux 系統的 MacOS 在開發上的效率對他而言會提升不少,於是他忍痛用自己的存款又買了一台 MacOS 來開發,希望可以加速達成他成為頂尖軟體工程師的夢想。
程式碼的變動大概如下
// 新增 Macbook 的 class
export class Macbook extends Notebook {
name: string = 'Macbook Pro'; constructor() {
super();
console.log(this.name);
} coding() {
console.log(`coding using ${this.name}`);
}
}// 將 KyleMo class 中 coding method 中的電腦改為 Macclass KyleMo extends Person {
name = 'Kyle Mo';
private age: number = 23;
// 一堆興趣
read() { console.log('read') }
watchMovie() { console.log('watchMovie') }
coding() {
// 改這裡
const notebook = new Macbook();
notebook.coding();
}
// 還有很多...
}
思考更好的方式
聰明的老莫馬上發現一個問題:每次換一個裝置,除了新增新裝置的類別外,還要去改變自身的邏輯。懶惰的老莫馬上思考要怎麼解決這個問題,畢竟每次都改變自己體內的構造真的會壞掉呀!這樣實在太依賴筆電了,完全被筆電牽著鼻子走,不如讓筆電注入到自己身上吧!
class KyleMo extends Person {
name = 'Kyle Mo';
private age: number = 23;
private readonly notebook: Notebook; constructor(notebook: Notebook) {
super();
this.notebook = notebook;
}
// 一堆興趣
read() { console.log('read') }
watchMovie() { console.log('watchMovie') }
coding() {
this.notebook.coding();
}
// 還有很多...
}const notebook = new Macbook();const kyle = new KyleMo(notebook);
kyle.coding();
如此一來老莫總算是脫離對筆電的依賴了,不用更改自身的邏輯,反正外面注入什麼樣的筆電跟他沒有關係,用就對了!
桌電的降臨
因為老莫總是習慣將所有還會再用到的應用程式保持開啟的狀態,常常導致 CPU 的使用率過高,造成筆電效能低落,因此他決定從台中老家加他以前的寶物 : 8核心的桌上型電腦給搬到台北來使用(用途假設為跟筆電一樣主要拿來開發)。
聰明的你看到這可能會想到以下寫法
export class Pc {
name: string = 'PC';constructor() {
console.log(this.name);
}coding() {
console.log(`coding using ${this.name}`);
}
}class KyleMo extends Person {
name = 'Kyle Mo';
private age: number = 23;
private readonly notebook: Notebook;
private readonly pc: Pc; constructor(notebook: Notebook, pc: Pc) {
super();
this.notebook = notebook;
this.pc = pc;
}
// 一堆興趣
read() { console.log('read') }
watchMovie() { console.log('watchMovie') }
coding_with_notebook() {
this.notebook.coding();
}
cosing_with_pc() {
this.pc.coding();
}
// 還有很多...
}const notebook = new Macbook();
const pc = new Pc();const kyle = new KyleMo(notebook, pc);
kyle.coding_with_notebook();
kyle.coding_with_pc();
再抽象一點
就算老莫再愛寫程式,一天能夠運用的時間也是有限的,不太可能會同時使用桌機跟筆電開發,再者兩者的功能都是一樣的(coding),也許我們可以提出更抽象的要求,沒有必要將筆電跟桌電都同時注入到 KyleMo class 中。
更改後的程式碼大致為
interface CodingDevice {
status: string;
coding: () => void;
broken: () => void;
}export class Notebook {
status: string = 'normal';
coding() {
console.log('coding');
} broken() {
this.status = 'broken';
}
}export class Macbook extends Notebook implements CodingDevice {
name: string = 'Macbook Pro'; constructor() {
super();
console.log(this.name);
} coding() {
console.log(`coding using ${this.name}`);
}
}export class Pc implements CodingDevice {
status: string = 'normal';
name: string = 'PC'; constructor() {
console.log(this.name);
} coding() {
console.log(`coding using ${this.name}`);
} broken() {
this.status = 'broken';
}
}export class Person{
name: string = '';
}class KyleMo extends Person {
name = 'Kyle Mo';
private age: number = 23;
private readonly codingDevice: CodingDevice; constructor(codingDevice: CodingDevice) {
super();
this.codingDevice = codingDevice;
}
// 一堆興趣
read() { console.log('read') }
watchMovie() { console.log('watchMovie') }
coding() {
this.codingDevice.coding();
}
// 還有很多...
}let codingDevice: CodingDevice;
let kyle: KyleMo;codingDevice = new Pc();kyle = new KyleMo(codingDevice);
kyle.coding();
如此一來老莫就可以輕鬆換自己要 coding 的裝置,也解決依賴的問題了。
老莫發現未來還有很多東西需要做 DI (依賴注入),不過自己實作真的有點累啊,有沒有辦法透過其他方式統一管理呢?
有。
InversifyJS
首先先來看看 InversifyJS官網 是怎麼介紹自己的
A powerful and lightweight inversion of control container for JavaScript & Node.js apps powered by TypeScript.
它透過一個 IOC container 來統一管理類別的注入,讓在 JS 與 TS 中能更輕易寫出優質的 IOC code。
多說無益,直接用程式碼示範吧!
加入 InversifyJS 之前
先來架構出尚未使用 InversifyJS 的程式碼
專案架構
- root
- main.ts
- service.ts
- dependencies.ts
main.ts 是建構出 service 實例的地方
service.ts 則是需要注入類別的 class
dependencies.ts 則存在兩個即將被 service 注入的類別
// dependencied.tsexport class DependencyA {
private readonly name: string = 'dependencyA'; public getName(): string {
return this.name;
}
}export class DependencyB {
private readonly name: string = 'dependencyB'; public getName(): string {
return this.name;
}
}// service.ts
import { DependencyA, DependencyB } from './dependencies';export class KyleService {
protected depA: DependencyA;
protected depB: DependencyB; constructor() {
this.depA = new DependencyA();
this.depB = new DependencyB();
} public getAllNames(): string[] {
return [this.depA.getName(), this.depB.getName()];
}
}// main.ts
import { KyleService } from './service';const service: KyleService = new KyleService();console.log(service.getAllNames());
接著我們試著投入 InversifyJS 的懷抱吧!
安裝 InversifyJS 與 reflect-metadata(compile 用)
$ yarn add -D inversify reflect-metadata
接著在 tsconfig.json 中新增以下幾行,讓 TypeScript 能夠支援 InversifyJS
"compilerOptions": {
...,
"types": ["reflect-metadata"],
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
以上都完成後就能在程式碼中使用 Inversify 啦
首先創建 di-container.ts 並在其中創建管理 DI 的 container
import { Container } from 'inversify';
import { DependencyA, DependencyB } from './dependencies';let IOCContainer = new Container();// instructs the container to return an instance of class whenever a particular class is requested (injected).
IOCContainer.bind<DependencyA>(DependencyA).toSelf();
IOCContainer.bind<DependencyB>(DependencyB).toSelf();export default IOCContainer;
接著用 TS decorator 讓 dependency 標示為 injectable
// dependencies.ts
import { injectable } from 'inversify';@injectable()
export class DependencyA {
private readonly name: string = 'dependencyA'; public getName(): string {
return this.name;
}
}@injectable()
export class DependencyB {
private readonly name: string = 'dependencyB'; public getName(): string {
return this.name;
}
}
最後修改 service.ts 與 main.ts
// service.ts
import { inject, injectable } from 'inversify';
import { DependencyA, DependencyB } from './dependencies';@injectable()
export class KyleService {
protected depA: DependencyA;
protected depB: DependencyB; constructor(
@inject(DependencyA) dependencyA: DependencyA,
@inject(DependencyA) dependencyB: DependencyB
) {
this.depA = dependencyA;
this.depB = dependencyB;
} public getAllNames(): string[] {
return [this.depA.getName(), this.depB.getName()];
}
}// main.ts
import { KyleService } from './service';
import IOCContainer from './di-container';const service: KyleService = IOCContainer.resolve<KyleService>(KyleService);console.log(service.getAllNames());
如此一來 code 就比原本自己實作 IOC 時更為直覺,介面也更為統一了。
結論
透過這篇文章介紹了 IOC 與 DI 的概念,讓開發者在開發物件導向程式時,能夠更往 SOLID 原則靠攏,寫出高品質的程式碼,也介紹了實作 IOC 的第三方套件 InversifyJS ,雖然沒有時間講述套件的各個 API 與實作細節,但透過簡單的範例,讀者應該可以感受到它的強大,若有興趣的話再自行深入研究囉!
程式碼連結
其中的 ioc folder。
參考連結
認為我的文章對你有幫助的話,幫我拍拍手吧!