阿里研究員:警惕軟件復(fù)雜度困局
阿里妹導(dǎo)讀:對(duì)于大型的軟件系統(tǒng)如互聯(lián)網(wǎng)分布式應(yīng)用或企業(yè)級(jí)軟件,為何我們常常會(huì)陷入復(fù)雜度陷阱?如何識(shí)別復(fù)雜度增長(zhǎng)的因素?在代碼開(kāi)發(fā)以及演進(jìn)的過(guò)程中需要遵循哪些原則?本文將分享阿里研究員谷樸關(guān)于軟件復(fù)雜度的思考:什么是復(fù)雜度、復(fù)雜度是如何產(chǎn)生的以及解決的思路。較長(zhǎng),同學(xué)們可收藏后再看。
文末福利:免費(fèi)下載《2020年微服務(wù)領(lǐng)域開(kāi)源數(shù)字化報(bào)告》。
寫(xiě)在前面
軟件設(shè)計(jì)和實(shí)現(xiàn)的本質(zhì)是工程師相互通過(guò)“寫(xiě)作”來(lái)交流一些包含豐富細(xì)節(jié)的抽象概念并且不斷迭代過(guò)程。
另外,如果你的代碼生存期一般不超過(guò)6個(gè)月,本文用處不大。
一 軟件架構(gòu)的核心挑戰(zhàn)是快速增長(zhǎng)的復(fù)雜性
越是大型系統(tǒng),越需要簡(jiǎn)單性。
大型系統(tǒng)的本質(zhì)問(wèn)題是復(fù)雜性問(wèn)題。互聯(lián)網(wǎng)軟件,是典型的大型系統(tǒng),如下圖所示,數(shù)百個(gè)甚至更多的微服務(wù)相互調(diào)用/依賴,組成一個(gè)組件數(shù)量大、行為復(fù)雜、時(shí)刻在變動(dòng)(發(fā)布、配置變更)當(dāng)中的動(dòng)態(tài)的、復(fù)雜的系統(tǒng)。而且,軟件工程師們常常自嘲,“when things work, nobody knows why”。
圖源:https://divante.com/blog/10-companies-that-implemented-the-microservice-architecture-and-paved-the-way-for-others/
如果我們只是寫(xiě)一段獨(dú)立代碼,不和其他系統(tǒng)交互,往往設(shè)計(jì)上要求不會(huì)很高,代碼是否易于使用、易于理解、易于測(cè)試和維護(hù),根本不是問(wèn)題。而一旦遇到大型的軟件系統(tǒng)如互聯(lián)網(wǎng)分布式應(yīng)用或者企業(yè)級(jí)軟件,我們常常陷入復(fù)雜度陷阱,下圖the life of a software engineer是我很喜歡的一個(gè)軟件cartoon,非常形象的展示了復(fù)雜度陷阱。
圖源:http://themetapicture.com/the-life-of-a-software-engineer/
做為一個(gè)有追求的軟件工程師,大家肯定都思考過(guò),我手上的項(xiàng)目,如何避免這種似乎難以避免的復(fù)雜度困境?
然而對(duì)于這個(gè)問(wèn)題給出答案,卻出乎意料的困難:很多的文章都給出了軟件架構(gòu)的設(shè)計(jì)建議,然后正如軟件領(lǐng)域的經(jīng)典論著《No silver bullet》所說(shuō),這個(gè)問(wèn)題沒(méi)有神奇的解決方案。并不是說(shuō)那么多的架構(gòu)文章都沒(méi)用(其實(shí)這么方法多半都有用),只不過(guò),人們很難真正去follow這些建議并貫徹下去。為什么?我們還是需要徹底理解這些架構(gòu)背后的思考和邏輯。所以我覺(jué)得有必要從頭開(kāi)始整理這個(gè)邏輯:什么是復(fù)雜度,復(fù)雜度是如何產(chǎn)生的,以及解決的思路。
二 軟件的復(fù)雜度為什么會(huì)快速增長(zhǎng)?
要理解軟件復(fù)雜度會(huì)快速增長(zhǎng)的本質(zhì)原因,需要理解軟件是怎么來(lái)的。我們首先要回答一個(gè)問(wèn)題,一個(gè)大型的軟件是建造出來(lái)的,還是生長(zhǎng)出來(lái)的?BUILT vs GROWN,that is the problem.
1 軟件是長(zhǎng)出來(lái)的,不是建造出來(lái)的
軟件不是建造出來(lái)的,甚至不是設(shè)計(jì)出來(lái)的。軟件是長(zhǎng)出來(lái)的。
這個(gè)說(shuō)法初看上去和我們平時(shí)的認(rèn)識(shí)似乎不同,我們常常談軟件架構(gòu),架構(gòu)這個(gè)詞似乎蘊(yùn)含了一種建造和設(shè)計(jì)的意味。然而,對(duì)于軟件系統(tǒng)來(lái)說(shuō),我們必須認(rèn)識(shí)到,架構(gòu)師設(shè)計(jì)的不是軟件的架構(gòu),而是軟件的基因,而這些基因如何影響軟件未來(lái)的形態(tài)則是難以預(yù)測(cè),無(wú)法完全控制。
為什么這么說(shuō)?所謂建造和“生長(zhǎng)”差異在哪里?其實(shí),我們看今天一個(gè)復(fù)雜的軟件系統(tǒng),確實(shí)很像一個(gè)復(fù)雜的建筑物。但是把軟件比作一棟摩天大樓卻不是一個(gè)好的比喻。原因在于,一個(gè)摩天大樓無(wú)論多么復(fù)雜,都是事先可以根據(jù)設(shè)計(jì)出完整詳盡的圖紙,按圖準(zhǔn)確施工,保證質(zhì)量就能建造出來(lái)的。然而現(xiàn)實(shí)中的大型軟件系統(tǒng),卻不是這么建造出來(lái)的。
例如淘寶由一個(gè)單體PHP應(yīng)用,經(jīng)過(guò)4、5代架構(gòu)不斷演進(jìn),才到今天服務(wù)十億人規(guī)模的電商交易平臺(tái)。支付寶,Google搜索,Netflix微服務(wù),都是類似的歷程。
是不是一定要經(jīng)過(guò)幾代演進(jìn)才能構(gòu)建出來(lái)大型軟件,就不能一次到位嗎?如果一個(gè)團(tuán)隊(duì)離開(kāi)淘寶,要拉開(kāi)架勢(shì)根據(jù)淘寶交易的架構(gòu)重新復(fù)制一套,在現(xiàn)實(shí)中是不可能實(shí)現(xiàn)的:沒(méi)有哪個(gè)創(chuàng)業(yè)團(tuán)隊(duì)能有那么多資源同時(shí)投入這么多組件的開(kāi)發(fā),也不可能有一開(kāi)始就朝著超級(jí)復(fù)雜架構(gòu)開(kāi)發(fā)而能夠成功的實(shí)現(xiàn)。
也就是說(shuō),軟件的動(dòng)態(tài)“生長(zhǎng)”,更像是上圖所畫(huà)的那樣,是從一個(gè)簡(jiǎn)單的“結(jié)構(gòu)”生長(zhǎng)到復(fù)雜的“結(jié)構(gòu)”的過(guò)程。伴隨著項(xiàng)目本身的發(fā)展、研發(fā)團(tuán)隊(duì)的壯大,系統(tǒng)是個(gè)逐漸生長(zhǎng)的過(guò)程。
2 大型軟件的核心挑戰(zhàn)是軟件“生長(zhǎng)”過(guò)程中的理解和維護(hù)成本
復(fù)雜軟件系統(tǒng)最核心的特征是有成百上千的工程師開(kāi)發(fā)和維護(hù)的系統(tǒng)(軟件的本質(zhì)是工程師之間用編程語(yǔ)言來(lái)溝通抽象和復(fù)雜的概念,注意軟件的本質(zhì)不是人和機(jī)器溝通)。如果認(rèn)同這個(gè)定義,設(shè)想一下復(fù)雜軟件是如何產(chǎn)生的:無(wú)論最終多么復(fù)雜的軟件,都要從第一行開(kāi)始開(kāi)發(fā)。都要從幾個(gè)核心開(kāi)始開(kāi)發(fā),這時(shí)架構(gòu)只能是一個(gè)簡(jiǎn)單的、少量程序員可以維護(hù)的系統(tǒng)組成架構(gòu)。隨著項(xiàng)目的成功,再去逐漸細(xì)化功能,增加可擴(kuò)展性,分布式微服務(wù)化,增加功能,業(yè)務(wù)需求也在這個(gè)過(guò)程中不斷產(chǎn)生,系統(tǒng)滿足這些業(yè)務(wù)需求,帶來(lái)業(yè)務(wù)的增長(zhǎng)。業(yè)務(wù)增長(zhǎng)對(duì)于軟件系統(tǒng)迭代帶來(lái)了更多的需求,架構(gòu)隨著適應(yīng)而演進(jìn),投入開(kāi)發(fā)的人員隨著業(yè)務(wù)的成功增加,這樣不斷迭代,才會(huì)演進(jìn)出幾十,幾百,甚至幾千人同時(shí)維護(hù)的復(fù)雜系統(tǒng)來(lái)。
大型軟件設(shè)計(jì)核心要素是控制復(fù)雜度。這一點(diǎn)非常有挑戰(zhàn),根本原因在于軟件不是機(jī)械活動(dòng)的組合,不能在事先通過(guò)精心的“架構(gòu)設(shè)計(jì)”規(guī)避復(fù)雜度失控的風(fēng)險(xiǎn):相同的架構(gòu)圖/藍(lán)圖,可以長(zhǎng)出完完全全不同的軟件來(lái)。大型軟件設(shè)計(jì)和實(shí)現(xiàn)的本質(zhì)是大量的工程師相互通過(guò)“寫(xiě)作”來(lái)交流一些包含豐富細(xì)節(jié)的抽象概念并且相互不斷迭代的過(guò)程[2]。稍有差錯(cuò),系統(tǒng)復(fù)雜度就會(huì)失控。
所以說(shuō)了這么多是要停留在形而上嗎?并不是。我們的結(jié)論是,軟件架構(gòu)師最重要的工作不是設(shè)計(jì)軟件的結(jié)構(gòu),而是通過(guò)API,團(tuán)隊(duì)設(shè)計(jì)準(zhǔn)則和對(duì)細(xì)節(jié)的關(guān)注,控制軟件復(fù)雜度的增長(zhǎng)。
- 架構(gòu)師的職責(zé)不是試圖畫(huà)出復(fù)雜軟件的大圖。大圖好畫(huà),靠譜的系統(tǒng)難做。復(fù)雜的系統(tǒng)是從一個(gè)個(gè)簡(jiǎn)單應(yīng)用 一點(diǎn)點(diǎn)長(zhǎng)出來(lái)的。
- 當(dāng)我們發(fā)現(xiàn)自己的系統(tǒng)問(wèn)題多多,別怪“當(dāng)初”設(shè)計(jì)的人,坑不是一天挖出來(lái)的。每一個(gè)設(shè)計(jì)決定都在貢獻(xiàn)復(fù)雜度。
三 理解軟件復(fù)雜度的維度
1 軟件復(fù)雜度的兩個(gè)表現(xiàn)維度:認(rèn)知負(fù)荷與協(xié)同成本
我們分析理解了軟件復(fù)雜度快速增長(zhǎng)的原因,下面我們自然希望能解決復(fù)雜度快速增長(zhǎng)這一看似永恒的難題。但是在此之前,我們還是需要先分析清楚一件事情,復(fù)雜度本身是什么?又如何衡量?
代碼復(fù)雜度是用行數(shù)來(lái)衡量么?是用類的個(gè)數(shù)/文件的個(gè)數(shù)么?深入思考就會(huì)意識(shí)到,這些表面上的指標(biāo)并非軟件復(fù)雜度的核心度量。正如前面所分析的,軟件復(fù)雜度從根本上說(shuō)可以說(shuō)是一個(gè)主觀指標(biāo)(先別跳,耐心讀下去),說(shuō)其主觀是因?yàn)檐浖?fù)雜度只有在程序員需要更新、維護(hù)、排查問(wèn)題的時(shí)候才有意義。一個(gè)不需要演進(jìn)和維護(hù)的系統(tǒng)其架構(gòu)、代碼如何關(guān)系也就不大了(雖然現(xiàn)實(shí)中這種情況很少)。
既然 “軟件設(shè)計(jì)和實(shí)現(xiàn)的本質(zhì)是工程師相互通過(guò)寫(xiě)作來(lái)交流一些包含豐富細(xì)節(jié)的抽象概念并且不斷迭代過(guò)程” (第三次強(qiáng)調(diào)了),那么,復(fù)雜度指的是軟件中那些讓人理解和修改維護(hù)的困難程度。相應(yīng)的,簡(jiǎn)單性,就是讓理解和維護(hù)代碼更容易的要素。
“The goal of software architecture is to minimize the manpower required to build and maintain the required system.” Robert Martin, Clean Architecture [3].
因此我們將軟件的復(fù)雜度分解為兩個(gè)維度,都和人理解與維護(hù)軟件的成本相關(guān):
- 第一,認(rèn)知負(fù)荷 cognitive load :理解軟件的接口、設(shè)計(jì)或者實(shí)現(xiàn)所需要的心智負(fù)擔(dān)。
- 第二,協(xié)同成本Collaboration cost:團(tuán)隊(duì)維護(hù)軟件時(shí)需要在協(xié)同上額外付出的成本。
我們看到,這兩個(gè)維度有所區(qū)別,但是又相互關(guān)聯(lián)。協(xié)同成本高,讓軟件系統(tǒng)演進(jìn)速度變慢,效率變差,工作其中的工程師壓力增大,而長(zhǎng)期難以取得進(jìn)展,工程師傾向于離開(kāi)項(xiàng)目,最終造成質(zhì)量進(jìn)一步下滑的惡性循環(huán)。而認(rèn)知負(fù)荷高的軟件模塊讓程序員難以理解,從而產(chǎn)生兩個(gè)后果:(1) 維護(hù)過(guò)程中易于出錯(cuò),bug 率故障率高;(2) 更大機(jī)率 團(tuán)隊(duì)人員變化時(shí)被拋棄,新成員選擇另起爐灶,原有投入被浪費(fèi),甚至更高糟糕的是,代碼被拋棄但是又無(wú)法下線,成為定時(shí)炸彈。
2 影響到認(rèn)知負(fù)荷的因素
認(rèn)知負(fù)荷又可以分解為:
- 定義新的概念帶來(lái)認(rèn)知負(fù)荷,而這種認(rèn)知負(fù)荷與 概念和物理世界的關(guān)聯(lián)程度相關(guān)。
- 邏輯符合思維習(xí)慣程度:正反邏輯差異,邏輯嵌套和獨(dú)立原子化組合。繼承和組裝差異。
(1)不恰當(dāng)?shù)倪壿嫀?lái)的認(rèn)知成本
看以下案例[7]:
A. Code with too much nesting
- response = server.Call(request)
- if response.GetStatus() == RPC.OK:
- if response.GetAuthorizedUser():
- if response.GetEnc() == 'utf-8':
- if response.GetRows():
- vals = [ParseRow(r) for r in
- response.GetRows()]
- avg = sum(vals) / len(vals)
- return avg, vals
- else:
- raise EmptyError()
- else:
- raise AuthError('unauthorized')
- else:
- raise ValueError('wrong encoding')
- else:
- raise RpcError(response.GetStatus())
B. Code with less nesting
- response = server.Call(request)
- if response.GetStatus() != RPC.OK:
- raise RpcError(response.GetStatus())
- if not response.GetAuthorizedUser():
- raise ValueError('wrong encoding')
- if response.GetEnc() != 'utf-8':
- raise AuthError('unauthorized')
- if not response.GetRows():
- raise EmptyError()
- vals = [ParseRow(r) for r in
- response.GetRows()]
- avg = sum(vals) / len(vals)
- return avg, vals
比較A和B,邏輯是完全等價(jià)的,但是B的邏輯明顯更容易理解,自然也更容易在B的代碼基礎(chǔ)上增加功能,且新增的功能很可能也會(huì)維持這樣一個(gè)比較好的狀態(tài)。
而我們看到A的代碼,很難理解其邏輯,在維護(hù)的過(guò)程中,會(huì)有更大的概率引入bug,代碼的質(zhì)量也會(huì)持續(xù)惡化。
(2)模型失配:和現(xiàn)實(shí)世界不完全符合的模型帶來(lái)高認(rèn)知負(fù)荷
軟件的模型設(shè)計(jì)需要符合現(xiàn)實(shí)物理世界的認(rèn)知,否則會(huì)帶來(lái)非常高的認(rèn)知成本。我遇到過(guò)這樣一個(gè)資源管理系統(tǒng)的設(shè)計(jì),設(shè)計(jì)者從數(shù)學(xué)角度有一個(gè)非常優(yōu)雅的模型,將資源賬號(hào) 用合約來(lái)表達(dá)(下圖左側(cè)),賬戶的balance可以由過(guò)往合約的累計(jì)獲得,確保數(shù)據(jù)一致性。但是這樣的設(shè)計(jì),完全不符合用戶的認(rèn)知,對(duì)于用戶來(lái)說(shuō),感受到的應(yīng)該是賬號(hào)和交易的概念,而不是帶著復(fù)雜參數(shù)的合約。可以想象這樣的設(shè)計(jì),其維護(hù)成本非常之高。
(3)接口設(shè)計(jì)不當(dāng)
以下是一個(gè)典型的接口設(shè)計(jì)不當(dāng)帶來(lái)的理解成本。
- class BufferBadDesign {
- explicit Buffer(int size);// Create a buffer with given sized slots
- void AddSlots(int num);// Expand the slots by `num`
- // Add a value to the end of stack, and the caller need to
- // ensure that there is at least one empty slot in the stack before
- // calling insert
- void Insert(int value);
- int getNumberOfEmptySlots(); // return the number of empty slots
- }
希望我們的團(tuán)隊(duì)不會(huì)設(shè)計(jì)出這樣的模塊。這個(gè)問(wèn)題可以明顯看到一個(gè)接口設(shè)計(jì)的不合理帶來(lái)的維護(hù)成本提升:一個(gè)Buffer的設(shè)計(jì)暴露了內(nèi)部?jī)?nèi)存管理的細(xì)節(jié)(slot維護(hù)),從而導(dǎo)致在調(diào)用最常用接口 “insert”時(shí)存在陷阱:如果不在insert前檢查空余slot,這個(gè)接口就會(huì)有異常行為。
但是從設(shè)計(jì)角度看,維護(hù)底層的Slot的邏輯,也外部可見(jiàn)的buffer的行為其實(shí)并沒(méi)有關(guān)聯(lián),而只是一個(gè)底層的實(shí)現(xiàn)細(xì)節(jié)。因此更好的設(shè)計(jì)應(yīng)該可以簡(jiǎn)化接口。把Slot數(shù)量的維護(hù)改為內(nèi)部的實(shí)現(xiàn)邏輯細(xì)節(jié),不對(duì)外暴露。這樣也完全消除了因?yàn)槭褂貌划?dāng)帶來(lái)問(wèn)題的場(chǎng)景。同時(shí)也讓接口更易于理解,降低了認(rèn)知成本。
class Buffer { explicit Buffer(int size); // Create a buffer with given sized slots // Add a value to the end of buffer. New slots are added // if necessary. void Insert(int value);}
事實(shí)上,當(dāng)我們發(fā)現(xiàn)一個(gè)模塊在使用時(shí)具備如下特點(diǎn)時(shí),一般就是難以理解、容易出錯(cuò)的信號(hào):
- 一個(gè)模塊需要調(diào)用者使用初始化接口才能正常行為:對(duì)于調(diào)用者來(lái)說(shuō),需要調(diào)用初始化接口看似不是大的問(wèn)題,但是這樣的模塊,帶來(lái)了多種后患,尤其是當(dāng)存在多個(gè)參數(shù)需要設(shè)置,相互關(guān)聯(lián)關(guān)系復(fù)雜時(shí)。配置問(wèn)題應(yīng)該單獨(dú)解決(比如通過(guò)工廠模式,或者通過(guò)單獨(dú)的配置系統(tǒng)來(lái)管理)。
- 一個(gè)模塊需要調(diào)用者使用后做清理/ finalizer才能正常退出。
- 一個(gè)模塊有多種方式讓調(diào)用者實(shí)現(xiàn)完全相同的功能:軟件在維護(hù)過(guò)程中,出現(xiàn)這種狀況可能是因?yàn)槌跏荚O(shè)計(jì)不當(dāng)后來(lái)修改設(shè)計(jì) 帶來(lái)的冗余,也可能是設(shè)計(jì)原版的缺陷,無(wú)論如何這種模塊,帶著強(qiáng)烈的“壞味道”。
完全避免這些問(wèn)題很難,但是我們需要在設(shè)計(jì)中盡最大努力。有時(shí)通過(guò)文檔的解釋來(lái)彌補(bǔ)這些問(wèn)題是必要的,但是好的工程師/架構(gòu)師,應(yīng)該清醒的意識(shí)到,這些都是“壞味道”。
(4)一個(gè)簡(jiǎn)單的修改需要在多處更新
簡(jiǎn)單修改涉及多處更改也是常見(jiàn)的軟件維護(hù)復(fù)雜度因素,而且主要影響的是我們的認(rèn)知負(fù)荷:維護(hù)修改代碼時(shí)需要花費(fèi)大量的精力確保各處需要修改的地方都被照顧到了。
最簡(jiǎn)單的情形是代碼當(dāng)中有重復(fù)的“常數(shù)”,為了修改這個(gè)常數(shù),我們需要多處修改代碼。程序員也知道如何解決這一問(wèn)題,例如通過(guò)定義個(gè)constant 并處處引用避免magic number。再例如網(wǎng)頁(yè)的風(fēng)格/色彩,每個(gè)頁(yè)面相同配置都重復(fù)設(shè)置同樣的色彩和風(fēng)格是一種模式,而采用css模版則是更加易于維護(hù)的架構(gòu)。這在架構(gòu)原則中對(duì)應(yīng)了數(shù)據(jù)歸一化原則(Data normalization)。
稍微復(fù)雜一些的是類似的邏輯/或者功能被copy-paste多次,原因往往是不同的地方需要稍微不同的使用方式,而過(guò)去的維護(hù)者沒(méi)有及時(shí)refactor代碼提取公共邏輯(這樣做往往需要更多的時(shí)間精力),而是省時(shí)間情況下選擇了copy-paste。這就是常說(shuō)的 Don't repeat yourself原則:
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system[8]
(5)命名
軟件中的API、方法、變量的命名,對(duì)于理解代碼的邏輯、范圍非常重要,也是設(shè)計(jì)者清晰傳達(dá)意圖的關(guān)鍵。然而,在很多的項(xiàng)目里我們沒(méi)有給Naming /命名足夠的重視。
我們的代碼一般會(huì)和一些項(xiàng)目關(guān)聯(lián),但是需要注意的是項(xiàng)目是抽象的,而代碼是具體的。項(xiàng)目或者產(chǎn)品可以隨意一些命名,如阿里云喜歡用中國(guó)古代神話(飛天、伏羲、女?huà)z)命名系統(tǒng),K8s也是來(lái)自于希臘神話,這些都沒(méi)有問(wèn)題。而代碼中的API、變量、方法不能這樣命名。
一個(gè)不好的例子是前一段我們的Cluster API 被命名為T(mén)rident API(三叉戟),設(shè)想一下代碼中的對(duì)象叫Trident時(shí),我們?nèi)绾卫斫庠谶@個(gè)對(duì)象應(yīng)該具備的行為?再對(duì)比一下K8s中的資源:Pod, ReplicaSet, Service, ClusterIP,我們會(huì)注意到都是清晰、簡(jiǎn)單、直接符合其對(duì)象特征的命名。名實(shí)相符可以很大程度上降低理解該對(duì)象的成本。
有人說(shuō)“Naming is the most difficult part of software engineering[9][10]”,或許也不完全是個(gè)玩笑話:Naming的難度在于對(duì)于模型的深入思考和抽象,而這往往確實(shí)是很難的。
需要注意的是:
(a)Intention vs what it is
需要避免用“是什么”來(lái)命名,要用“for what / intention”。“是什么”來(lái)命名是會(huì)很容易將實(shí)現(xiàn)細(xì)節(jié)。比如我們用 LeakedBarrel做rate limiting,這個(gè)類最好叫 RateLimiter,而不是LeakedBarrel:前者定義了意圖(做什么的),后者 描述了具體實(shí)現(xiàn),而具體實(shí)現(xiàn)可能會(huì)變化。再比如 Cache vs FixedSizeHashMap,前者也是更好的命名。
(b)命名需要符合當(dāng)前抽象的層級(jí)
首先我們軟件需要始終有清晰的抽象和分層。事實(shí)上我們Naming時(shí)遇到困難,很多就是因?yàn)檐浖呀?jīng)缺乏明確的抽象和分層帶來(lái)的表象而已。
(6)不知道一個(gè)簡(jiǎn)單特性需要在哪些做修改,或者一個(gè)簡(jiǎn)單的改動(dòng)會(huì)帶來(lái)什么影響,即unknown unknowns
在所有認(rèn)知復(fù)雜度的表現(xiàn)中,這是最壞的一種,不幸的是,所有人都曾經(jīng)遇到過(guò)這樣的情況。
一個(gè)典型的unknown unknown是一部分代碼存在這樣的情況:
- 代碼缺乏充分的測(cè)試覆蓋,一些重要場(chǎng)景依賴維護(hù)者手工測(cè)試。
- 代碼有隱藏/不易被發(fā)現(xiàn)的行為或者邊界條件,與文檔和接口描述并不符合。
對(duì)于維護(hù)者來(lái)說(shuō),改動(dòng)這樣的代碼(或者是改動(dòng)影響到了這樣代碼 / 被這樣代碼影響到了)時(shí),如果按照接口描述或者文檔進(jìn)行,沒(méi)發(fā)現(xiàn)隱藏行為,同時(shí)代碼又缺乏足夠測(cè)試覆蓋,那么就存在未知的風(fēng)險(xiǎn)unknown unknowns。這時(shí)出現(xiàn)問(wèn)題是很難避免的。最好的方式還是要盡量避免我們的系統(tǒng)質(zhì)量劣化到這個(gè)程度。
上線時(shí),我們最大的噩夢(mèng)就是unknown unknowns:這類風(fēng)險(xiǎn),我們無(wú)法預(yù)知在哪里或者是否有問(wèn)題,只能在軟件上線后遇到問(wèn)題才有可能發(fā)現(xiàn)。其他的問(wèn)題 尚可通過(guò)努力來(lái)解決(認(rèn)知成本),而unknown unknowns可以說(shuō)已經(jīng)超出了認(rèn)知成本的范圍。我們最希望避免的也是unknown unknowns。
(7)認(rèn)知成本低要不易出錯(cuò),而不是無(wú)腦“簡(jiǎn)化”
從認(rèn)知成本角度來(lái)說(shuō),我們還要認(rèn)識(shí)到,衡量不同方案/寫(xiě)法的認(rèn)知成本,要考慮的是不易出錯(cuò),而不是表面上的簡(jiǎn)化:表面上簡(jiǎn)化可能帶來(lái)實(shí)質(zhì)性的復(fù)雜度上升。
例如,為了表達(dá)時(shí)間段,可以有兩種選擇:
- // Time period in seconds.
- void someFunction(int timePeriod);
- // time period using Duration.
- void someFunction(Duration timePeriod);
在上面這個(gè)例子里面,我們都知道,應(yīng)該選用第二個(gè)方案,即采用Duration作time period,而不是int:盡管Duration本身需要一點(diǎn)點(diǎn)學(xué)習(xí)成本,但是這個(gè)模式可以避免多個(gè)時(shí)間單位帶來(lái)的常見(jiàn)問(wèn)題。
3 影響協(xié)同成本的因素
協(xié)同成本則是增長(zhǎng)這塊模塊所需要付出的協(xié)同成本。什么樣的成本是協(xié)同成本?(1)增加一個(gè)新的特性往往需要多個(gè)工程師協(xié)同配合,甚至多個(gè)團(tuán)隊(duì)協(xié)同配合;(2) 測(cè)試以及上線需要協(xié)調(diào)同步。
(1)系統(tǒng)模塊拆分與團(tuán)隊(duì)邊界
在微服務(wù)化時(shí)代,模塊/服務(wù)的切分和團(tuán)隊(duì)對(duì)齊,更加有利于迭代效率。而模塊拆分和邊界的不對(duì)齊,則讓代碼維護(hù)的復(fù)雜度增加,因這時(shí)新的特性需要在跨多個(gè)團(tuán)隊(duì)的情況下進(jìn)行開(kāi)發(fā)、測(cè)試和迭代。
另外一個(gè)角度,則是:
Any piece of software reflects the organizational structure that produces it.
或者就是我們常說(shuō)的“組織架構(gòu)決定系統(tǒng)架構(gòu)”,軟件的架構(gòu)最后會(huì)圍繞組織的邊界而變化(當(dāng)然也有文化因素),當(dāng)組織分工不合理時(shí),會(huì)產(chǎn)生重復(fù)的建設(shè)或者沖突。
(2)服務(wù)之間的依賴,Composition vs Inheritance/Plugin
軟件之間的依賴模式,常見(jiàn)的有Composition 和Inheritance模式,對(duì)于local模塊/類之間的依賴還是遠(yuǎn)程調(diào)用,都存在類似模式。
上圖左側(cè)是Inheritance(繼承或者是擴(kuò)展模式),有四個(gè)團(tuán)隊(duì),其中一個(gè)是Framework團(tuán)隊(duì)負(fù)責(zé)框架實(shí)現(xiàn),框架具有三個(gè)擴(kuò)展點(diǎn),這三個(gè)擴(kuò)展點(diǎn)有三個(gè)不同的團(tuán)隊(duì)實(shí)現(xiàn)插件擴(kuò)展,這些插件被Framework調(diào)用,從架構(gòu)上,這是一種類似于繼承的模式。
右側(cè)是組合模式(composition):底層的系統(tǒng)以API服務(wù)的方式提供接口,而上層應(yīng)用或者服務(wù)通過(guò)調(diào)用這些接口來(lái)實(shí)現(xiàn)業(yè)務(wù)功能。
這兩種模式適用于不同的系統(tǒng)模型。當(dāng)Framework偏向于底層、不涉及業(yè)務(wù)邏輯且相對(duì)非常穩(wěn)定時(shí),可以采用inheritance模式,也即Framework被集成到團(tuán)隊(duì)1,2,3的業(yè)務(wù)實(shí)現(xiàn)當(dāng)中。例如RPC framework就是這樣的模型:RPC底層實(shí)現(xiàn)作為公共的base 代碼/SDK提供給業(yè)務(wù)使用,業(yè)務(wù)實(shí)現(xiàn)自己的RPC 方法,被framework調(diào)用,業(yè)務(wù)無(wú)需關(guān)注底層RPC實(shí)現(xiàn)的細(xì)節(jié)。因?yàn)镕ramework代碼被業(yè)務(wù)所依賴,因此這時(shí)業(yè)務(wù)希望Framework的代碼非常穩(wěn)定,而且盡量避免對(duì)framework層的感知,這時(shí)inheritance是一種比較合適的模型。
然而,我們要慎用Inheritance模式。Inheritance模式的常見(jiàn)陷阱:
(a)要避免出現(xiàn)管理倒置
即Framework層負(fù)責(zé)整個(gè)系統(tǒng)的運(yùn)維(framework團(tuán)隊(duì)負(fù)責(zé)代碼打包、構(gòu)建、上線),那么會(huì)出現(xiàn)額外的協(xié)同復(fù)雜度,影響系統(tǒng)演進(jìn)效率(設(shè)想一下如果Dubbo的團(tuán)隊(duì)要求負(fù)責(zé)所有的使用Dubbo的應(yīng)用的打包、發(fā)布成為一個(gè)大的應(yīng)用,會(huì)是多么的低效)。
(b)要避免破壞業(yè)務(wù)邏輯流程的封閉性
Inheritance模式如果使用不當(dāng),很容易破壞上層業(yè)務(wù)的邏輯抽象完整性,也即“擴(kuò)展實(shí)現(xiàn)1”這個(gè)模塊的邏輯,依賴于其調(diào)用者的內(nèi)部邏輯流程甚至是內(nèi)部實(shí)現(xiàn)細(xì)節(jié),這會(huì)帶來(lái)危險(xiǎn)的耦合,破壞業(yè)務(wù)的邏輯封閉性。
如果你所在的項(xiàng)目采用了插件/Inheritance模式,同時(shí)又出現(xiàn)上面所說(shuō)的管理倒置、破壞封閉性情況,就需要反思當(dāng)前的架構(gòu)的合理性。
而右側(cè)的Composition是更常用的模型:服務(wù)與服務(wù)之間通過(guò)API交互,相互解耦,業(yè)務(wù)邏輯的完整性不被破壞,同時(shí)框架/Infra的encapsulation也能保證。同時(shí)也更靈活,在這種模型下,Service 1, 2, 3 如果需要也可以產(chǎn)生相互調(diào)用。
另外《Effective Java》一書(shū)的Favor composition over inheritance有很好的分析,可以作為這個(gè)問(wèn)題的補(bǔ)充。
(3)可測(cè)試性不足帶來(lái)的協(xié)同成本
交付給其他團(tuán)隊(duì)(包括測(cè)試團(tuán)隊(duì))的代碼應(yīng)該包含充分的單元測(cè)試,具備良好的封裝和接口描述,易于被集成測(cè)試的。然而因?yàn)? 單測(cè)不足/模塊測(cè)試不足,帶來(lái)的集成階段的復(fù)雜度升高、失敗率和返工率的升高,都極大的增加了協(xié)同的成本。因此做好代碼的充分單元測(cè)試,并提供良好的集成測(cè)試支持,是降低協(xié)同成本提升迭代效率的關(guān)鍵。
可測(cè)試性不足,帶來(lái)協(xié)同成本升高,往往導(dǎo)致的破窗效應(yīng):上線越來(lái)越靠運(yùn)氣,unknown unknowns越來(lái)越多。
(4)文檔
降低協(xié)同成本需要對(duì)接口/API提供清晰的、不斷保持更新一致的文檔,針對(duì)接口的場(chǎng)景、使用方式等給出清晰描述。這些工作需要投入,開(kāi)發(fā)團(tuán)隊(duì)有時(shí)不愿意投入,但是對(duì)于每一個(gè)用戶/使用方,需要依賴釘釘上的詢問(wèn)、或者是依靠ATA文章(多半有PR性質(zhì)或者是已經(jīng)過(guò)時(shí),沒(méi)有及時(shí)更新,畢竟ATA不是產(chǎn)品文檔),協(xié)同成本太高,對(duì)于系統(tǒng)來(lái)說(shuō)出現(xiàn)bug/使用不當(dāng)?shù)膸茁蚀鬄樵黾恿恕?/p>
最好的方式:(1)代碼都公開(kāi);(2)文檔和代碼寫(xiě)在一起(README.md, *.md),隨著代碼一起提交和更新,還計(jì)算代碼行數(shù),多好。
4 軟件復(fù)雜度生命周期
復(fù)雜度的惡化到一定程度,一定進(jìn)入有諸多unknown unknown的程度。好的工程師一定要能識(shí)別這樣的狀態(tài):可以說(shuō),如果不投入力氣去做一定的重構(gòu)/改造,有過(guò)多unknown unknowns的系統(tǒng),很難避免失敗的厄運(yùn)了。
這張圖是要表明,軟件演進(jìn)的過(guò)程,是一個(gè)“不由自主”就會(huì)滑向過(guò)于復(fù)雜而無(wú)法維護(hù)的深淵的過(guò)程。如何要避免失敗的厄運(yùn)?這篇文章的篇幅不容許我們展開(kāi)討論如何避免復(fù)雜度,但是首要的,對(duì)于真正重要的、長(zhǎng)生命周期的軟件演進(jìn),我們需要做到對(duì)于復(fù)雜度增量零容忍。
5 Good enough vs Perfect
軟件領(lǐng)域,從效率和質(zhì)量的折中,我們會(huì)提“Good enough”即可。這個(gè)理論是沒(méi)錯(cuò)的。只不過(guò)現(xiàn)實(shí)中,我們極少看到“overly good”,因?yàn)檫^(guò)于追求perfection而影響效率的情況。大多數(shù)情況下,我們的系統(tǒng)是根本沒(méi)做到Good enough。
四 對(duì)復(fù)雜度增長(zhǎng)的對(duì)策
每一份新的代碼的引入,都在增加系統(tǒng)的復(fù)雜度:因?yàn)槊恳粋€(gè)類或者方法的創(chuàng)建,都會(huì)有其他代碼來(lái)引用或者調(diào)用這部分代碼,因而產(chǎn)生依賴/耦合,增加系統(tǒng)的復(fù)雜度(除非之前的代碼過(guò)度復(fù)雜unncessarily complex,而通過(guò)重構(gòu)可以降低復(fù)雜度),如果讀者都意識(shí)到了這個(gè)問(wèn)題,并且那些識(shí)別增加復(fù)雜度的關(guān)鍵因素對(duì)于大家有所幫助,那么本文也就達(dá)到了目標(biāo)。
而如何Keep it simple,是個(gè)非常大的話題,本文不會(huì)展開(kāi)。對(duì)于API設(shè)計(jì),在[5]中做了一些總結(jié),其他的希望后續(xù)有時(shí)間能繼續(xù)總結(jié)。
有人會(huì)說(shuō),項(xiàng)目交付的壓力才是最重要的,不要站著說(shuō)話不腰疼。實(shí)際呢?我認(rèn)為絕對(duì)不是這樣。多數(shù)情況下,我們要對(duì)復(fù)雜度增長(zhǎng)采用接近于“零容忍”的態(tài)度,避免“能用就行”,原因在于:
- 復(fù)雜度增長(zhǎng)帶來(lái)的風(fēng)險(xiǎn)(unknown unknowns、不可控的失敗等)往往是后知后覺(jué)的,等到問(wèn)題出現(xiàn)時(shí),往往legacy已經(jīng)形成一段時(shí)間,或者坑往往是很久以前埋的。
- 當(dāng)我們?cè)诖a評(píng)審、設(shè)計(jì)評(píng)審時(shí)面臨一個(gè)個(gè)選擇時(shí),每一個(gè)Hack、每一個(gè)帶來(lái)額外成本和復(fù)雜度的設(shè)計(jì)似乎都顯得沒(méi)那么有危害:就是增加了一點(diǎn)點(diǎn)復(fù)雜度而已,就是一點(diǎn)點(diǎn)風(fēng)險(xiǎn)而已。但是每一個(gè)失敗的系統(tǒng)的問(wèn)題都是這樣一點(diǎn)點(diǎn)積累起來(lái)的。
- 破窗效應(yīng)Broken window:一個(gè)建筑,當(dāng)有了一個(gè)破窗而不及時(shí)修補(bǔ),這個(gè)建筑就會(huì)被侵入住認(rèn)為是無(wú)人居住的、風(fēng)雨更容易進(jìn)來(lái),更多的窗戶被人有意打破,很快整個(gè)建筑會(huì)加速破敗。這就是破窗效應(yīng),在軟件的質(zhì)量控制上這個(gè)效應(yīng)非常恰當(dāng)。所以,Don't live with broken windows (bad designs, wrong decisions, poor code) [6]:有破窗盡快修。
零容忍,并不是不讓復(fù)雜度增長(zhǎng):我們都知道這是不可能的。我們需要的是盡力控制。因?yàn)檫M(jìn)度而臨時(shí)打破窗戶也能接受,但是要盡快補(bǔ)上。
當(dāng)然文章一開(kāi)始就強(qiáng)調(diào)了,如果所寫(xiě)的業(yè)務(wù)代碼生命周期只有幾個(gè)月,那么多半在代碼變得不可維護(hù)之前就可以下線了,那可以不用關(guān)注太多,能用就行。
最后,作為Software engineer,軟件是我們的作品,希望大家都相信:
- 真正的工程師一定在意自己的作品:我們的作品就是我們的代碼。工匠精神是對(duì)每個(gè)工程師的要求。
- 我們都可以帶來(lái)改變:代碼是最公平的工作場(chǎng)地,代碼就在那里,只要我們?cè)敢猓湍軒?lái)變化。
Reference
[1]John Ousterhout, A Philosophy of software design
[2]Frederick Brooks, No Silver Bullet - essence and accident in software engineering
[3]Robert Martin, Clean Architecture
[4]https://medium.com/monsterculture/getting-your-software-architecture-right-89287a980f1b
[5]API設(shè)計(jì)最佳實(shí)踐思考 https://developer.aliyun.com/article/701810
[6]Andrew Hunt and David Thomas, The pragmatic programmer: from Journeyman to master
[7]https://testing.googleblog.com/2017/06/code-health-reduce-nesting-reduce.html
[8]https://en.wikipedia.org/wiki/Don%27t_repeat_yourself
[9]http://www.multunus.com/blog/2017/01/naming-the-hardest-software/
[10]https://martinfowler.com/bliki/TwoHardThings.html
【本文為51CTO專欄作者“阿里巴巴官方技術(shù)”原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)聯(lián)系原作者】