系統困境與軟體複雜度,為什麼我們的系統會如此複雜

阿里巴巴移動技術發表於2022-06-17

作者:聶曉龍(率鴿)

讀 A Philosophy of Software Design 有感,軟體設計與架構複雜度,你是戰術龍捲風嗎?

前言

有一天,一個醫生和一個土木工程師在一起爭論“誰是世界上最古老的職業”。醫生說:“上帝用亞當的肋骨造出了夏娃,這是歷史上第一次外科手術,所以最古老的職業應該是醫生”,土木工程師說:“在創世紀之前,上帝從混沌中創造了天堂與人間,這是更早之前的一次土木作業,所以最古老的職業應該是土木工程”。這時軟體工程師拖著鍵盤走出來說,“那你認為,是誰創造了那片混沌?”

建築師不會輕易給100層的高樓增加一個地下室,但我們卻經常在幹這樣的事,並且總有人會對你說,“這個需求很簡單”。到土裡埋個地雷,這確實不復雜,但我們往往面臨的真實場景其實是:“在這片雷區里加一個雷”,而雷區裡哪裡有雷,任何人都不知道 。

什麼是複雜性

我們一直在說系統很複雜,那到底什麼是複雜性?關於複雜的定義有很多種,其中比較有代表的是Thomas J. McCabe 在1976提出的理性派的複雜性度量,與John Ousterhout 教授提出的感性派的複雜性認知。

理性度量

複雜性並不是什麼新概念,早在上世紀70年代,軟體就已經極其複雜,開發與維護的成本都非常高。1976年McCabe&Associates公司開始對軟體進行結構測試,並提出了McCabe Cyclomatic Complexity Metric,我們也稱之為McCabe圈複雜度。它通過多個維度來度量軟體的複雜度,從而判斷軟體當前的開發/維護成本。

感性認知

複雜度高的程式碼一定不是好程式碼,但複雜度低的也不一定就是好程式碼。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. 想簡單圖省事,沒有及時治理不合理的內容
  2. 缺少匠心追求,對骯髒程式碼視而不見
  3. 技術能力不夠,無法應對複雜系統
  4. 交接過渡缺失,三無產品幾乎無法維護

除了上述內容外,還可以想到很多理由。但我們發現他們好像有一個共同的指向點 - 軟體工程師,似乎所有複雜的源頭就是軟體工程師的不合格導致,所以其實一些罪惡的根因是我們自己?

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等等。所有的軟體架構萬變不離其宗,都在致力解決軟體的複雜性。

架構的本質

程式設計正規化指的是程式的編寫模式,軟體架構發展到今天只出現過3種程式設計正規化( paradigm ),分別是結構化程式設計,物件導向程式設計與函數語言程式設計。

  • 結構化程式設計取消 goto 移除跳轉語句,對程式控制權的直接轉移進行了限制和規範
  • 物件導向程式設計限制 指標 的使用,對程式控制權的間接轉移進行了限制和規範
  • 函數語言程式設計以 λ演演算法 為核心思想,對程式中的賦值進行了限制和規範

物件導向的五大設計原則 S.O.L.I.D。依賴倒置限制了模組的依賴順序、單一職責限制模組的職責範圍、介面隔離限制介面的提供形式。

軟體的本質是約束。商品的程式碼不能寫在訂單域,資料層的方法不能寫在業務層。70年的軟體發展,並沒有告訴我們應該怎麼做,而是教會了我們不該做什麼。

遞增的複雜性

軟體的複雜性不會憑空消失,並且會逐級遞增。針對遞增的複雜性有3個觀點:

  1. 模糊性創造了複雜,依賴性傳播了複雜
  2. 複雜性往往不是由單個災難引起的
  3. 我們可以容易地說服自己,當前變更帶來的一點點複雜性沒什麼大不了

曾經小李跟我抱怨,說這段程式碼實在是太噁心了,花了很長時間才看懂,並且程式碼非常僵硬,而正好這個需求需要改動到這裡,程式碼真的就像一坨亂麻。我問他最後是怎麼處理的,他說,我給它又加了一坨。

程式設計思維論

戰術程式設計

其實小李的這種做法並非是一個個體行為,或許我們在遇到複雜程式碼時都曾這樣苟且過,John教授這種程式設計方法稱之為“戰術程式設計”。戰術程式設計最主要的特點是快,同時具備如下幾個特點。

  1. 當前一定是最快的
  2. 不會花費太多時間來尋找最佳設計
  3. 每個程式設計任務都會引入一些複雜度
  4. 重構會減慢當前任務速度,所以保持最快速度
@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,但不規範的地方其實非常多

  1. Facade層定義全部邏輯 - 未做結構分層
  2. 業務與技術未做分離 - 耦合介面資訊與業務資料
  3. Try catch 滿天飛 - 缺少統一異常處理機制
  4. 沒有規範化的日誌格式 - 日誌格式混亂

但不可否認,他一定是當前最快的。這就是戰術設計的特點之一,永遠按當前最快速交付的方案進行推進,甚至很多組織鼓勵這種工作方式,為了使功能更快運作,只注重短期收益而忽略長期價值。

戰術龍捲風

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》

將戰術程式設計發揮到極致的人,叫戰術龍捲風。戰術龍捲風以腐化系統為代價換取當前最高效的解決方案(或許他自己並未覺得)。戰術龍捲風也有如下幾個特點:

  1. 是一位多產的程式設計師,沒人比龍捲風更快完成任務
  2. 總能留下龍捲風後毀滅的痕跡留給後人去清理
  3. 是真的很卷

一些組織甚至會將戰術龍捲風視為英雄,為什麼能幹得又多又快?因為他將成本放到了未來。軟體工程最大的成本在於維護,我們每一次程式碼的改動,都應該是對歷史程式碼的一次整理,而非單一的功能堆積。龍捲風能贏得現在,但終將失去未來,而這個失敗的未來或許需要全團隊與他一起買單。

戰略程式設計

John教授提出與戰術程式設計相對的是戰略程式設計,戰略程式設計更注重長期價值,不滿足於功能work,致力於製作出色的設計,以滿足對未來擴充套件的訴求(注意,不要過度)。戰略設計有如下4個特點

  1. 工作程式碼遠遠不夠
  2. 引入不必要的複雜度不可接受
  3. 不斷對系統設計進行小幅改進
  4. 投資心態(每位工程師都需要對良好的設計進行連續的少量投資 10~20%)

John Ousterhout教授在 A Philosophy of Software Design 書中提到了戰略設計與戰術設計的總成本投入。隨著時間的流逝,戰略設計可以有效控制軟體成本,但戰術設計會隨著時間的推移線性遞增。這與Martin Fowler在 Patterns of Enterprise Application Architecture 這本書中所提的關於資料驅動與領域驅動關於複雜度的治理是同樣的含義,要致力於長期的價值投資。

系統的困境與演進

沒有系統是天然複雜的,為了快速完成任務不斷引入新的複雜度至系統逐漸腐化,無限增長與無限傳遞的複雜度讓軟體需求越來越難“快速完成”。當有一天我們意識到系統的複雜性時再試圖通過戰略設計進行軟體的迭代,你會發現舉步維艱,一處很小的修改需要投入大量的基建修復,最終我們不得不向成本低頭,不斷再通過戰術設計無限的苟且。

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點內容,讓我們絕不能拋棄註釋。

  1. 無法精準命名
    命名的含義是抽象實體隱藏細節,我們不能在一個名字上賦予它全部的資訊,而必要的註釋可以完美的進行輔佐。
  2. 設計思想的闡述
    程式碼只能實現設計不能闡述設計,這也是為什麼一些複雜的架構設計我們需要文件的支撐而非程式碼的‘自解釋’,在文件與程式碼之間的空隙,由註釋來填補。
  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,最難處理的問題域是不確定性的問題域。業務往往是最複雜的,面向不確定性設計才是最複雜的設計。軟體工程學科最難的事情是抽象,因為它沒有標準、沒有方法、甚至沒有對錯。如何在軟體固有的複雜性上找到一條既不過度也不缺失的路,是軟體工程師的終身課題,或許永遠也無法達到,或許我們已經在路上了。

參閱書籍:

關注【阿里巴巴移動技術】,阿里前沿移動乾貨&實踐給你思考!

相關文章