如何從容應對複雜性

軟體的複雜性,是一個很泛的概念。
但是一直都是開發過程中的一個難題,本文旨在探討如何去從容應對複雜性。

一  軟體的熵增、構造定律

1  熵增定律

熵的概念最早起源於物理學,熱力學第二定律(又稱“熵增定律”),表明了在自然過程中,一個孤立的系統總是從最初的集中、有序的排列狀態,趨向於分散、混亂和無序;當熵達到最大時,系統就會處於一種靜寂狀態。
軟體系統亦是如此, 在軟體系統的維護過程中。軟體的生命力會從最初的集中、有序的排列狀態,逐步趨向複雜、無序狀態,直到軟體不可維護而被迫下線或重構。

2  構造定律

自然界是如何應對這複雜性?
這在物理中被稱為構造定律 (Constructal Law), 是由Adrian Bejan於1995提出的:
For a finite-size system to persist in time (to live), it must evolve in such a way that it provides easier access to the imposed currents that flow through it.
對於一個有限大小的持續活動的系統,它必須以這種方式發展演進:它提供了一種在自身元素之間更容易訪問的流動方式。
這個定理在自然界中比比皆是,最典型的比如水迴圈系統,海水蒸發到大氣,下雨時降落在地面,一部分滲入地面流入江河,一部分繼續蒸發,不斷迴圈。這種自發性質的設計反映了這一趨勢:他們允許實體或事物更容易地流動 – 以最少的能量消耗到達最遠的地方,就連街道和道路這些人為地構建物體,往往也是有排序的模式,以提供最大的靈活性。

二  如何應對軟體系統的複雜性?

軟體系統的複雜性往往是被低估的。複雜越高,開發人員會感到不安。對其的理解認知負荷代價就越高,我們就更不快樂。真正的挑戰是在構建我們的系統時要保持其有序以及工程師的生產方式。
Ousterhout教授在《軟體設計的哲學》書中提到:軟體設計的最大目標,就是降低複雜度(complexity)。
就是設計符合業務的構造定律的演進方式,一種可以以最小的開發維護成本, 使業務更快更好的流動發展的方式。

三  軟體複雜性來自哪裡, 如何解決?

1  不確定性的來源

1、業務的不確定性
2、技術的不確定性
3、人員流動的不確定性

2  如何面對不確定性

面對外部的確定性,轉化為核心的確定性。
面對外部的不確定性,找到穩定的核心基礎。
專注問題域
當下網際網路發展速度是迅猛的, 軟體的形態也在不斷的變化演進。面對未來的業務及變化,橫向業務與縱向業務的發展都是不確定性的。
Robert C. Martin提到的BDUF,永遠不要想著在開始就設計好了全部的事情(big design up front),一定要避免過度設計。除非能夠十分確認的可預見變化, 業務邊界,否則專注解決當前1-2年內業務變化設計, 講好當下的使用者故事,專注解決眼前的問題域。 面向不確定設計,增量敏捷開發。
確認穩定的系統核心
隨著業務的變化、系統設計也要持續演進升級。沒有一開始就完美的架構, 好的架構設計一定演化來的,不是一開始就設計出來的。
一個健康公司的成長,業務橫向、縱向會發展的會越來越複雜,支援業務的系統也一定會越來越複雜。
系統演進過程中的成本,會受到最開始的設計、系統最初的核心影響的。面對外部業務的不確定性, 技術的不確定性,外部依賴的不確定性。一個穩定的核心應該儘量把外部的不確定性隔離。
  • 業務與技術的隔離
以業務為核心,分離業務複雜度和技術複雜度。
  • 內部系統與外部依賴的隔離
  • 系統中常變部分與不常變部分的隔離
  • 隔離複雜性(把複雜性的部分隔離在一個模組,儘量不與其他模組互動)

3  無序性

系統和程式碼像多個線團一樣散落一地一樣,混亂不堪,毫無頭緒。

4  如何面對無序性

1、統一認知(秩序化)
2、系統清晰明瞭的結構(結構化)
3、業務開發流程化(標準化)
注:這裡說的流程化並非指必須使用類似BPM的流程編排系統,
而是指對於一個需求,業務開發有一定的順序, 有規劃的先做一部分事情,開發哪一個模組再去做剩下的工作,是可以流程化的。

5  規模

業務規模的膨脹以及開發團隊規模的膨脹,都會帶來系統的複雜性提升。

6  如何面對規模膨脹帶來的複雜性

1、業務隔離, 分而治之
2、專注產品核心競爭力的發展
3、場景分層
關鍵場景
投入更多的開發、測試資源、業務資源(比如單元測試覆蓋率在90%以上)在關鍵場景。
普通場景
更快,更低成本、更少資源投入地完成普通場景的迭代

7  認知成本

是指開發人員需要多少知識才能完成一項任務。
在引入新的變化時,要考慮到帶來的好處是否大於系統認知成本的提升,比如:之前提到的BPM流程編排引擎,如果對系統帶來的好處不夠多也是增加認知成本的一種。
不合適的設計模式也是增加認知成本的一種,前臺同學吐槽的中臺架構比較高的學習成本, 也是認知成本的一種。

8  如何降低認知成本

1、系統與現實業務更自然真實的對映,對業務抽象建模
軟體工程師實際上只在做一件事情,即把現實中的問題搬到計算機上,透過資訊化提升生產力。
2、程式碼的含義清晰,不模糊
3、程式碼的整潔度
4、系統的有序性, 架構清晰
5、避免過度設計
6、減少複雜、重複概念, 降低學習成本
7、謹慎引入會帶來系統複雜性的變化

四  應對複雜性的利器

1  領域驅動設計——DDD

DDD是把業務模型翻譯成系統架構設計的一種方式, 領域模型是對業務模型的抽象。
不是所有的業務服務都合適做DDD架構,DDD合適產品化,可持續迭代,業務邏輯足夠複雜的業務系統,小規模的系統與簡單業務不適合使用,畢竟相比較於MVC架構,認知成本和開發成本會大不少。但是DDD裡面的一些戰略思想我認為還是較為通用的。

對通用語言的提煉和推廣

清晰語言認知, 比如之前在詳情裝修系統中:
ItemTemplate : 表示當前具體的裝修頁面
ItemDescTemplate、Template,兩個都能表示模板概概念
剛開始接觸這塊的時候比較難理解這一塊邏輯,之後在負責設計詳情編輯器大融合這個專案時第一件事就是團隊內先重新統一認知。
  • 裝修頁面統一使用 —— Page概念
  • 模板統一使用 —— Template概念
不將模板和頁面的概念糅雜在一起,含糊不清,避免重複和混亂的概念定義。

貧血模型和充血模型

1)貧血模型

貧血模型的基本特徵是:它第一眼看起來還真像這麼回事兒。專案中有許多物件,它們的命名都是根據領域模型來的。然而當你真正檢視這些物件的行為時,會發現它們基本上沒有任何行為,僅僅是一堆getter/setter方法。
這些貧血物件在設計之初就被定義為只能包含資料,不能加入領域邏輯;所有的業務邏輯是放在所謂的業務層(xxxService, xxxManager物件中),需要使用這些模型來傳遞資料。
@Datapublicclass Person {/** * 姓名 */privateString name;/** * 年齡 */private Integer age;/** * 生日 */privateDate birthday;/** * 當前狀態 */private Stauts stauts;}
publicclassPersonServiceImplimplementsPersonService {publicvoidsleep(Person person) { person.setStauts(SleepStatus.get()); }publicvoidsetAgeByBirth(Person person) { Date birthday = person.getBirthday();if (currentDate.before(birthday)) {thrownew IllegalArgumentException("The birthday is before Now,It's unbelievable"); }int yearNow = cal.get(Calendar.YEAR);int dayBirth = bir.get(Calendar.DAY_OF_MONTH);/*大概計算, 忽略月份等,年齡是當前年減去出生年*/int age = yearNow - yearBirth; person.setAge(age); }}}
publicclassWorkServiceImplimplementsWorkService{publicvoidcode(Person person){ person.setStauts(CodeStatus.get()); }}
這一段程式碼就是貧血物件的處理過程,Person類, 透過PersonService、WorkingService去控制Person的行為,第一眼看起來像是沒什麼問題,但是真正去思考整個流程。WorkingService, PersonService到底是什麼樣的存在?與真實世界邏輯相比, 過於抽象。基於貧血模型的傳統開發模式,將資料與業務邏輯分離,違反了 OOP 的封裝特性,實際上是一種面向過程的程式設計風格。但是,現在幾乎所有的 Web 專案,都是基於這種貧血模型的開發模式,甚至連 Java Spring 框架的官方 demo,都是按照這種開發模式來編寫的。
面向過程程式設計風格有種種弊端,比如,資料和操作分離之後,資料本身的操作就不受限制了。任何程式碼都可以隨意修改資料。

2)充血模型

充血模型是一種有行為的模型,模型中狀態的改變只能透過模型上的行為來觸發,同時所有的約束及業務邏輯都收斂在模型上。
@DatapublicclassPersonextendsEntity {/** * 姓名 */private String name;/** * 年齡 */private Integer age;/** * 生日 */private Date birthday;/** * 當前狀態 */private Stauts stauts;publicvoidcode() {this.setStauts(CodeStatus.get()); }publicvoidsleep() {this.setStauts(SleepStatus.get()); }publicvoidsetAgeByBirth() { Date birthday = this.getBirthday(); Calendar currentDate = Calendar.getInstance();if (currentDate.before(birthday)) {thrownew IllegalArgumentException("The birthday is before Now,It's unbelievable"); }int yearNow = currentDate.get(Calendar.YEAR);int yearBirth = birthday.getYear();/*粗略計算, 忽略月份等,年齡是當前年減去出生年*/int age = yearNow - yearBirth;this.setAge(age); }}

3)貧血模型和充血模型的區別

/** * 貧血模型 */publicclassClient { @Resourceprivate PersonService personService; @Resourceprivate WorkService workService;publicvoidtest() { Person person = new Person(); personService.setAgeByBirth(person); workService.code(person); personService.sleep(person); }}/** * 充血模型 */publicclassClient {publicvoidtest() { Person person = new Person(); person.setAgeByBirth(); person.code(); person.sleep(); }}
上面兩段程式碼很明顯第二段的認知成本更低,  這在滿是Service,Manage 的系統下更為明顯,Person的行為交由自己去管理, 而不是交給各種Service去管理。
貧血模型是事務指令碼模式
貧血模型相對簡單,模型上只有資料沒有行為,業務邏輯由xxxService、xxxManger等類來承載,相對來說比較直接,針對簡單的業務,貧血模型可以快速的完成交付,但後期的維護成本比較高,很容易變成我們所說的麵條程式碼。
充血模型是領域模型模式
充血模型的實現相對比較複雜,但所有邏輯都由各自的類來負責,職責比較清晰,方便後期的迭代與維護。
面向物件設計主張將資料和行為繫結在一起也就是充血模型,而貧血領域模型則更像是一種面向過程設計,很多人認為這些貧血領域物件是真正的物件,從而徹底誤解了面向物件設計的涵義。
Martin Fowler 曾經和 Eric Evans 聊天談到它時,都覺得這個模型似乎越來越流行了。作為領域模型的推廣者,他們覺得這不是一件好事,極力反對這種做法。
貧血領域模型的根本問題是,它引入了領域模型設計的所有成本,卻沒有帶來任何好處。最主要的成本是將物件對映到資料庫中,從而產生了一個O/R(物件關係)對映層。
只有當你充分使用了面向物件設計來組織複雜的業務邏輯後,這一成本才能夠被抵消。如果將所有行為都寫入到Service物件,那最終你會得到一組事務處理指令碼,從而錯過了領域模型帶來的好處。而且當業務足夠複雜時, 你將會得到一堆爆炸的事務處理指令碼。

對業務的理解和抽象

限定業務邊界,對業務進行與現實更自然的理解和抽象,資料模型與業務模型隔離,把業務對映成為領域模型沉澱在系統中。

結構與防腐層

User Interfaces
負責對外互動, 提供對外遠端介面
application
應用程式執行其任務所需的程式碼。
它協調域層物件以執行實際任務。
該層適用於跨事務、安全檢查和高階日誌記錄。
domain
負責表達業務概念。
對業務的分解,抽象,建模 。
業務邏輯、程式的核心。
防腐層介面放在這裡。
infrastucture
為其他層提供通用的技術能力。如repository的implementation(ibatis,hibernate, nosql),中介軟體服務等anti-corruption layer的implementation 防腐層實現放在這裡。
防腐層的作用:
封裝三方服務。
隔離內部系統對外部的依賴。

讓隱性概念顯性化

文件與註釋可能會失去即時性(文件、註釋沒有人持續維護),但是線上生產程式碼是業務邏輯最真實的展現,減少程式碼中模糊的地方,讓業務邏輯顯性化體現出來,提升程式碼清晰度。
if (itemDO != null && MapUtils.isNotEmpty(itemDO.getFeatures()) && itemDO.getFeatures().containsKey(ITEM_PC_DESCRIPTION_PUSH)) { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode());} else { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_WL_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode()); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_WL_PUSH, "" + content.hashCode());}
比如這一段程式碼就把判斷裡的業務邏輯隱藏了起來,這段程式碼其實的業務邏輯是這樣, 判斷商品是否有PC裝修內容。如果有做一些操作, 如果沒有做一些操作,將hasPCContent 這個邏輯表現出來, 一眼就能看出來大概的業務邏輯,讓業務邏輯顯現化,能讓程式碼更清晰。可以改寫成這樣:
boolean hasPCContent = itemDO != null && MapUtils.isNotEmpty(itemDO.getFeatures()) && itemDO.getFeatures().containsKey(ITEM_PC_DESCRIPTION_PUSH);if (hasPCContent) { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode());} else { itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_PC_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_WL_TEMPLATEID, "" + templateId); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_PC_PUSH, "" + pcContent.hashCode()); itemUpdateBO.getFeatures().put(ItemTemplateConstant.FEATURE_TSP_SELL_WL_PUSH, "" + content.hashCode());}

2  簡單設計原則——《Clean Code》

1、保持系統最大可測試
只要系統可測試並且越豐富的單元測試越會導向保持類短小且目的單一的設計方案,遵循單一職責的類,測試起來比較簡單。
遵循有關編寫測試並持續執行測試的簡單、明確規則,系統就會更貼近OO低偶爾度,高內聚度的目標。編寫測試越多,就越會遵循DIP之類的規則,編寫最大可測試可改進並走向更好的系統設計。
2、避免重複
重複是擁有良好設計系統的大敵。它代表著額外的工作、額外的風險和額外且不必要的複雜度。除了雷同的程式碼,功能類似的方法也可以進行包裝減少重複,“小規模複用”可大量降低系統複雜性。要想實現大規模複用,必須理解如何實現小規模複用。
共性的抽取也會使程式碼更好的符合單一職責原則。
3、更清晰的表達開發者的意圖
軟體專案的主要成本在於長期維護,當系統變得越來越複雜,開發者就需要越來越多的時間來理解他,而且也極有可能誤解。
所以作者需要將程式碼寫的更清晰:選用好名稱、保持函式和類的短小、採用標準命名法、標準的設計模式名,編寫良好的單元測試。用心是最珍貴的資源。
4、儘可能減少類和方法
如果過度使用以上原則,為了保持類的函式短小,我們可能會造出太多細小的類和方法。所以這條規則也主張函式和類的數量要少。
如應當為每個類建立介面、欄位和行為必須切分到資料類和行為類中。應該抵制這類教條,採用更實用的手段。目標是在保持函式和類短小的同時,保持系統的短小精悍。不過這是優先順序最低的一條。更重要的是測試,消除重複和清晰表達。

五  最後

總而言之,做業務開發其實一點也不簡單,面對不確定性的問題域,複雜的業務變化,
如何更好的理解和抽象業務,如何更優雅的應對複雜性,一直都是軟體開發的一個難題。
在對抗軟體熵增,尋找對抗軟體複雜性,符合業務的構造定律的演進方式,我們一直都在路上。

參考

[1]  《Domain-Driven Design》 :https://book.douban.com/subject/1629512/
[2] 《Implementing Domain-Driven Design》 :https://book.douban.com/subject/25844633/
[3] 《Clean Code》:https://book.douban.com/subject/4199741/
[4]  《A Philosophy of Software Design》 :https://book.douban.com/subject/30218046/

求職特訓營火熱來襲!阿里專家教你製作專業簡歷!
如何在眾多簡歷中吸引HR關注?如何描述過往經歷突出亮點?如何增加簡歷加分項?阿里專家從面試官角度告訴你一份高質量簡歷應該長什麼樣!
阿里雲開發者社群舉辦“阿里專家五堂課教你製作專業簡歷”訓練營,邀請四位阿里專家傾情傳授簡歷秘籍,深挖簡歷承載的價值,從面試官的角度切入講解簡歷必含模組,更有資深專家直播線上答疑幫你修改簡歷!金三銀四黃金求職季,阿里專家助力你的求職之路!還在等什麼?立即點選閱讀原文免費報名參加!

相關文章