淺析控制反轉
介紹
控制反轉 (Inversion of control) 並不是一項新的技術,是 Martin Fowler 教授提出的一種軟體設計模式。那到底什麼被反轉了?獲得依賴物件的過程被反轉了。控制反轉 (下文統一簡稱為 IoC) 把傳統模式中需要自己透過 new 例項化建構函式,或者透過工廠模式例項化的任務交給容器。通俗的來理解,就是本來當需要某個類(建構函式)的某個方法時,自己需要主動例項化變為被動,不需要再考慮如何例項化其他依賴的類,只需要依賴注入 (Dependency Injection, 下文統一簡稱為 DI), DI 是 IoC 的一種實現方式。所謂依賴注入就是由 IoC 容器在執行期間,動態地將某種依賴關係注入到物件之中。所以 IoC 和 DI 是從不同的角度的描述的同一件事情,就是透過引入 IoC 容器,利用依賴注入的方式,實現物件之間的解耦。
那反轉控制這種設計模式到底給前端帶來了什麼價值?這裡先給出答案:
提升開發效率
提高模組化
便於單元測試
為什麼我們需要它?
先給出一個例子,傳統模式下當我們建立汽車
(Car)
這個類的時候,我們需要依賴輪子,發動機。
import
{
Engine
}
from
‘path/to/engine’
;
import
{
Tires
}
from
‘path/to/tires’
;
class
Car
{
private
engine
;
private
tires
;
constructor
()
{
this
。
engine
=
new
Engine
();
this
。
tires
=
Tires
。
getInstance
();
}
}
在
Car
這個類的構造器中我們裝備了這個類中需用到的依賴項,這有什麼問題呢?正如你所見,構造器不僅需要把依賴賦值到當前類內部屬性上還需要把依賴例項化。比如 Engine 是透過 new 例項化的, 而
Tires
是透過工廠模式建立的。這樣的高度耦合的依賴關係大大增加了單元測試難度和後期維護的成本。必然會出現牽一髮而動全身的情形。而且在依賴 hard-code 寫死在程式碼中並不符合 SOLID 開發原則中的 “開閉原則”。試想一個程式中,我們有超多種類的 Car,他們都依賴同一個依賴 Engine,但是有一天我想把所有的 Engine 換成 V8Engine 我該怎麼做?全域性搜尋 Engine 修改為 V8Engine,想想都有點麻煩。
每輛車都需要自己控制引擎的建立
然後我們嘗試一下 IoC 的版本。
import
{
Engine
}
from
‘path/to/engine’
;
import
{
Tires
}
from
‘path/to/tires’
;
import
{
Container
}
from
‘path/to/container’
;
const
container
=
new
Container
();
container
。
bind
(
‘engine’
,
Engine
);
container
。
bind
(
‘tires’
,
Tires
);
class
Car
{
private
engine
;
private
tires
;
constructor
()
{
this
。
engine
=
container
。
get
(
‘engine’
);
this
。
tires
=
container
。
get
(
‘tires’
);
}
}
現在引擎和輪胎的建立不再直接依賴它們的建構函式,而是透過 IoC 容器 (container) 來建立,使得 Car 類 和 Engine,Tires 沒有了強耦合關係。程式碼中不再依賴於具體,而是依賴於 container 抽象容器,即要針對介面程式設計,不針對實現程式設計。過去思維中想要什麼依賴,需要自己去 “拉” 改為抽象容器主動 “推” 給你,你只管使用實體就可以了。這是
依賴倒轉
(DIP) 的一種表現形式。
所有車裝有引擎
因為汽車不直接依賴引擎,所以現在我想把所有引擎換成 V8 引擎,只需要把 IoC 容器中的引擎替換掉就可以了。
所有車裝有 V8 引擎
原理
首先讓我們實現一個最簡單的容器來管理依賴,這裡省略了大量型別定義,型別判斷和異常處理,並不適用於生產環境。
class
Container
{
private
constructorPool
;
constructor
()
{
this
。
constructorPool
=
new
Map
();
}
register
(
name
,
constructor
)
{
this
。
constructorPool
。
set
(
name
,
constructor
);
}
get
(
name
)
{
const
target
=
this
。
constructorPool
。
get
(
name
);
return
new
target
();
}
}
container
。
register
(
‘myClass’
,
DemoClass
);
const
classInstance
=
container
。
get
(
‘myClass’
);
constructorPool 是存放所有依賴的集合, 這是最簡單的物件池,池中儲存著建構函式和唯一識別符號的集合。當呼叫 get 方法時,根據唯一識別符號從物件池中拿到建構函式並返回例項,這隻考慮了在註冊時如參是建構函式,並且每次 get 的時候都返回新的例項。當我們需要在全域性使用單一例項,並且在不同的地方拿到同一個例項,就需要在註冊 (register) 的時候新增配置區分是
單例模式
還是
工廠模式
。
class
Container
{
private
constructorPool
;
constructor
()
{
this
。
constructorPool
=
new
Map
();
}
register
(
name
,
definition
,
dependencies
)
{
this
。
constructorPool
。
set
(
name
,
{
definition
:
definition
,
dependencies
:
dependencies
});
}
get
(
name
)
{
const
targetConstructor
=
this
。
constructorPool
。
get
(
name
);
if
(
this
。
_isClass
(
targetConstructor
。
definition
))
{
return
this
。
_createInstance
(
targetConstructor
);
}
else
{
return
targetConstructor
。
definition
;
}
}
// 遞迴拿到類的所有依賴集合
_getResolvedDependencies
(
target
)
{
let
classDependencies
=
[];
if
(
target
。
dependencies
)
{
classDependencies
=
target
。
dependencies
。
map
(
dependency
=>
{
return
this
。
get
(
dependency
);
});
}
return
classDependencies
;
}
_createInstance
(
target
)
{
return
new
target
。
definition
(。。。
this
。
_getResolvedDependencies
(
service
));
}
// 判斷是否為建構函式
_isClass
(
definition
)
{
return
Object
。
prototype
。
toString
。
call
(
definition
)
===
“[object Function]”
;
}
}
而且依賴容器中需要維護一套自己的生命週期去滿足連線資料庫等需求,這裡建議大家讀一下 midway 團隊出品的 injection ,這裡有更完整的解決方案。
可測性
接下來我們用實際開發的例子看一下 IoC 是如何提高程式碼的可測性。
這裡還是使用汽車的例子。
import
{
Engine
}
from
‘engine/path’
;
import
{
Tires
}
from
‘tires/path’
;
class
Car
{
private
engine
;
private
tires
;
constructor
()
{
this
。
engine
=
new
Engine
();
this
。
tires
=
Tires
。
getInstance
();
}
async
run
()
{
const
engineStatus
=
await
this
。
engine
。
check
();
const
tiresStatus
=
await
this
。
tires
。
check
();
if
(
engineStatus
&&
tiresStatus
)
{
return
console
。
log
(
‘car running。’
);
}
return
console
。
log
(
‘car broken’
);
}
}
當我們例項化 Car 之後,執行 run 的時候,我們會呼叫 engine 和 tires 依賴裡的方法,這個方法有可能會有外部依賴,比如從資料庫中讀資料,或者一次 http 請求。
export
class
Engine
{
private
health
=
true
;
async
check
()
{
const
result1
=
await
http
。
get
(
‘demo’
);
//check 1
const
result2
=
await
db
。
find
({
//check 2
id
:
‘demoId’
});
const
result3
=
this
。
health
;
//check 3
return
result1
&&
result2
&&
result3
;
}
}
當生產環境下我們執行 check,我們期望 3 個 check 都是 true 才讓引擎發動,但是在測試階段,我們只想執行 check3,忽略 check1 和 check2,這在傳統開發模式下是很難做的,因為在 Car 建構函式中,已經寫死了 Engine 的建立。想在測試階段提供一個永遠保持健康狀態的引擎只能透過例項化時判斷環境變數,賦值不同的例項,或者修改建構函式。
例項化時判斷環境。
class
Car
{
private
engine
;
public
running
=
false
;
constructor
()
{
if
(
process
。
env
===
‘test’
)
{
this
。
engine
=
new
TestEngine
();
}
else
{
this
。
engine
=
new
Engine
();
}
}
async
run
()
{
const
engineStatus
=
await
this
。
engine
。
check
();
return
this
。
running
=
engineStatus
;
}
公用類判斷環境。
export
class
Engine
{
private
health
=
true
;
async
check
()
{
if
(
process
。
env
===
‘test’
)
{
// test check
}
else
{
// normal check
}
}
}
這兩種方式都不是優雅的解決方案,這種髒程式碼不應該在專案中出現。為了單元測試而需要判斷執行環境的程式碼不應該寫在具體實現上,而是應該放在公共的地方統一處理。
藉由 IoC 容器,我們的業務程式碼不需要為單元測試作出修改,只需要在測試的時候,把測試的例項註冊到 IoC 的容器中就可以了。
class
Car
{
private
engine
;
public
running
=
false
;
constructor
()
{
this
。
engine
=
container
。
get
(
‘engine’
);
}
async
run
()
{
const
engineStatus
=
await
this
。
engine
。
check
();
if
(
engineStatus
)
{
return
this
。
running
=
true
;
}
return
this
。
running
=
false
;
}
}
透過 IoC 我們可以優雅的處理測試環境下,業務程式碼中需要的依賴實體。因為當測試開始時,我們可以透過配置建立符合預期的類放到物件池中,業務程式碼中只需要直接使用就可以了。
以下給出一段對於 Car 的測試程式碼。
// car。spec。js
const
Car
=
require
(
‘。/car’
);
describe
(
‘Car’
,
function
()
{
it
(
‘#car。run’
,
async
function
()
{
// 註冊測試用依賴
container
。
register
(
‘engine’
,
MockEngine
);
const
car
=
new
Car
();
await
car
。
run
()
expect
(
car
。
running
)。
to
。
eql
(
true
);
});
});
社群最佳實踐
在前端領域,反轉控制可能被提及的比較少 (Angular 2 釋出之前),但是在服務端領域, IoC 有很多實現,比如 Java 的 Spring 框架,PHP 的 Laravel 等等。Angular 的出現讓我對前端工程化有了新的見解,Angular 把依賴注入作為應用設計模式,在框架的高度管理所有依賴和幫助開發者獲取依賴,Angular 官方自己維護了一套自己的 DI 框架。
di。js
想揭開 DI 的神秘面紗需要了解兩個東西。
首先是 @Injectable。這是 JavaScript 裝飾器 (Decorators) 語法特性,裝飾器語法已經進入 TC39 提案 Stage 2,但是還沒正式進入 ECMA 語法標準。這個特發特性是使類可被注入的關鍵。開發者可以使用註解的方式自定義類的行為,方法,和執行時的屬性。在 Angular 中使用 @Injectable 註解向 IoC 容器註冊。angular/packages/core/src/di/ 在這個名稱空間下 Angular 組織了 DI 的邏輯。框架提供了一套解決方案跟蹤被註解的所有依賴,當你需要時提供正確的例項。
然後是 reflect-metadata。這個包提供了讀取和修改類的源資料的能力,是幫助 Angular 判斷被注入方所需例項型別的關鍵點。當使用這個包時,必須設定在 tsconfig。json 中開啟 emitDecoratorMetadata: true 。
透過這兩位的幫助,TypeScript 便可在編譯時拿到被註解類的原資料,而且這些原屬組是在執行時可用的。
總結
因篇幅原因,這裡只是簡單介紹 IoC 的使用,控制反轉設計模式的優點是顯而易見的,它有益於編寫單元測試。因為依賴的例項化交給了容器,所以減少了例項化模版程式碼。讓程式更易於擴充套件。去除程式碼之間的直接依賴關係,降低了耦合度。控制反轉離不開依賴注入,現階段社群中解決方案是透過 reflect-metadata 和
裝飾器
來進行注入。
擴充套件閱讀
因為本人也是剛剛接觸 IoC,並沒有在大型業務場景中實踐這一設計模式,所以這次分享內容過於侷限,如果你想更深入的瞭解控制反轉和依賴注入可以透過以下路徑:
Inversion of Control Containers and the Dependency Injection patternMicrosoft/tsyringeAngular
https://
github。com/spring-proje
cts/spring-framework/tree/3a0f309e2c9fdbbf7fb2d348be861528177f8555/spring-beans/src/main/java/org/springframework/beans/factory
tc39/proposal-decoratorsrbuckton/reflect-metadatamidwayjs/injection
上一篇:抗癌蔬菜“紅薯葉”
下一篇:有適合普通人的副業推薦嗎?