大型 web 前端架構(gòu)設(shè)計-面向抽象編程入門
時間:2023-05-16 12:39:01 | 來源:網(wǎng)站運(yùn)營
時間:2023-05-16 12:39:01 來源:網(wǎng)站運(yùn)營
大型 web 前端架構(gòu)設(shè)計-面向抽象編程入門:作者:svenzeng,騰訊 PCG 前端開發(fā)工程師
面向抽象編程,是構(gòu)建一個大型系統(tǒng)非常重要的參考原則。 但對于許多前端同學(xué)來說,對面向抽象編程的理解說不上很深刻。大部分同學(xué)的習(xí)慣是 拿到需求單和設(shè)計稿之后就開始編寫 UI 界面,UI 里哪個按鈕需要調(diào)哪些方法,接下來再編寫這些方法,很少去考慮復(fù)用性。當(dāng)某天發(fā)生需求變更時,才發(fā)現(xiàn)目前的代碼很難適應(yīng)這些變更,只能重寫。日復(fù)一日,如此循環(huán)。
當(dāng)?shù)谝淮慰吹健皩⒊橄蠛途唧w實現(xiàn)分開”這句話的時候,可能很難明白它表達(dá)的是什么意思。什么是抽象,什么又是具體實現(xiàn)?為了理解這段話,我們耐下性子,先看一個假想的小例子,回憶下什么是面向具體實現(xiàn)編程。
假設(shè)我們正在開發(fā)一個類似“模擬人生”的程序,并且創(chuàng)造了小明,為了讓他的每一天都有規(guī)律的生活下去,于是給他的核心程序里設(shè)置了如下邏輯:
1、8點(diǎn)起床2、9點(diǎn)吃面包3、17點(diǎn)打籃球
過了一個月,小明厭倦了一成不變的重復(fù)生活,某天早上起來之后他突然想吃薯片,而不是面包。等到傍晚的時候他想去踢足球,而不是繼續(xù)打籃球,于是我們只好修改源代碼:
1、8點(diǎn)起床 2、9點(diǎn)吃面包 -> 9點(diǎn)吃薯片 3、17點(diǎn)打籃球 -> 17點(diǎn)踢足球
又過了一段時間,小明希望周 3 和周 5 踢足球,星期天打羽毛球,這時候為了滿足需求,我們的程序里可能會被加進(jìn)很多 if、else 語句。
為了滿足需求的變換,跟現(xiàn)實世界很相似,我們需要深入核心源代碼,做大量改動。現(xiàn)在再想想自己的代碼里,是不是有很多似曾相識的場景?
這就是一個面向具體實現(xiàn)編程的例子,在這里,吃面包、吃薯片、打籃球、踢足球這些動作都屬于具體實現(xiàn),映射到程序中,它們就是一個模塊、一個類,或者一個函數(shù),包含著一些具體的代碼,去負(fù)責(zé)某件具體的事情。
一旦我們想在代碼中更改這些實現(xiàn),必然需要被迫深入和修改核心源代碼。當(dāng)需求發(fā)生變更時,一方面,如果核心代碼中存在各種各樣的大量具體實現(xiàn),想去全部重寫這些具體實現(xiàn)的工作量是巨大的,另一方面,修改代碼總是會帶來未知的風(fēng)險,當(dāng)模塊間的聯(lián)系千絲萬縷時,修改任何一個模塊都得小心翼翼,否則很可能發(fā)生改好 1 個 bug,多出 3 個 bug 的情況。
抽取出共同特性
抽象的意思是:從一些事物中抽取出共同的、本質(zhì)性的特征。
如果我們總是針對具體實現(xiàn)去編寫代碼,就像上面的例子,要么寫死 9 點(diǎn)吃面包,要么寫死 9 點(diǎn)吃薯片。這樣一來,在業(yè)務(wù)發(fā)展和系統(tǒng)迭代過程中,系統(tǒng)就會變得僵硬和修改困難。產(chǎn)品需求總是多變的,我們需要在多變的環(huán)境里,盡量讓核心源代碼保持穩(wěn)定和不用修改。
方法就是需要抽取出“9 點(diǎn)吃面包”和“9 點(diǎn)吃薯片”的通用特性,這里可以用“9 點(diǎn)吃早餐”來表示這個通用特性。同理,我們抽取出“17 點(diǎn)打籃球”和“17 點(diǎn)踢足球”的通用特性,用“17 點(diǎn)做運(yùn)動”來代替它們。然后讓這段核心源代碼去依賴這些“抽象出來的通用特性”,而不再是依賴到底是“吃面包”還是“吃早餐”這種“具體實現(xiàn)”。
我們將這段代碼寫成:
1、 8點(diǎn)起床 2、 9點(diǎn)吃早餐 3、17點(diǎn)做運(yùn)動
這樣一來,這段核心源代碼就變得相對穩(wěn)定多了,不管以后小明早上想吃什么,都無需再改動這段代碼,只要在后期,由外層程序?qū)ⅰ俺栽绮汀边€是“吃薯片”注入進(jìn)來即可。
真實示例
剛才是一個虛擬的例子,現(xiàn)在看一段真實的代碼,這段代碼依然很簡單,但可以很好的說明抽象的好處。
在某段核心業(yè)務(wù)代碼里,需要利用 localstorge 儲存一些用戶的操作信息,代碼很快就寫好了:
import ‘localstorge’ from 'localstorge';class User{ save(){ localstorge.save('xxx'); }}const user = new User();user.save();
這段代碼本來工作的很好,但是有一天,我們發(fā)現(xiàn)用戶信息相關(guān)數(shù)據(jù)量太大, 超過了 localstorge 的儲存容量。這時候我們想到了 indexdb,似乎用 indexdb 來存儲會更加合理一些。
現(xiàn)在我們需要將 localstorge 換成 indexdb,于是不得不深入 User 類,將調(diào)用 localstorge 的地方修改為調(diào)用 indexdb。似乎又回到了熟悉的場景,我們發(fā)現(xiàn)程序里,在許多核心業(yè)務(wù)邏輯深處,不只一個,而是有成百上千個地方調(diào)用了 localstorge,這個簡單的修改都成了災(zāi)難。
所以,我們依然需要提取出 localstorge 和 indexdb 的共同抽象部分,很顯然,localstorge 和 indexdb 的共同抽象部分,就是都會向它的消費(fèi)者提供一個 save 方法。作為它的消費(fèi)者,也就是業(yè)務(wù)中的這些核心邏輯代碼,并不關(guān)心它到底是 localstorge 還是 indexdb,這件事情完全可以等到程序后期再由更外層的其他代碼來決定。
我們可以申明一個擁有 save 方法的接口:
interface DB{ save(): void;}
然后讓核心業(yè)務(wù)模塊 User 僅僅依賴這個接口:
import DB from 'DB';class User{ constructor( private db: DB ){ } save(){ this.db.save('xxx'); }}
接著讓 Localstorge 和 Indexdb 分別實現(xiàn) DB 接口:
class Localstorge implements DB{ save(str:string){ ...//do something }}class Indexdb implements DB{ save(str:string){ ...//do something }}const user = new User( new Localstorge() );//orconst user = new User( new Indexdb() );userInfo.save();
這樣一來,User 模塊從依賴 Localstorge 或者 Indexdb 這些具體實現(xiàn),變成了依賴 DB 接口,User 模塊成了一個穩(wěn)定的模塊,不管以后我們到底是用 Localstorge 還是用 Indexdb,User 模塊都不會被迫隨之進(jìn)行改動。
讓修改遠(yuǎn)離核心源代碼
可能有些同學(xué)會有疑問,雖然我們不用再修改 User 模塊,但還是需要去選擇到底是用 Localstorge 還是用 Indexdb,我們總得在某個地方改動代碼把,這和去改動 User 模塊的代碼有什么區(qū)別呢?
實際上,我們說的面向抽象編程,通常是針對核心業(yè)務(wù)模塊而言的。User 模塊是屬于我們的核心業(yè)務(wù)邏輯,我們希望它是盡量穩(wěn)定的。不想僅僅因為選擇使用 Localstorge 還是 Indexdb 這種事情就得去改動 User 模塊。因為 User 模塊這些核心業(yè)務(wù)邏輯一旦被不小心改壞了,就會影響到千千萬萬個依賴它的外層模塊。
如果 User 模塊現(xiàn)在依賴的是 DB 接口,那它被改動的可能性就變小了很多。不管以后的本地存儲怎么發(fā)展,只要它們還是對外提供的是 save 功能,那 User 模塊就不會因為本地存儲的變化而發(fā)生改變。
相對具體行為而言,接口總是相對穩(wěn)定的,因為接口一旦要修改,意味著具體實現(xiàn)也要隨之修改。而反之當(dāng)具體行為被修改時,接口通常是不用改動的。
至于選擇到底是用 Localstorge 還是用 Indexdb 這件事情放在那里做,有很多種實現(xiàn)方式,通常我們會把它放在更容易被修改的地方,也就是遠(yuǎn)離核心業(yè)務(wù)邏輯的外層模塊,舉幾個例子:
* 在main函數(shù)或者其他外層模塊中生成Localstorge或者Indexdb對象,在User對象被創(chuàng)建時作為參數(shù)傳給User* 用工廠方法創(chuàng)建Localstorge或者Indexdb* 用依賴注入的容器來綁定DB接口和它具體實現(xiàn)之間的映射
內(nèi)層、外層和單向依賴關(guān)系
將系統(tǒng)分層,就像建筑師會將大廈分為很多層,每層有特有的設(shè)計和功能,這是構(gòu)建大型系統(tǒng)架構(gòu)的基礎(chǔ)。除了過時的 mvc 分層架構(gòu)方式外,目前常用的分層方式有洋蔥架構(gòu)(整潔架構(gòu))、DDD(領(lǐng)域驅(qū)動設(shè)計)架構(gòu)、六邊形架構(gòu)(端口-適配器架構(gòu))等,這里不會詳細(xì)介紹每個分層模式,但不管是洋蔥架構(gòu)、DDD 架構(gòu)、還是六邊形架構(gòu),它們的層與層之間,都會被相對而動態(tài)地區(qū)分為外層和內(nèi)層。
前面我們也提過好幾次內(nèi)層和外層的概念(大部分書里稱為高層和低層),那么在實際業(yè)務(wù)中,哪些模塊會對應(yīng)內(nèi)層,而哪些模塊應(yīng)該被放在外層,到底由什么規(guī)律來決定呢?
先觀察下自然屆,地球圍繞著太陽轉(zhuǎn),我們認(rèn)為太陽是內(nèi)層,地球是外層。眼睛接收光線后通過大腦成像,我們認(rèn)為大腦是內(nèi)層,眼睛是外層。當(dāng)然這里的內(nèi)層和外層不是由物理位置決定的,而是基于模塊的穩(wěn)定性,即越穩(wěn)定越難修改的模塊應(yīng)該被放在越內(nèi)層,而越易變越可能發(fā)生修改的模塊應(yīng)該被放在越外層。就像用積木搭建房子時,我們需要把最堅固的積木搭在下面。
這樣的規(guī)則設(shè)置是很有意義的,因為一個成熟的分層系統(tǒng)都會嚴(yán)格遵守單向依賴關(guān)系。
我們看下面這個圖:
假設(shè)系統(tǒng)中被分為了 A、B、C、D 這 4 層,那么 A 是相對的最內(nèi)層,外層依次是 B、C、D。在一個嚴(yán)格單向依賴的系統(tǒng)中,依賴關(guān)系總是只能從外層指向內(nèi)層。
這是因為,如果最內(nèi)層的 A 模塊被修改,則依賴 A 模塊的 B、C、D 模塊都會分別受到牽連。在靜態(tài)類型語言中,這些模塊因為 A 模塊的改動都要重新進(jìn)行編譯,而如果它們引用了 A 模塊的某個變量或者調(diào)用了 A 模塊中的某個方法,那么它們很可能因為 A 模塊的修改而需要隨之修改。所以我們希望 A 模塊是最穩(wěn)定的,它最好永遠(yuǎn)不要發(fā)生修改。
但如果外層的模塊被修改呢?比如 D 模塊被修改之后,因為它處在最外層,沒有其他模塊依賴它,它影響的僅僅是自己而已,A、B、C 模塊都不需要擔(dān)心它們收到任何影響,所以,當(dāng)外層模塊被修改時,對系統(tǒng)產(chǎn)生的破壞性相對是比較小的。
如果從一開始就把容易變化,經(jīng)常跟著產(chǎn)品需求變更的模塊放在靠近內(nèi)層,那意味著我們經(jīng)常會因為這些模塊的改動,不得不去跟著調(diào)整或者測試系統(tǒng)中依賴它的其他模塊。
可以設(shè)想一下,造物者也許也是基于單向依賴原則來設(shè)置宇宙和自然界的,比如行星依賴恒星,沒有地球并不會對太陽造成太大影響,而如果失去了太陽,地球自然也不存在。眼睛依賴大腦,大腦壞了眼睛自然失去了作用,但眼睛壞了大腦的其他功能還能使用??雌饋淼厍蛑皇翘柕囊粋€插件,而眼睛只是大腦的一個插件。
回到具體的業(yè)務(wù)開發(fā),核心業(yè)務(wù)邏輯一般是相對穩(wěn)定的,而越接近用戶輸入輸出的地方(越接近產(chǎn)品經(jīng)理和設(shè)計師,比如 UI 界面),則越不穩(wěn)定。比如開發(fā)一個股票交易軟件,股票交易的核心規(guī)則是很少發(fā)生變化的,但系統(tǒng)的界面長成什么樣子很容易發(fā)生變化。所以我們通常會把核心業(yè)務(wù)邏輯放在內(nèi)層,而把接近用戶輸入輸出的模塊放在外層。
在騰訊文檔業(yè)務(wù)中,核心業(yè)務(wù)邏輯指的就是將用戶輸入數(shù)據(jù)通過一定的規(guī)則進(jìn)行計算,轉(zhuǎn)換成文檔數(shù)據(jù)。這些轉(zhuǎn)換規(guī)則和具體計算過程是騰訊文檔的核心業(yè)務(wù)邏輯,它們是非常穩(wěn)定的,從微軟 office 到谷歌文檔到騰訊文檔,30 多年了也沒有太多變化,它們理應(yīng)被放在系統(tǒng)的內(nèi)層。另一方面,不管這些核心業(yè)務(wù)邏輯跑在瀏覽器、終端或者是 node 端,它們也都不應(yīng)該變化。而網(wǎng)絡(luò)層、存儲層,離線層、用戶界面這些是易變的,在終端環(huán)境里,終端用戶界面層和 web 層的實現(xiàn)就完全不一樣。在 node 端,存儲層或許可以直接從系統(tǒng)中剔除掉,因為在 node 端,我們只需要利用核心業(yè)務(wù)邏輯模塊對函數(shù)進(jìn)行一些計算。同理,在單元測試或者集成測試的時候,離線層和存儲層可能都是不需要的。在這些易變的情況下,我們需要把非核心業(yè)務(wù)邏輯都放在外層,方便它們被隨時修改或替換。
所以,遵守單向依賴原則能極大提高系統(tǒng)穩(wěn)定性,減少需求變更時對系統(tǒng)的破壞性。我們在設(shè)計各個模塊的時候,要將相當(dāng)多的時間花在設(shè)計層級、模塊的切分,以及層級、模塊之間的依賴關(guān)系上,我們常說“分而治之”, “分”就是指層級、模塊、類等如何切分,“治”就是指如何將分好的層級、模塊、類合理的聯(lián)系起來。這些設(shè)計比具體的編碼細(xì)節(jié)工作要更加重要。
依賴反轉(zhuǎn)原則
依賴反轉(zhuǎn)原則的核心思想是:內(nèi)層模塊不應(yīng)該依賴外層模塊,它們都應(yīng)該依賴于抽象。
盡管我們會花很多時間去考慮哪些模塊分別放到內(nèi)層和外層,盡量保證它們處于單向依賴關(guān)系。但在實際開發(fā)中,總還是有不少內(nèi)層模塊需要依賴外層模塊的場景。
比如在 Localstorge 和 Indexdb 的例子里,User 模塊作為內(nèi)層的核心業(yè)務(wù)邏輯,卻依賴了外層易變的 Localstorge 和 Indexdb 模塊,導(dǎo)致 User 模塊變得不穩(wěn)定。
import ‘localstorge’ from 'localstorge';class User{ save(){ localstorge.save('xxx'); }}const user = new User();user.save();
缺圖
為了解決 User 模塊的穩(wěn)定性問題,我們引入了 DB 抽象接口,這個接口是相對穩(wěn)定的,User 模塊改為去依賴 DB 抽象接口,從而讓 User 變成一個穩(wěn)定的模塊。
Interface DB{ save(): void;}
然后讓核心業(yè)務(wù)模塊 User 僅僅依賴這個接口:
import DB from 'DB';class User{ constructor( private db: DB ){ } save(){ this.db.save('xxx'); }}
接著讓 Localstorge 和 Indexdb 分別實現(xiàn) DB 接口:
class Localstorge implements DB{ save(str:string){ ...//do something }}
依賴關(guān)系變成: 缺圖
User -> DB <- Localstorge
在圖 1 和圖 2 看來,User 模塊不再顯式的依賴 Localstorge,而是依賴穩(wěn)定的 DB 接口,DB 到底是什么,會在程序后期,由其他外層模塊將 Localstorge 或者 Indexdb 注入進(jìn)來,這里的依賴關(guān)系看起來被反轉(zhuǎn)了,這種方式被稱為“依賴反轉(zhuǎn)”。
找到變化,并將其抽象和封裝出來
我們的主題“面向抽象編程”,很多時候其實就是指的“面向接口編程”,面向抽象編程站在系統(tǒng)設(shè)計的更宏觀角度,指導(dǎo)我們?nèi)绾螛?gòu)建一個松散的低耦合系統(tǒng),而面向接口編程則告訴我們具體實現(xiàn)方法。依賴倒置原則告訴我們?nèi)绾瓮ㄟ^“面向接口編程”,讓依賴關(guān)系總是從外到內(nèi),指向系統(tǒng)中更穩(wěn)定的模塊。
知易行難,面向抽象編程雖然概念上不難理解,但在真實實施中卻總是不太容易。哪些模塊應(yīng)該被抽象,哪些依賴應(yīng)該被倒轉(zhuǎn),系統(tǒng)中引入多少抽象層是合理的,這些問題都沒有標(biāo)準(zhǔn)答案。
我們在接到一個需求,對其進(jìn)行模塊設(shè)計時,要先分析這個模塊以后有沒有可能隨著需求變更被替換,或是被大范圍修改重構(gòu)?當(dāng)我們發(fā)現(xiàn)可能會存在變化之后,就需要將這些變化封裝起來,讓依賴它的模塊去依賴這些抽象。
比如上面例子中的 Localstorge 和 indexdb,有經(jīng)驗的程序會很容易想到它們是有可能需要被互相替換的,所以它們最好一開始就被設(shè)計為抽象的。
同理,我們的數(shù)據(jù)庫也可能產(chǎn)生變化,也許今天使用的是 mysql,但明年可能會替換為 oracle,那么我們的應(yīng)用程序里就不應(yīng)該強(qiáng)依賴 mysql 或者 oracle,而是要讓它們依賴 mysql 和 oracle 的公共抽象。
再比如,我們經(jīng)常會在程序中使用 ajax 來傳輸用戶輸入數(shù)據(jù),但有一天可能會想將 ajax 替換為 websocket 的請求,那么核心業(yè)務(wù)邏輯也應(yīng)該去依賴 ajax 和 websocket 的公共抽象。
封裝變化與設(shè)計模式
實際上常見的 23 種設(shè)計模塊,都是從封裝變化的角度被總結(jié)出來的。拿創(chuàng)建型模式來說,要創(chuàng)建一個對象,是一種抽象行為,而具體創(chuàng)建什么對象則是可以變化的,創(chuàng)建型模式的目的就是封裝創(chuàng)建對象的變化。而結(jié)構(gòu)型模式封裝的是對象之間的組合關(guān)系。行為型模式封裝的是對象的行為變化。
比如工廠模式,通過將創(chuàng)建對象的變化封裝在工廠里,讓核心業(yè)務(wù)不需要依賴具體的實現(xiàn)類,也不需要了解過多的實現(xiàn)細(xì)節(jié)。當(dāng)創(chuàng)建的對象有變化的時候,我們只需改動工廠的實現(xiàn)就可以,對核心業(yè)務(wù)邏輯沒有造成影響。
比如模塊方法模式,封裝的是執(zhí)行流程順序,子類會繼承父類的模版函數(shù),并按照父類設(shè)置好的流程規(guī)則執(zhí)行下去,具體的函數(shù)實現(xiàn)細(xì)節(jié),則由子類自己來負(fù)責(zé)實現(xiàn)。
通過封裝變化的方式,可以把系統(tǒng)中穩(wěn)定不變的部分和容易變化的部分隔離開來。在系統(tǒng)的演變過程中,只需要替換或者修改那些容易變化的部分,如果這些部分是已經(jīng)封裝好的,替換起來也相對容易。這可以最大程度地保證程序的穩(wěn)定性。
避免過度抽象
雖然抽象提高了程序的擴(kuò)展性和靈活性,但抽象也引入了額外的間接層,帶來了額外的復(fù)雜度。本來一個模塊依賴另外一個模塊,這種依賴關(guān)系是最簡單直接的,但我們在中間每增加了一個抽象層,就意味著需要一直關(guān)注和維護(hù)這個抽象層。這些抽象層被加入系統(tǒng)中,必然會增加系統(tǒng)的層次和復(fù)雜度。
如果我們判斷某些模塊相對穩(wěn)定,很長時間內(nèi)都不會發(fā)生變化,那么沒必要一開始就讓它們成為抽象。
比如 java 中的 String 類,它非常穩(wěn)定,所以并沒有對 String 做什么抽象。
比如一些工具方法,類似 utils.getCookie(),我很難想象 5 年內(nèi)有什么東西會代替 cookie,所以我更喜歡直接寫 getCookie。
比如騰訊文檔 excel 的數(shù)據(jù) model,它屬于內(nèi)核中的內(nèi)核,像整個身體中的骨骼和經(jīng)脈,已經(jīng)融入到了各個應(yīng)用邏輯中,它被替換的可能性非常小,難度也非常大,不亞于重寫一個騰訊文檔 excel,所以也沒有必要對 model 做過度抽象。
結(jié)語
面向抽象編程有 2 個最大好處。
一方面,面向抽象編程可以將系統(tǒng)中經(jīng)常變化的部分封裝在抽象里,保持核心模塊的穩(wěn)定。
另一方面,面向抽象編程可以讓核心模塊開發(fā)者從非核心模塊的實現(xiàn)細(xì)節(jié)中解放出來,將這些非核心模塊的實現(xiàn)細(xì)節(jié)留在后期或者留給其他人。
這篇文章討論的實際主要偏重第一點(diǎn),即封裝變化。封裝變化是構(gòu)建一個低耦合松散系統(tǒng)的關(guān)鍵。
這篇文章,作為面向抽象編程的入門,希望能幫助一些同學(xué)認(rèn)識面向抽象編程的好處,以及掌握一些基礎(chǔ)的面向抽象編程的方法。
更多干貨盡
在騰訊技術(shù),歡迎關(guān)注官方公眾號
:騰訊技術(shù)工程,微信交流群已建立,交流討論可加
:Journeylife1900(備注騰訊技術(shù)) 。
關(guān)鍵詞:抽象,入門,設(shè)計,大型