阿里妹導讀:對於大型的軟體系統如網際網路分散式應用或企業級軟體,為何我們常常會陷入複雜度陷阱?如何識別複雜度增長的因素?在程式碼開發以及演進的過程中需要遵循哪些原則?本文將分享阿里研究員谷樸關於軟體複雜度的思考:什麼是複雜度、複雜度是如何產生的以及解決的思路。較長,同學們可收藏後再看。
軟體設計和實現的本質是工程師相互透過“寫作”來交流一些包含豐富細節的抽象概念並且不斷迭代過程。另外,如果你的程式碼生存期一般不超過6個月,本文用處不大。
大型系統的本質問題是複雜性問題。網際網路軟體,是典型的大型系統,如下圖所示,數百個甚至更多的微服務相互呼叫/依賴,組成一個元件數量大、行為複雜、時刻在變動(釋出、配置變更)當中的動態的、複雜的系統。而且,軟體工程師們常常自嘲,“when things work, nobody knows why”。圖源:https://divante.com/blog/10-companies-that-implemented-the-microservice-architecture-and-paved-the-way-for-others/如果我們只是寫一段獨立程式碼,不和其他系統互動,往往設計上要求不會很高,程式碼是否易於使用、易於理解、易於測試和維護,根本不是問題。而一旦遇到大型的軟體系統如網際網路分散式應用或者企業級軟體,我們常常陷入複雜度陷阱,下圖the life of a software engineer是我很喜歡的一個軟體cartoon,非常形象的展示了複雜度陷阱。圖源:http://themetapicture.com/the-life-of-a-software-engineer/ 做為一個有追求的軟體工程師,大家肯定都思考過,我手上的專案,如何避免這種似乎難以避免的複雜度困境?然而對於這個問題給出答案,卻出乎意料的困難:很多的文章都給出了軟體架構的設計建議,然後正如軟體領域的經典論著《No silver bullet》所說,這個問題沒有神奇的解決方案。並不是說那麼多的架構文章都沒用(其實這麼方法多半都有用),只不過,人們很難真正去follow這些建議並貫徹下去。為什麼?我們還是需要徹底理解這些架構背後的思考和邏輯。所以我覺得有必要從頭開始整理這個邏輯:什麼是複雜度,複雜度是如何產生的,以及解決的思路。要理解軟體複雜度會快速增長的本質原因,需要理解軟體是怎麼來的。我們首先要回答一個問題,一個大型的軟體是建造出來的,還是生長出來的?BUILT vs GROWN,that is the problem.軟體不是建造出來的,甚至不是設計出來的。軟體是長出來的。這個說法初看上去和我們平時的認識似乎不同,我們常常談軟體架構,架構這個詞似乎蘊含了一種建造和設計的意味。然而,對於軟體系統來說,我們必須認識到,架構師設計的不是軟體的架構,而是軟體的基因,而這些基因如何影響軟體未來的形態則是難以預測,無法完全控制。為什麼這麼說?所謂建造和“生長”差異在哪裡?其實,我們看今天一個複雜的軟體系統,確實很像一個複雜的建築物。但是把軟體比作一棟摩天大樓卻不是一個好的比喻。原因在於,一個摩天大樓無論多麼複雜,都是事先可以根據設計出完整詳盡的圖紙,按圖準確施工,保證質量就能建造出來的。然而現實中的大型軟體系統,卻不是這麼建造出來的。例如淘寶由一個單體PHP應用,經過4、5代架構不斷演進,才到今天服務十億人規模的電商交易平臺。支付寶,Google搜尋,Netflix微服務,都是類似的歷程。是不是一定要經過幾代演進才能構建出來大型軟體,就不能一次到位嗎?如果一個團隊離開淘寶,要拉開架勢根據淘寶交易的架構重新複製一套,在現實中是不可能實現的:沒有哪個創業團隊能有那麼多資源同時投入這麼多元件的開發,也不可能有一開始就朝著超級複雜架構開發而能夠成功的實現。也就是說,軟體的動態“生長”,更像是上圖所畫的那樣,是從一個簡單的“結構”生長到複雜的“結構”的過程。伴隨著專案本身的發展、研發團隊的壯大,系統是個逐漸生長的過程。2 大型軟體的核心挑戰是軟體“生長”過程中的理解和維護成本複雜軟體系統最核心的特徵是有成百上千的工程師開發和維護的系統(軟體的本質是工程師之間用程式語言來溝通抽象和複雜的概念,注意軟體的本質不是人和機器溝通)。如果認同這個定義,設想一下複雜軟體是如何產生的:無論最終多麼複雜的軟體,都要從第一行開始開發。都要從幾個核心開始開發,這時架構只能是一個簡單的、少量程式設計師可以維護的系統組成架構。隨著專案的成功,再去逐漸細化功能,增加可擴充套件性,分散式微服務化,增加功能,業務需求也在這個過程中不斷產生,系統滿足這些業務需求,帶來業務的增長。業務增長對於軟體系統迭代帶來了更多的需求,架構隨著適應而演進,投入開發的人員隨著業務的成功增加,這樣不斷迭代,才會演進出幾十,幾百,甚至幾千人同時維護的複雜系統來。大型軟體設計核心要素是控制複雜度。這一點非常有挑戰,根本原因在於軟體不是機械活動的組合,不能在事先透過精心的“架構設計”規避複雜度失控的風險:相同的架構圖/藍圖,可以長出完完全全不同的軟體來。大型軟體設計和實現的本質是大量的工程師相互透過“寫作”來交流一些包含豐富細節的抽象概念並且相互不斷迭代的過程[2]。稍有差錯,系統複雜度就會失控。所以說了這麼多是要停留在形而上嗎?並不是。我們的結論是,軟體架構師最重要的工作不是設計軟體的結構,而是透過API,團隊設計準則和對細節的關注,控制軟體複雜度的增長。我們分析理解了軟體複雜度快速增長的原因,下面我們自然希望能解決複雜度快速增長這一看似永恆的難題。但是在此之前,我們還是需要先分析清楚一件事情,複雜度本身是什麼?又如何衡量?程式碼複雜度是用行數來衡量麼?是用類的個數/檔案的個數麼?深入思考就會意識到,這些表面上的指標並非軟體複雜度的核心度量。正如前面所分析的,軟體複雜度從根本上說可以說是一個主觀指標(先別跳,耐心讀下去),說其主觀是因為軟體複雜度只有在程式設計師需要更新、維護、排查問題的時候才有意義。一個不需要演進和維護的系統其架構、程式碼如何關係也就不大了(雖然現實中這種情況很少)。
既然 “軟體設計和實現的本質是工程師相互透過寫作來交流一些包含豐富細節的抽象概念並且不斷迭代過程” (第三次強調了),那麼,複雜度指的是軟體中那些讓人理解和修改維護的困難程度。相應的,簡單性,就是讓理解和維護程式碼更容易的要素。“The goal of software architecture is to minimize the manpower required to build and maintain the required system.” Robert Martin, Clean Architecture [3].
因此我們將軟體的複雜度分解為兩個維度,都和人理解與維護軟體的成本相關:我們看到,這兩個維度有所區別,但是又相互關聯。協同成本高,讓軟體系統演進速度變慢,效率變差,工作其中的工程師壓力增大,而長期難以取得進展,工程師傾向於離開專案,最終造成質量進一步下滑的惡性迴圈。而認知負荷高的軟體模組讓程式設計師難以理解,從而產生兩個後果:(1) 維護過程中易於出錯,bug 率故障率高;(2) 更大機率 團隊人員變化時被拋棄,新成員選擇另起爐灶,原有投入被浪費,甚至更高糟糕的是,程式碼被拋棄但是又無法下線,成為定時炸彈。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的程式碼基礎上增加功能,且新增的功能很可能也會維持這樣一個比較好的狀態。而我們看到A的程式碼,很難理解其邏輯,在維護的過程中,會有更大的機率引入bug,程式碼的質量也會持續惡化。(2)模型失配:和現實世界不完全符合的模型帶來高認知負荷軟體的模型設計需要符合現實物理世界的認知,否則會帶來非常高的認知成本。我遇到過這樣一個資源管理系統的設計,設計者從數學角度有一個非常優雅的模型,將資源賬號 用合約來表達(下圖左側),賬戶的balance可以由過往合約的累計獲得,確保資料一致性。但是這樣的設計,完全不符合使用者的認知,對於使用者來說,感受到的應該是賬號和交易的概念,而不是帶著複雜引數的合約。可以想象這樣的設計,其維護成本非常之高。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
}
希望我們的團隊不會設計出這樣的模組。這個問題可以明顯看到一個介面設計的不合理帶來的維護成本提升:一個Buffer的設計暴露了內部記憶體管理的細節(slot維護),從而導致在呼叫最常用介面 “insert”時存在陷阱:如果不在insert前檢查空餘slot,這個介面就會有異常行為。但是從設計角度看,維護底層的Slot的邏輯,也外部可見的buffer的行為其實並沒有關聯,而只是一個底層的實現細節。因此更好的設計應該可以簡化介面。把Slot數量的維護改為內部的實現邏輯細節,不對外暴露。這樣也完全消除了因為使用不當帶來問題的場景。同時也讓介面更易於理解,降低了認知成本。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);
}
事實上,當我們發現一個模組在使用時具備如下特點時,一般就是難以理解、容易出錯的訊號:一個模組需要呼叫者使用初始化介面才能正常行為:對於呼叫者來說,需要呼叫初始化介面看似不是大的問題,但是這樣的模組,帶來了多種後患,尤其是當存在多個引數需要設定,相互關聯關係複雜時。配置問題應該單獨解決(比如透過工廠模式,或者透過單獨的配置系統來管理)。
一個模組需要呼叫者使用後做清理/ finalizer才能正常退出。
一個模組有多種方式讓呼叫者實現完全相同的功能:軟體在維護過程中,出現這種狀況可能是因為初始設計不當後來修改設計 帶來的冗餘,也可能是設計原版的缺陷,無論如何這種模組,帶著強烈的“壞味道”。
完全避免這些問題很難,但是我們需要在設計中盡最大努力。有時透過文件的解釋來彌補這些問題是必要的,但是好的工程師/架構師,應該清醒的意識到,這些都是“壞味道”。簡單修改涉及多處更改也是常見的軟體維護複雜度因素,而且主要影響的是我們的認知負荷:維護修改程式碼時需要花費大量的精力確保各處需要修改的地方都被照顧到了。最簡單的情形是程式碼當中有重複的“常數”,為了修改這個常數,我們需要多處修改程式碼。程式設計師也知道如何解決這一問題,例如透過定義個constant 並處處引用避免magic number。再例如網頁的風格/色彩,每個頁面相同配置都重複設定同樣的色彩和風格是一種模式,而採用css模版則是更加易於維護的架構。這在架構原則中對應了資料歸一化原則(Data normalization)。稍微複雜一些的是類似的邏輯/或者功能被copy-paste多次,原因往往是不同的地方需要稍微不同的使用方式,而過去的維護者沒有及時refactor程式碼提取公共邏輯(這樣做往往需要更多的時間精力),而是省時間情況下選擇了copy-paste。這就是常說的 Don't repeat yourself原則:Every piece of knowledge must have a single, unambiguous, authoritative representation within a system[8]
軟體中的API、方法、變數的命名,對於理解程式碼的邏輯、範圍非常重要,也是設計者清晰傳達意圖的關鍵。然而,在很多的專案裡我們沒有給Naming /命名足夠的重視。我們的程式碼一般會和一些專案關聯,但是需要注意的是專案是抽象的,而程式碼是具體的。專案或者產品可以隨意一些命名,如阿里雲喜歡用中國古代神話(飛天、伏羲、女媧)命名系統,K8s也是來自於希臘神話,這些都沒有問題。而程式碼中的API、變數、方法不能這樣命名。一個不好的例子是前一段我們的Cluster API 被命名為Trident API(三叉戟),設想一下程式碼中的物件叫Trident時,我們如何理解在這個物件應該具備的行為?再對比一下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”。“是什麼”來命名是會很容易將實現細節。比如我們用 LeakedBarrel做rate limiting,這個類最好叫 RateLimiter,而不是LeakedBarrel:前者定義了意圖(做什麼的),後者 描述了具體實現,而具體實現可能會變化。再比如 Cache vs FixedSizeHashMap,前者也是更好的命名。首先我們軟體需要始終有清晰的抽象和分層。事實上我們Naming時遇到困難,很多就是因為軟體已經缺乏明確的抽象和分層帶來的表象而已。(6)不知道一個簡單特性需要在哪些做修改,或者一個簡單的改動會帶來什麼影響,即unknown unknowns 在所有認知複雜度的表現中,這是最壞的一種,不幸的是,所有人都曾經遇到過這樣的情況。一個典型的unknown unknown是一部分程式碼存在這樣的情況:對於維護者來說,改動這樣的程式碼(或者是改動影響到了這樣程式碼 / 被這樣程式碼影響到了)時,如果按照介面描述或者文件進行,沒發現隱藏行為,同時程式碼又缺乏足夠測試覆蓋,那麼就存在未知的風險unknown unknowns。這時出現問題是很難避免的。最好的方式還是要儘量避免我們的系統質量劣化到這個程度。上線時,我們最大的噩夢就是unknown unknowns:這類風險,我們無法預知在哪裡或者是否有問題,只能在軟體上線後遇到問題才有可能發現。其他的問題 尚可透過努力來解決(認知成本),而unknown unknowns可以說已經超出了認知成本的範圍。我們最希望避免的也是unknown unknowns。從認知成本角度來說,我們還要認識到,衡量不同方案/寫法的認知成本,要考慮的是不易出錯,而不是表面上的簡化:表面上簡化可能帶來實質性的複雜度上升。// Time period in seconds.
void someFunction(int timePeriod);
// time period using Duration.
void someFunction(Duration timePeriod);
在上面這個例子裡面,我們都知道,應該選用第二個方案,即採用Duration作time period,而不是int:儘管Duration本身需要一點點學習成本,但是這個模式可以避免多個時間單位帶來的常見問題。協同成本則是增長這塊模組所需要付出的協同成本。什麼樣的成本是協同成本?(1)增加一個新的特性往往需要多個工程師協同配合,甚至多個團隊協同配合;(2) 測試以及上線需要協調同步。在微服務化時代,模組/服務的切分和團隊對齊,更加有利於迭代效率。而模組拆分和邊界的不對齊,則讓程式碼維護的複雜度增加,因這時新的特性需要在跨多個團隊的情況下進行開發、測試和迭代。Any piece of software reflects the organizational structure that produces it.
或者就是我們常說的“組織架構決定系統架構”,軟體的架構最後會圍繞組織的邊界而變化(當然也有文化因素),當組織分工不合理時,會產生重複的建設或者衝突。(2)服務之間的依賴,Composition vs Inheritance/Plugin軟體之間的依賴模式,常見的有Composition 和Inheritance模式,對於local模組/類之間的依賴還是遠端呼叫,都存在類似模式。上圖左側是Inheritance(繼承或者是擴充套件模式),有四個團隊,其中一個是Framework團隊負責框架實現,框架具有三個擴充套件點,這三個擴充套件點有三個不同的團隊實現外掛擴充套件,這些外掛被Framework呼叫,從架構上,這是一種類似於繼承的模式。右側是組合模式(composition):底層的系統以API服務的方式提供介面,而上層應用或者服務透過呼叫這些介面來實現業務功能。這兩種模式適用於不同的系統模型。當Framework偏向於底層、不涉及業務邏輯且相對非常穩定時,可以採用inheritance模式,也即Framework被整合到團隊1,2,3的業務實現當中。例如RPC framework就是這樣的模型:RPC底層實現作為公共的base 程式碼/SDK提供給業務使用,業務實現自己的RPC 方法,被framework呼叫,業務無需關注底層RPC實現的細節。因為Framework程式碼被業務所依賴,因此這時業務希望Framework的程式碼非常穩定,而且儘量避免對framework層的感知,這時inheritance是一種比較合適的模型。然而,我們要慎用Inheritance模式。Inheritance模式的常見陷阱:即Framework層負責整個系統的運維(framework團隊負責程式碼打包、構建、上線),那麼會出現額外的協同複雜度,影響系統演進效率(設想一下如果Dubbo的團隊要求負責所有的使用Dubbo的應用的打包、釋出成為一個大的應用,會是多麼的低效)。Inheritance模式如果使用不當,很容易破壞上層業務的邏輯抽象完整性,也即“擴充套件實現1”這個模組的邏輯,依賴於其呼叫者的內部邏輯流程甚至是內部實現細節,這會帶來危險的耦合,破壞業務的邏輯封閉性。如果你所在的專案採用了外掛/Inheritance模式,同時又出現上面所說的管理倒置、破壞封閉性情況,就需要反思當前的架構的合理性。而右側的Composition是更常用的模型:服務與服務之間透過API互動,相互解耦,業務邏輯的完整性不被破壞,同時框架/Infra的encapsulation也能保證。同時也更靈活,在這種模型下,Service 1, 2, 3 如果需要也可以產生相互呼叫。另外《Effective Java》一書的Favor composition over inheritance有很好的分析,可以作為這個問題的補充。交付給其他團隊(包括測試團隊)的程式碼應該包含充分的單元測試,具備良好的封裝和介面描述,易於被整合測試的。然而因為 單測不足/模組測試不足,帶來的整合階段的複雜度升高、失敗率和返工率的升高,都極大的增加了協同的成本。因此做好程式碼的充分單元測試,並提供良好的整合測試支援,是降低協同成本提升迭代效率的關鍵。可測試性不足,帶來協同成本升高,往往導致的破窗效應:上線越來越靠運氣,unknown unknowns越來越多。降低協同成本需要對介面/API提供清晰的、不斷保持更新一致的文件,針對介面的場景、使用方式等給出清晰描述。這些工作需要投入,開發團隊有時不願意投入,但是對於每一個使用者/使用方,需要依賴釘釘上的詢問、或者是依靠ATA文章(多半有PR性質或者是已經過時,沒有及時更新,畢竟ATA不是產品文件),協同成本太高,對於系統來說出現bug/使用不當的機率大為增加了。最好的方式:(1)程式碼都公開;(2)文件和程式碼寫在一起(README.md, *.md),隨著程式碼一起提交和更新,還計算程式碼行數,多好。複雜度的惡化到一定程度,一定進入有諸多unknown unknown的程度。好的工程師一定要能識別這樣的狀態:可以說,如果不投入力氣去做一定的重構/改造,有過多unknown unknowns的系統,很難避免失敗的厄運了。這張圖是要表明,軟體演進的過程,是一個“不由自主”就會滑向過於複雜而無法維護的深淵的過程。如何要避免失敗的厄運?這篇文章的篇幅不容許我們展開討論如何避免複雜度,但是首要的,對於真正重要的、長生命週期的軟體演進,我們需要做到對於複雜度增量零容忍。軟體領域,從效率和質量的折中,我們會提“Good enough”即可。這個理論是沒錯的。只不過現實中,我們極少看到“overly good”,因為過於追求perfection而影響效率的情況。大多數情況下,我們的系統是根本沒做到Good enough。每一份新的程式碼的引入,都在增加系統的複雜度:因為每一個類或者方法的建立,都會有其他程式碼來引用或者呼叫這部分程式碼,因而產生依賴/耦合,增加系統的複雜度(除非之前的程式碼過度複雜unncessarily complex,而透過重構可以降低複雜度),如果讀者都意識到了這個問題,並且那些識別增加複雜度的關鍵因素對於大家有所幫助,那麼本文也就達到了目標。而如何Keep it simple,是個非常大的話題,本文不會展開。對於API設計,在[5]中做了一些總結,其他的希望後續有時間能繼續總結。有人會說,專案交付的壓力才是最重要的,不要站著說話不腰疼。實際呢?我認為絕對不是這樣。多數情況下,我們要對複雜度增長採用接近於“零容忍”的態度,避免“能用就行”,原因在於:複雜度增長帶來的風險(unknown unknowns、不可控的失敗等)往往是後知後覺的,等到問題出現時,往往legacy已經形成一段時間,或者坑往往是很久以前埋的。
當我們在程式碼評審、設計評審時面臨一個個選擇時,每一個Hack、每一個帶來額外成本和複雜度的設計似乎都顯得沒那麼有危害:就是增加了一點點複雜度而已,就是一點點風險而已。但是每一個失敗的系統的問題都是這樣一點點積累起來的。
破窗效應Broken window:一個建築,當有了一個破窗而不及時修補,這個建築就會被侵入住認為是無人居住的、風雨更容易進來,更多的窗戶被人有意打破,很快整個建築會加速破敗。這就是破窗效應,在軟體的質量控制上這個效應非常恰當。所以,Don't live with broken windows (bad designs, wrong decisions, poor code) [6]:有破窗儘快修。
零容忍,並不是不讓複雜度增長:我們都知道這是不可能的。我們需要的是盡力控制。因為進度而臨時打破窗戶也能接受,但是要儘快補上。當然文章一開始就強調了,如果所寫的業務程式碼生命週期只有幾個月,那麼多半在程式碼變得不可維護之前就可以下線了,那可以不用關注太多,能用就行。最後,作為Software engineer,軟體是我們的作品,希望大家都相信:[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設計最佳實踐思考 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