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