3D程式設計模式:依賴隔離模式

YYC 發表於 2022-06-09
設計模式

大家好~本文提出了“依賴隔離”模式

系列文章詳見:
3D程式設計模式:開篇

本文相關程式碼在這裡:
相關程式碼

編輯器需要替換引擎

編輯器使用了Three.js引擎作為渲染引擎,來建立一個預設的3D場景
編輯器相關程式碼Editor:

import {
	Scene,
	...
} from "three";

export let createScene = function () {
	let scene = new Scene();

	...
}

客戶相關程式碼Client:

import { createScene } from "./Editor";

createScene()

image

現在需要升級Three.js引擎的版本,或者替換為其它的渲染引擎,我們會發現非常困難,因為需要修改編輯器中所有呼叫了該引擎的程式碼(如需要修改createScene函式),程式碼一多就不好修改了

有沒有辦法能在不需要修改編輯器相關程式碼的情況下,就升級引擎版本或者替換引擎呢?

只要解除編輯器和具體的引擎的依賴,並把引擎升級或替換的邏輯隔離出去就可以!新設計的類圖如下所示:
image

IRenderEngine介面對渲染引擎的API進行抽象
IRenderEngine

//scene為抽象型別,這裡用any型別表示
type scene = any;

export interface IRenderEngine {
    createScene(): scene
    ...
}

ThreeImplement用Three.js引擎實現IRenderEngine介面
ThreeImplement

import { IRenderEngine } from "./IRenderEngine";
import {
	Scene,
...
} from "three";

export let implement = (): IRenderEngine => {
	return {
		createScene: () => {
			return new Scene();
		},
		...
  }
}

DependencyContainer負責維護由Client注入的IRenderEngine的實現
DependencyContainer:

import {IRenderEngine} from "./IRenderEngine"

let _renderEngine: IRenderEngine = null

export let getRenderEngine = (): IRenderEngine => {
  return _renderEngine;
}

export let setRenderEngine = (renderEngine: IRenderEngine) {
  _renderEngine = renderEngine;
}

Editor增加injectDependencies函式,用於通過DependencyContainer注入由Client傳過來的IRenderEngine的實現;另外還通過DependencyContainer獲得注入的IRenderEngine的實現,呼叫它來建立場景
Editor:

import { getRenderEngine, setRenderEngine } from "./DependencyContainer";
import { IRenderEngine } from "./IRenderEngine";

export let injectDependencies = function (threeImplement: IRenderEngine) {
	setRenderEngine(threeImplement);
};

export let createScene = function () {
	let { createScene, ...} = getRenderEngine()

	let scene = createScene()

	...
}

Client選擇要注入的IRenderEngine的實現
Client:

import { createScene, injectDependencies } from "./Editor";
import { implement } from "./ThreeImplement";

injectDependencies(implement())

createScene()

經過這樣的設計後,就把編輯器和渲染引擎隔離開了
現在如果要升級Three.js引擎版本,只需要修改ThreeImplement;如果要替換為Babylon引擎,只需要增加BabylonImplement,修改Client為注入BabylonImplement。它們都不再影響Editor的程式碼了

設計意圖

隔離系統的外部依賴

定義

我們定義依賴隔離模式為:
隔離系統的外部依賴,使得外部依賴的變化不會影響系統

將外部依賴隔離後,系統變得更“純”了,類似於函數語言程式設計中的“純函式”的概念,消除了外部依賴帶來了副作用

那麼哪些依賴屬於外部依賴呢?對於編輯器而言,渲染引擎、後端服務、檔案操作、日誌等都屬於外部依賴;對於網站而言,UI元件庫(如Ant Design)、後端服務、資料庫操作等都屬於外部依賴。可以將每個外部依賴都抽象為對應的IDendency介面,從而都隔離出去。

image

依賴隔離模式的通用類圖

我們來看看依賴隔離模式的相關角色:

  • IDendepency(依賴介面)
    該角色對依賴的具體庫的API進行了抽象
  • DependencyImplement(依賴實現)
    該角色是對IDendepency的實現
  • DependencyLibrary(依賴的具體庫)
    該角色通常為第三方庫,如Three.js引擎等
  • DependencyContainer(依賴容器)
    該角色封裝了注入的依賴實現,為每個注入的依賴實現提供get/set函式
  • System(系統)
    該角色使用了一個或多個外部依賴,它只知道外部依賴的介面而不知道外部依賴的具體實現

各個角色之間的關係為:

  • 可以有多個依賴介面,如除了IRenderEngine以外,還可以IFile、IServer等依賴介面
  • 一個IDendepency可以有多個DependencyImplement,如對於IRenderEngine,除了有ThreeImplement,還可以有Babylon引擎的依賴實現BabylonImplement
  • 一個DependencyImplement一般只組合一個DependencyLibrary,但也可以組合多個DependencyLibrary,比如對於IRenderEngine,可以增加ThreeAndBabylonImplement,它同時組合Three.js和Babylon.js這兩個DependencyLibrary,這樣就使得編輯器可以同時使用兩個引擎來渲染,達到最大化的渲染能力

下面我們來看看各個角色的抽象程式碼:

  • IDendepency抽象程式碼
type abstractType1 = any;
...

export interface IDependency1 {
    abstractAPI1(): abstractType1,
    ...
}
  • DependencyImplement抽象程式碼
import { IDependency1 } from "./IDependency1";
import {
	api1,
...
} from "dependencylibrary1";

export let implement = (): IDependency1 => {
	return {
		abstractAPI1: () => {
			...
			return api1()
	},
		...
  }
}
  • DependencyLibrary抽象程式碼
export let api1 = function () {
	...
}

...
  • DependencyContainer抽象程式碼
import { IDependency1 } from "./IDependency1"

let _dependency1: IDependency1 = null
...

export let getDependency1 = (): IDependency1 => {
  return _dependency1;
}

export let setDependency1 = (dependency1: IDependency1) {
  _dependency1 = dependency1;
}

...
  • System抽象程式碼
import { getDependency1, setDependency1 } from "./DependencyContainer";
import { IDependency1 } from "./IDependency1";

export let injectDependencies = function (dependency1Implement1: IDependency1, ...) {
	setDependency1(dependency1Implement1)
	注入其它依賴實現...
};

export let doSomethingNeedDependency1 = function () {
	let { abstractAPI1, ...} = getDependency1()

	let abstractType1 = abstractAPI1()

	...
}
  • Client抽象程式碼
import { doSomethingNeedDependency1, injectDependencies } from "./System";
import { implement } from "./Dependency1Implement1";

injectDependencies(implement(), 其它依賴實現...)

doSomethingNeedDependency1()

依賴隔離模式主要遵循下面的設計原則:

  • 依賴倒置原則
    系統依賴於外部依賴的抽象(IDendepency)而不是外部依賴的細節(DependencyImplement和DependencyLibrary),從而外部依賴的細節的變化不會影響系統
  • 開閉原則
    可以增加更多的IDendepency,從而隔離更多的外部依賴;或者對一個IDendepency增加更多的DependencyImplement,從而能夠替換外部依賴的實現。這些都不會影響System,從而實現了對擴充套件開放
    可以升級外部依賴,這也只會影響DependencyImplement和DependencyLibrary,不會影響System,從而實現了對修改關閉

依賴隔離模式也應用了“依賴注入”、“控制反轉”的思想

應用

優點

  • 提高系統的穩定性
    外部依賴的變化不會影響系統
  • 提高系統的擴充套件性
    可以任意修改外部依賴的實現而不影響系統
  • 提高系統的可維護性
    系統與外部依賴解耦,便於維護

缺點

使用場景

  • 需要替換外部依賴的實現,如替換編輯器使用的渲染引擎
  • 外部依賴經常變化,如編輯器使用的渲染引擎的版本頻繁升級
  • 執行時外部依賴會變化
    對於這種情況,在執行時注入對應的DependencyImplement即可。如編輯器向使用者提供了“切換渲染效果”的功能,使使用者點選一個按鈕後,就可以切換渲染引擎。為了實現該功能,只需在按鈕的點選事件中注入對應的DependencyImplement到DependencyContainer中即可

注意事項

  • IDendepency要足夠抽象,這樣才能不至於在修改或增加DependencyImplement時修改IDendepency,導致影響System
    當然,在開發階段難免考慮不足,如當一開始只有一個DependencyImplement時,IDendepency往往只會考慮這個DependencyImplement,導致在增加其它DependencyImplement時就需要修改IDendepency,使其變得更加抽象。
    我們可以在開發階段修改IDendepency,而在上線後則確保IDendepency已經足夠抽象和穩定,不需要再改動

擴充套件

如果基於依賴隔離模式這樣設計一個架構:

  • 定義4個層,其中的應用服務層、領域服務層、領域模型層為上下層的關係,上層依賴下層;外部依賴層則屬於獨立的層,層中的外部依賴是按照依賴隔離模式設計,在執行時注入
  • 將系統的所有外部依賴都隔離出去,也就是為每個外部依賴建立一個IDendepency,其中DependencyImplement位於外部依賴層,IDendepency位於領域模型層中的Application Core
    其它三層不依賴外部依賴層,而是依賴領域模型層中的Application Core(具體就是依賴IDendepency)
  • 運用領域驅動設計DDD設計系統,將系統的核心邏輯建模為領域模型,放到領域模型層

那麼這樣的架構就是洋蔥架構
洋蔥架構如下圖所示:
image

它的核心思想就是將變化最頻繁的外部依賴層隔離出去,並使變化最少的領域模型層獨立而不依賴其它層。
在傳統的架構中,領域模型層會依賴外部依賴層(如在領域模型中呼叫後端服務等),但是現在卻解耦了。這樣的好處就是如果外部依賴層變化,不會影響其他層

最佳實踐

如果只是開發Demo或者短期使用的系統,可以在系統中直接呼叫外部依賴庫,這樣開發得最快;
如果要開發長期維護的系統(如編輯器),則最好一開始就使用依賴隔離模式將所有的外部依賴都隔離,或者升級為洋蔥架構。這樣可以避免到後期如果要修改外部依賴時需要修改系統所有相關程式碼的情況。

我遇到過這種問題:3D應用開發完成後,交給3個外部使用者使用。用了一段時間後,這3個使用者提出了不同的修改外部依賴的要求:第一個使用者想要升級3D應用依賴的渲染引擎A,第二個使用者想要替換A為B,第三個使用者想要同時使用B和升級後的A來渲染。
如果3D應用是直接呼叫外部依賴庫的話,我們就需要去修改交付的3份程式碼中系統的相關程式碼,且每份程式碼都需要不同的修改(因為3個使用者的需求不同),工作量很大;
如果使用了依賴隔離模式進行了解耦,那麼就只需要對3D應用做下面的修改:
1.修改AImplement和ALibrary(升級)
2.增加BImplement
3.增加BLibrary
4.增加ABImplement
對交付給使用者的程式碼做下面的修改:
1.更新第一個使用者交付程式碼的AImplement和ALibrary
2.為第二個使用者交付程式碼增加BImplement、BLibrary;修改Client程式碼,注入BImplement
3.為第三個使用者交付程式碼增加ABImplement、BLibrary;修改Client程式碼,注入ABImplement
相比之下工作量減少了很多

更多資料推薦

可以瞭解下依賴注入 控制反轉 依賴倒置

洋蔥架構的資料在這裡:資料

六邊形架構類似於洋蔥架構,相關資料在這裡

參考資料

《設計模式之禪》