朝更好的 OOP 走去: IOC 控制反轉與 DI 依賴注入

莫力全 Kyle Mo
17 min readJul 21, 2020

--

圖片來源

學習寫程式一段時間後,漸漸會明白除了寫出能 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。

參考連結

認為我的文章對你有幫助的話,幫我拍拍手吧!

--

--

莫力全 Kyle Mo

什麼都想學的雜食性軟體工程師 🇹🇼 (https://github.com/kylemocode) 合作與聯繫 📪 oldmo860617@gmail.com IG 技術自媒體:@kylemo.webdev.life