系統困境與軟件復雜度,為什么我們的系統會如此復雜
一、前言
有一天,一個醫生和一個土木工程師在一起爭論“誰是世界上最古老的職業”。醫生說:“上帝用亞當的肋骨造出了夏娃,這是歷史上第一次外科手術,所以最古老的職業應該是醫生”,土木工程師說:“在創世紀之前,上帝從混沌中創造了天堂與人間,這是更早之前的一次土木作業,所以最古老的職業應該是土木工程”。這時軟件工程師拖著鍵盤走出來說,“那你認為,是誰創造了那片混沌?”
建筑師不會輕易給100層的高樓增加一個地下室,但我們卻經常在干這樣的事,并且總有人會對你說,“這個需求很簡單”。到土里埋個地雷,這確實不復雜,但我們往往面臨的真實場景其實是:“在這片雷區里加一個雷”,而雷區里哪里有雷,任何人都不知道 。
二、什么是復雜性
我們一直在說系統很復雜,那到底什么是復雜性?關于復雜的定義有很多種,其中比較有代表的是Thomas J. McCabe 在1976提出的理性派的復雜性度量,與John Ousterhout 教授提出的感性派的復雜性認知。
1.理性度量
復雜性并不是什么新概念,早在上世紀70年代,軟件就已經極其復雜,開發與維護的成本都非常高。1976年McCabe&Associates公司開始對軟件進行結構測試,并提出了McCabe Cyclomatic Complexity Metric,我們也稱之為McCabe圈復雜度。它通過多個維度來度量軟件的復雜度,從而判斷軟件當前的開發/維護成本。
圈復雜度 | 代碼狀況 | 測性成本 | 維護成本 |
圈復雜度1 - 10 | 清晰/結構化 | 可測性高 | 維護成本低 |
圈復雜度10 - 20 | 復雜 | 可測性中 | 維護成本中 |
圈復雜度20 - 30 | 非常復雜 | 可測性低 | 維護成本高 |
圈復雜度30 | 不可讀 | 不可測 | 維護成本非常高 |
2.感性認知
復雜度高的代碼一定不是好代碼,但復雜度低的也不一定就是好代碼。John Ousterhout教授認為軟件的復雜性相對理性的分析,可能更偏感性的認知。
Complexity is anything that makes software hard to understand or to modify
-- John Ousterhout 《A Philosophy of Software Design》
譯:所謂復雜性,就是任何使得軟件難于理解和修改的因素。
50年后的今天,John Ousterhout教授在 A Philosophy of Software Design 書中提到了一個非常主觀的見解,復雜性就是任何使得軟件難于理解和修改的因素。
模糊性與依賴性是引起復雜性的2個主要因素,模糊性產生了最直接的復雜度,讓我們很難讀懂代碼真正想表達的含義,無法讀懂這些代碼,也就意味著我們更難去改變它。而依賴性又導致了復雜性不斷傳遞,不斷外溢的復雜性最終導致系統的無限腐化,一旦代碼變成意大利面條,幾乎不可能修復,成本將成指數倍增長。
三、復雜性的表現形式
復雜的系統往往也有一些非常明顯的特征,John教授將它抽象為變更放大(Change amplification)、認知負荷(Cognitive load)與未知的未知(Unknown unknowns)這3類。當我們的系統出現這3個特征,說明我們的系統已經開始逐漸變得復雜了。
癥狀1-變更放大
Change amplification: a seemingly simple change requires code modifications in many different places.
-- John Ousterhout 《A Philosophy of Software Design》
譯:看似簡單的變更需要在許多不同地方進行代碼修改。
變更放大(Change amplification)指得是看似簡單的變更需要在許多不同地方進行代碼修改。比較典型的代表是Ctrl-CV式代碼開發,領域模型缺少內聚與收攏,當需要對某段業務進行調整時,需要改動多個模塊以適應業務的發展。
/**
* 銷售撿入客戶
*/
public void pick(String salesId, String customerId) {
// 查詢客戶總數
long customerCnt = customerDao.findCustomerCount(salesId);
// 查詢銷售庫容
long capacity = capacityDao.findSalesCapacity(salesId);
// 判斷是否超額
if(customerCnt >= capacity) {
throws new BizException("capacity over limit");
}
// 代碼省略 do customer pick
}
在CRM領域,銷售撿入客戶時需要進行庫容判斷,這段代碼也確實可以滿足需求。但隨著業務的發展,簽約的客戶要調整為不占庫容。而客戶除了銷售撿入,還包括主管分發、leads分發、手工錄入、數據采買等多個場景,如果沒對庫容域做模型的收攏,一個簡單的邏輯調整,就需要我們在多個場景做適配才能滿足訴求。
癥狀2-認知負荷
Cognitive load: how much a developer needs to know in order to complete a task.
-- John Ousterhout 《A Philosophy of Software Design》
譯:開發人員需要多少知識才能完成一項任務。
認知負荷(Cognitive load)是指開發人員需要多少知識才能完成一項任務。使用功能性框架時,我們希望它操作簡單,部署復雜系統時,我們希望它架構清晰,其實都是降低一項任務所需的成本。盲目的追求高端技術,設計復雜系統,增加學習與理解成本都屬于本末倒置的一種。
TMF是整個星環的支柱,也是業務中臺面向可復用可擴展架構的核心。但TMF太過復雜,認知與學習成本非常高,我們日常中所面臨的一些擴展訴求99%(或者應該說100%)都不適合TMF,可能通過一些設計模式或者就是一些if else,可能更適合解決我們的問題。
除此之外,還包括一些簡單搜索場景卻用到了blink等流式引擎,簡單后臺系統通過DDD進行構建,幾個商品發布的狀態機轉換用上了規則引擎等等,都屬于認知負荷復雜度的一種。
癥狀3-未知的未知
Unknown unknowns: it is not obvious which pieces of code must be modified to complete a task
-- John Ousterhout 《A Philosophy of Software Design》
譯:必須修改哪些代碼才能完成任務。
未知的未知(Unknown unknowns)是指必須修改哪些代碼才能完成任務,或者說開發人員必須獲得哪些信息才能成功地執行任務。這一項也是John Ousterhout教授認為復雜性中最糟糕的一個表現形式。
當你維護一個有20年歷史的項目時,這種問題的出來相對而言就沒那么意外。由于代碼的混亂與文檔的缺失,導致你無法掌控一個500萬行代碼的應用,并且代碼本身也沒有明顯表現出它們應該要闡述的內容。這時“未知的未知”出現了,你不知道改動的這行代碼是否能讓程序正常運轉,也不知道這行代碼的改動是否又會引發新的問題。這時候我們發現,那些“上帝類”真的就只有上帝能拯救了。
四、為什么會產生復雜性
那軟件為什么越來越復雜,是不是減少一些犯錯就能避免一場浩劫呢?回顧那些復雜的系統,我們可以找到很多因素導致系統腐化。
- 想簡單圖省事,沒有及時治理不合理的內容
- 缺少匠心追求,對骯臟代碼視而不見
- 技術能力不夠,無法應對復雜系統
- 交接過渡缺失,三無產品幾乎無法維護
除了上述內容外,還可以想到很多理由。但我們發現他們好像有一個共同的指向點 - 軟件工程師,似乎所有復雜的源頭就是軟件工程師的不合格導致,所以其實一些罪惡的根因是我們自己?
1.統一的中國與分裂的歐洲
歐洲大陸面積大體與中國相當,但為什么歐洲是分裂的,而中國是統一的。有人說他們文化不一樣,也有人說他們語言不通是主要原因,也有人說他們缺一個秦始皇。其實我們回顧歐洲的歷史,歐洲還真不缺一個大一統的帝國。羅馬帝國曾經讓地中海成為自己的內海,拿破侖鼎盛時期掌管著1300萬平方公里的領地。歐洲也曾出現過偉大的帝國,但都未走向統一。
我們再觀察地圖,其實除了中國、俄羅斯以外,全世界99%的國家都是小國。分裂才是常態,統一才不正常。馬老師也曾說過,成功都有偶然性只有失敗才存在必然。只有極少國家才實現了大一統,所以我們不應該問為什么歐洲是分裂的,而應該問為什么中國是統一的。類比到我們的軟件也同樣如此,復雜才是常態,不復雜才不正常。
2.軟件固有的復雜性
The Complexity of software is an essential property, not an accidental one.
-- Grady Booch 《Object-Oriented Analysis and Design with Applications》
譯:軟件的復雜性是一個基本特征,而不是偶然如此。
Grady Booch在 Object-Oriented Analysis and Design with Applications 中提出這樣一個觀念,他認為軟件的復雜性是固有的,包括問題域的復雜性、管理開發過程的困難性、通過軟件可能實現的靈活性與刻畫離散系統行為的問題,這4個方面來分析了軟件的發展一定伴隨著復雜,這是軟件工程這本科學所必然伴隨的一個特性。
Everything, without exception, requires additional energy and order to maintain itself. I knew this in the abstract as the famous second law of thermodynamics, which states that everything is falling apart slowly.
-- Kevin Kelly 《The Inevitable》
譯:世間萬物都需要額外的能量和秩序來維持自身,無一例外。這就是著名的熱力學第二定律,即所有的事務都在緩慢地分崩離析。
Kevin Kelly在 The Inevitable 也有提過類似的觀點,他認為世間萬物都需要額外的能量和秩序來維持自身,所有的事物都在緩慢地分崩離析。沒有外部力量的注入事物就會逐漸崩潰,這是世間萬物的規律,而非我們哪里做得不對。
五、軟件架構治理復雜度
為軟件系統注入的外力就是我們的軟件架構,以及我們未來的每一行代碼。軟件架構有很多種,從最早的單體架構,到后面的分布式架構、SOA、微服務、FaaS、ServiceMesh等等。所有的軟件架構萬變不離其宗,都在致力解決軟件的復雜性。
1.架構的本質
編程范式指的是程序的編寫模式,軟件架構發展到今天只出現過3種編程范式( paradigm ),分別是結構化編程,面向對象編程與函數式編程。
- 結構化編程取消 goto 移除跳轉語句,對程序控制權的直接轉移進行了限制和規范
- 面向對象編程限制 指針 的使用,對程序控制權的間接轉移進行了限制和規范
- 函數式編程以 λ演算法 為核心思想,對程序中的賦值進行了限制和規范
面向對象的五大設計原則 S.O.L.I.D。依賴倒置限制了模塊的依賴順序、單一職責限制模塊的職責范圍、接口隔離限制接口的提供形式。
軟件的本質是約束。商品的代碼不能寫在訂單域,數據層的方法不能寫在業務層。70年的軟件發展,并沒有告訴我們應該怎么做,而是教會了我們不該做什么。
2.遞增的復雜性
軟件的復雜性不會憑空消失,并且會逐級遞增。針對遞增的復雜性有3個觀點:
- 模糊性創造了復雜,依賴性傳播了復雜
- 復雜性往往不是由單個災難引起的
- 我們可以容易地說服自己,當前變更帶來的一點點復雜性沒什么大不了
曾經小李跟我抱怨,說這段代碼實在是太惡心了,花了很長時間才看懂,并且代碼非常僵硬,而正好這個需求需要改動到這里,代碼真的就像一坨亂麻。我問他最后是怎么處理的,他說,我給它又加了一坨。
3.編程思維論
戰術編程
其實小李的這種做法并非是一個個體行為,或許我們在遇到復雜代碼時都曾這樣茍且過,John教授這種編程方法稱之為“戰術編程”。戰術編程最主要的特點是快,同時具備如下幾個特點。
- 當前一定是最快的
- 不會花費太多時間來尋找最佳設計
- 每個編程任務都會引入一些復雜度
- 重構會減慢當前任務速度,所以保持最快速度
@HSFProvider(serviceInterface = AgnDistributeRuleConfigQueryService.class)
public class AgnDistributeRuleConfigQueryServiceImpl implements AgnDistributeRuleConfigQueryService {
@Override
public ResultModel<AgnDistributeRuleConfigDto> queryAgnDistributeRuleConfigById(String id) {
logger.info("queryAgnDistributeRuleConfigById id=" + id);
ResultModel<AgnDistributeRuleConfigDto> result = new ResultModel<AgnDistributeRuleConfigDto>();
if(StringUtils.isBlank(id)){
result.setSuccess(false);
result.setErrorMsg("id cannot be blank");
return result
}
try {
AgnDistributeRuleConfigDto agnDistributeRuleConfigDto = new AgnDistributeRuleConfigDto();
AgnDistributeRuleConfig agnDistributeRuleConfig = agnDistributeRuleConfigMapper.selectById(id);
if(agnDistributeRuleConfig == null){
logger.error("agnDistributeRuleConfig is null");
result.setSuccess(false);
result.setErrorMsg("agnDistributeRuleConfig is null");
return result
}
this.filterDynamicRule(agnDistributeRuleConfig);
BeanUtils.copyProperties(agnDistributeRuleConfig, agnDistributeRuleConfigDto);
result.setSuccess(true);
result.setTotal(1);
result.setValues(agnDistributeRuleConfigDto);
} catch (Exception e) {
logger.error("queryAgnDistributeRuleConfigById error,", e);
result.setSuccess(false);
result.setErrorMsg(e.getMessage());
}
return result;
}
}
我們看上面這段代碼,是一段查詢分發規則的業務邏輯。雖然功能能夠work,但不規范的地方其實非常多
- Facade層定義全部邏輯 - 未做結構分層
- 業務與技術未做分離 - 耦合接口信息與業務數據
- Try catch 滿天飛 - 缺少統一異常處理機制
- 沒有規范化的日志格式 - 日志格式混亂
但不可否認,他一定是當前最快的。這就是戰術設計的特點之一,永遠按當前最快速交付的方案進行推進,甚至很多組織鼓勵這種工作方式,為了使功能更快運作,只注重短期收益而忽略長期價值。
戰術龍卷風
Almost every software development organization has at least one developer who takes tactical programming to the extreme: a tactical tornado.
-- John Ousterhout 《A Philosophy of Software Design》
譯:幾乎每個軟件開發組織都有至少一個將戰術編程發揮到極致的開發人員:戰術龍卷風。
將戰術編程發揮到極致的人,叫戰術龍卷風。戰術龍卷風以腐化系統為代價換取當前最高效的解決方案(或許他自己并未覺得)。戰術龍卷風也有如下幾個特點:
- 是一位多產的程序員,沒人比龍卷風更快完成任務
- 總能留下龍卷風后毀滅的痕跡??留給后人去清理
- 是真的很卷
一些組織甚至會將戰術龍卷風視為英雄,為什么能干得又多又快?因為他將成本放到了未來。軟件工程最大的成本在于維護,我們每一次代碼的改動,都應該是對歷史代碼的一次整理,而非單一的功能堆積。龍卷風能贏得現在,但終將失去未來,而這個失敗的未來或許需要全團隊與他一起買單。
戰略編程
John教授提出與戰術編程相對的是戰略編程,戰略編程更注重長期價值,不滿足于功能work,致力于制作出色的設計,以滿足對未來擴展的訴求(注意,不要過度)。戰略設計有如下4個特點
- 工作代碼遠遠不夠
- 引入不必要的復雜度不可接受
- 不斷對系統設計進行小幅改進
- 投資心態(每位工程師都需要對良好的設計進行連續的少量投資 10~20%)
John Ousterhout教授在 A Philosophy of Software Design 書中提到了戰略設計與戰術設計的總成本投入。隨著時間的流逝,戰略設計可以有效控制軟件成本,但戰術設計會隨著時間的推移線性遞增。這與Martin Fowler在 Patterns of Enterprise Application Architecture 這本書中所提的關于數據驅動與領域驅動關于復雜度的治理是同樣的含義,要致力于長期的價值投資。
4.系統的困境與演進
沒有系統是天然復雜的,為了快速完成任務不斷引入新的復雜度至系統逐漸腐化,無限增長與無限傳遞的復雜度讓軟件需求越來越難“快速完成”。當有一天我們意識到系統的復雜性時再試圖通過戰略設計進行軟件的迭代,你會發現舉步維艱,一處很小的修改需要投入大量的基建修復,最終我們不得不向成本低頭,不斷再通過戰術設計無限的茍且。
A condition that is often incorrectly labeled software maintenance. To be more precise, it is maintenance when we correct errors; it is evolution when we respond to changing requirements; it is preservation when we continue to use extraordinary means to keep an ancient and decaying piece of software in operation. Unfortunately, reality suggests that an inordinate percent- age of software development resources are spent on software preservation.
-- Grady Booch 《Object-Oriented Analysis and Design with Applications》
譯:我們總是說我們需要“維護”這些老系統。而準確的說,在軟件發展過程里,只有我們修正錯誤時,才是維護;在我們應對改變的需求時,這是演進;當我們使用一些極端的手段來保持古老而陳腐的軟件繼續工作時,這是保護(茍且)。事實證明我們更多的時間是在應對最后一種狀況。
如同Grady Booch在 Object-Oriented Analysis and Design with Applications 中所提到的觀點,當我們使用一些極端的手段來保持古老而陳腐的軟件繼續工作時,這確實是一種茍且。我們小心翼翼、集成測試、灰度發布、及時回滾等等,我們沒有在“維護”他們,而是以一種丑陋的方式讓這些丑陋的代碼繼續能夠成功茍且下去。當代碼變成意大利面條時,將幾乎是不可能修復,成本將成指數倍增長,并且似乎我們的系統已經存在這樣的代碼,并且可能還在持續增加中。
六、架構偽論
在架構設計中,總有一些軟件工程師所堅信的詩和遠方,但到不了的烏托邦不一定就是遙不可及的美好圣地,實則也可能是對系統無益甚至有害的架構設計。這里列舉其中2條可能存在的架構偽論。
1.好的代碼自解釋
Comments do not make up for bad code
-- Martin Fowler 《Clean Code》
譯:注釋不是對劣質代碼的補救
Martin Fowler在 Clean Code 書中提到注釋不是對劣質代碼的補救,以前我也一直堅信如果代碼足夠好是不需要注釋的。但實則這是一個偽命題,John教授這么評價它 ‘good code is self-documenting’ is a delicious myth。
/**
* 批量查詢客戶信息
*/
public List<CustomerVO> queryCustomerList(){
// 查詢參數準備
UserInfo userInfo = context.getLoginContext().getUserInfo();
if(userInfo == null || StringUtils.isBlank(userInfo.getUserId())){
return Collections.emptyList();
}
LoginDTO loginDTO = userInfoConvertor.convert(userInfo);
// 查詢客戶信息
List<CustomerSearchVO> customerSearchVOList = customerRemoteQueryService.queryCustomerList(loginDTO);
Iterator<CustomerSearchVO> it = customerSearchVOList.iterator();
// 排除不合規客戶
while(it.hasNext()){
CustomerSearchVO customerSearchVO = it.next();
if(isInBlackList(customerSearchVO) || isLowQuality(customerSearchVO)){
it.remove();
}
}
// 補充客戶其他屬性信息
batchFillCustomerPositionInfo(customerSearchVOList);
batchFillCustomerAddressInfo(customerSearchVOList);
return customerSearchVOList;
}
這段代碼我們可以很輕松的在5秒內看明白這個函數是做什么的,并且知道它內部的一些業務規則。無限的私有方法封裝會讓代碼鏈路過深,無限類的拆解會造成更多網狀依賴,至少有3點內容,讓我們絕不能拋棄注釋。
無法精準命名
命名的含義是抽象實體隱藏細節,我們不能在一個名字上賦予它全部的信息,而必要的注釋可以完美的進行輔佐。
設計思想的闡述
代碼只能實現設計不能闡述設計,這也是為什么一些復雜的架構設計我們需要文檔的支撐而非代碼的‘自解釋’,在文檔與代碼之間的空隙,由注釋來填補。
母語的力量
這點尤其適合我們中國人,有時并不是因為注釋少代碼多,所以我們下意識會首先看代碼。而是我們幾十年感受的文化,讓我們對中文與ABC具有完全不一樣的感觀。
2.永遠追求最優雅
雷布斯曾自夸自己寫的代碼像詩一樣優雅,追求優雅的代碼應該是每個軟件工程師的心中的圣地。但有時存在一些不優雅,存在一些‘看似不合理’并不代表就不對,反而有時在追求更優雅的路上我們持續跑偏。
The goal of software architecture is to minimize the human resources required
to build and maintain the required system.
-- Robert C.Martin 《Clean Architecture》
譯:軟件架構的終極目標是,用最小的人力成本來滿足構建和維護該系統的需求
Robert C.Martin在 Clean Architecture 一書中提到了架構終極目標,用最小的人力成本來滿足構建和維護該系統的需求。架構始終是我們解決復雜度的一個工具,如果當前系統并不復雜,我們不需要為了所謂的優雅去過分改造與優化它,持續將成本置在一個較低水位,就是軟件最好的解決辦法。
業務簡單的系統不應用DDD架構,弱交互場景也無需進行前后端分離,哪怕是鄧總設計師在規劃新中國的發展上,也是制定了一套‘中國特色社會主義’制度。不要盲從一些教條的觀念,選擇適合自己的,控制在可控制范圍內,既不過度也不缺失。畢竟沒有絕對的優雅,甚至沒有絕對的正確。
七、寫在最后
很多人認為做業務開發顯得沒那么有挑戰性,但其實正好相反。最難解決的bug是無法重現的bug,最難處理的問題域是不確定性的問題域。業務往往是最復雜的,面向不確定性設計才是最復雜的設計。軟件工程學科最難的事情是抽象,因為它沒有標準、沒有方法、甚至沒有對錯。如何在軟件固有的復雜性上找到一條既不過度也不缺失的路,是軟件工程師的終身課題,或許永遠也無法達到,或許我們已經在路上了。
參閱書籍
《A Philosophy of Software Design》《Object Oriented Analysis and Design with Applications》《Clean Code》《Clean Architecture》《Patterns of Enterprise Application Architecture》