張逸 中生代技術
張逸,架構編碼實踐者,IT 文藝工作者,大資料平台架構師,兼愛面向對象與函數式程式設計,熱衷于程式設計語言學習與技藝提升,緻力于将主流領域驅動設計(DDD)與函數式程式設計、響應式程式設計以及微服務架構完美結合。個人微信公衆号:逸言。個人部落格: (http://iamzhangyi.github.io)
複雜的事物讓人着迷,繁複、多樣、無序以及其中蘊含的無窮變化或許也是我覺得軟體設計有趣的地方。由于設計的複雜性,我在每次面臨不同的項目、不同的産品時,油然而生一種耳目一新的感覺,似乎重新開機了新的旅程,風景不同,心境自然也就不同了。
然而,複雜并不總是令人感到有趣,除非我們具有掌控複雜的能力。
那麼,什麼是複雜?
Jurgen Appelo 在分析複雜系統理論時,将 Complicated 與 Complex 分别放在了解力與預測能力兩個迥然不同的次元上。Complicated 與 Simple(簡單)相對,意指非常難以了解, 而 Complex 則介于 Ordered(有序的)與 Chaotic(混沌的)之間,意指在某種程度上可以預測,但會有很多出乎意料的事情發生,如圖 4.1 所示。
大多數軟體系統是難以了解的,雖然我們可以遵循一些設計原則來應對未來的變化,但由于未來是不可預測的,因而軟體的演進其實存在不可預測的風險。如此看來,軟體系統所謂的“複雜”其實覆寫了 Complicated 與 Complex 兩個方面,等同于圖 4.1 中城市所處的位置。湊巧的是,Sam Newman 也認為城市的變遷與軟體的演化存在很大程度的相似性:
圖 4.1
很多人把城市比作生物,因為城市會時不時地發生變化。當居民對城市的使用方式有所變化,或者受到外力的影響時,城市就會相應地演化。
上面描述的城市和軟體的對應關系應該是很明顯的。當使用者對軟體提出變更需求時,我們需要對其進行響應并做出相應的改變。未來的變化很難預見,是以與其對所有變化的可能性進行預測,不如做一個允許變化的計劃。
城市與軟體的複雜度有可比之處,還在于其結構的複雜性。不同風格與不同類型的建築,雜亂如蜘蛛網一般的城市道路,還有居民生存的複雜生态圈,展現出形态各異的風貌,甚至每一條陋巷都背負了滄桑厚重的曆史。
軟體系統的代碼行即磚瓦,通信端口即車輛行駛的道路,每個構模組化塊是建築物,基礎設施是排水系統,公共子產品是醫院、學校或者公園,軟體架構就是對整個城市的規劃和布局。
因而要了解軟體系統的複雜度,也可以結合了解力與預測能力這兩個因素來幫助我們思考。在軟體系統中,是什麼阻礙了開發人員對它的了解?想象一下,團隊招入一位新人,這位新人就像一位遊客來到了一座陌生的城市,他是否會迷失在阡陌交錯的城市交通體系中不辨方向?倘若這座城市不過隻有房屋數間,一條街道連通城市的兩頭,實則是鄉野郊外的一座村落,那還會使他生出迷失之感嗎?
是以,影響了解力的第一要素是規模。
軟體的需求決定了系統的規模。當需求呈現線性增長的趨勢時,為了實作這些功能,軟體規模也會以近似的速度增長。
由于需求不可能做到完全獨立,這種互相影響互相依賴的關系使得修改一處就會牽一發而動全身。
就好似城市的一條道路因為施工需要臨時關閉,此路不通,通行的車輛隻得改道繞行,這又導緻了其他原本已經飽和的道路因為湧入更多車輛,超出道路的負載進而變得更加擁堵,這種擁堵現象又會順勢向這些道路的其他分叉道路蔓延,形成一種輻射效應的擁堵現象。
以下幾種情況都可能使軟體開發産生擁堵現象,或許比道路堵塞更嚴重。
函數存在副作用,調用時可能對函數的結果作了隐含的假設。 類的職責繁多,不敢輕易修改,因為不知道這種變化會影響到哪些子產品。 熱點代碼被頻繁變更,職責被包裹了一層又一層,沒有清晰的邊界。 在系統的某個角落裡,隐藏着伺機而動的 Bug,當誘發條件具備時,就會讓整條調用鍊癱瘓。 在不同場景下,會産生不同的異常場景,每種異常場景的處理方式都各不相同。 同步處理與異步處理代碼糾纏在一起,不可預知程式執行的順序。
這是一個複雜的生态環境,新的需求變化就好似在南美洲亞馬孫河流域熱帶雨林中的蝴蝶,輕輕扇動一下翅膀,就在美國得克薩斯州掀起了一場龍卷風。面對軟體複雜度的“蝴蝶效應”,我們心存畏懼。
在我負責設計與開發的 BI(Business Intelligence)産品中,我們需要展現報表(Report) 下的所有視圖(View)。這些視圖的資料可能來自多個不同的資料集(Data Set),而視圖的類型也多種多樣,例如柱狀圖、折線圖、散點圖等。
在這個“逼仄”的報表問題域中,我們需要滿足如下業務需求。
在編輯狀态下,支援對每個視圖進行拖曳以改變視圖的位置。
在編輯狀态下,允許通過拖曳邊框調制視圖的尺寸。 當單擊視圖的圖形區域時,應當使目前圖形的組成部分顯示高亮。 當單擊視圖的圖形區域時,應當擷取目前值,對屬于相同資料集的視圖進行關聯。 如果打開鑽取開關,則應當在單擊視圖的圖形區域時擷取目前值,并根據事先設定的鑽取路徑對視圖進行鑽取。 能夠建立篩選器這樣的特殊視圖,通過篩選器選擇資料,對目前報表中所有相同資料集的視圖進行篩選。
這些業務需求都是我們事先預見到的,無一例外,它們都是對視圖進行操作,這就導緻了多種操作之間的糾纏與沖突。例如,高亮與級聯都需要響應相同的 Click 事件,鑽取同樣如此,與之不同的是它還要判斷鑽取開關是否已經打開。而在操作效果上,如果高亮與鑽取僅針對目前視圖本身,則關聯與篩選就會因為目前視圖的操作影響到同一張報表下其他屬于相同資料集的視圖。對于拖曳操作,雖然它監聽的是 MouseDown 事件,但該事件卻與 Click 事件沖突。顯然,實作這些功能的複雜度不能僅以功能點的增加來衡量。
軟體複雜度會受到需求與規模的正向影響,但它的增長趨勢要比需求與規模更加陡峭。
倘若需求還産生了事先未曾預料到的變化,我們又沒有足夠的風險應對措施,那麼在時間 緊迫的情況下,難免會對設計做出妥協,頭疼醫頭,腳疼醫腳,在系統的各個地方打上更新檔,進而欠下技術債。當技術債務越欠越多,累計到某個臨界點時,量變就會引起質變, 整個軟體系統的複雜度達到巅峰,步入衰亡的老年期。許多遺留系統(Legacy System)就 掙紮在瀕臨死亡的懸崖邊上。這些遺留系統符合飼養場的奶牛原則:
奶牛逐漸衰老,最終無奶可擠;然而與此同時,飼養成本卻在上升。
這意味着遺留系統會逐漸随着時間的推移,不斷地增加維護成本。
一方面,随着需求 的變化,對遺留系統的維護變得越來越捉襟見肘;
另一方面,系統的知識又逐漸被腐蝕。團隊成員變動了,留存在他們大腦中的系統知識随之而去。文檔呢?勤奮而尊重流程的團隊或許編寫了可謂聖經一般完整而翔實的文檔,可惜我們卻隻能參考,而不可盡信,因為這些文檔不過是刻在船舷上的印迹,雖然刻下了當時寶劍落下的位置,然而舟船已經随着槳聲欸乃滑向了彼岸。似乎隻有代碼才是最忠實的,然而當遭遇佶屈聱牙、晦澀難懂的代碼時,當需要解開如一團亂麻般的依賴關系時,我們又該何去何從?
需求的變化,知識的流逝,正是遺留系統之殇!
我曾經參與過某大型金融機構客戶系統的技術棧遷移。為了保證我們的技術棧遷移沒有破壞系統的原有功能,需要為系統的核心功能編寫自動化測試以形成保護網。
當時,曾經參與過該系統開發的人員已經“遺失”殆盡,我們除了得到少數團隊人員的有限支援, 還可以參考和借鑒的隻有這個系統的數百頁 Word 文檔以及千萬行級的 Java 代碼庫。Java 代碼庫經曆了大約七八年的變遷,并主要由外包團隊開發,涉及的平台與架構包括 EJB 2、 Spring 3.0、Struts,乃至 JDK 5 之前的 Java 代碼;
除此之外,還有部分我們完全搞不懂的 COBOL 代碼(COBOL 語言? 是在遠古時代吧!)。閱讀代碼庫時,我們常常震驚于龐大臃腫的類,許多類的代碼行數超過一萬行以上,而數千行的方法體也是屢見不鮮,并沿襲了原始時代的程式設計傳統,常常在方法的首端定義了數十個變量,并在整個方法中被重複指派、 修改。系統通過 IBM MQ 實作分布式系統之間的內建。子系統之間傳遞的消息被定義為各式沒有任何業務意義的消息編碼,諸如 S01、S02、P01、P02。我們需要查閱文檔了解這些 消息代碼代表的業務含義,還需要明确消息之間傳遞的流程以及處理邏輯。
我們在為合并客戶賬戶場景編寫自動化測試時,發現文檔中描述的異常消息 S05 的處理邏輯與實際的運作結果不一緻。無奈之下,我們隻有通過閱讀源代碼尋找業務的真相。
這個過程仿佛福爾摩斯探案,我們不能放過代碼中任何可能揭示真相的蛛絲馬迹。運作已經編寫好的自動化測試,結合跨程序的調試手法,通過列印控制台日志來複現消息的走向, 進而通盤了解業務流程的運作軌迹。最後,真相水落石出,而我們發現為了編寫這個自動化測試,足足耗費了兩個人日的時間。
軟體規模的一個顯著特征是代碼行數。然而,代碼行數常常具有欺騙性。如果需求與代碼行數之間呈現出不成比例的關系,則說明該系統的生命體征可能出現了異常,例如代碼行數的龐大其實可能是一種肥胖症,它可能包含了大量的重複代碼,這或許傳遞了一個需要改進的信号。
我在做一個咨詢項目時,曾經利用 Sonar 工具對該項目中的一個子產品進行了代碼靜态 分析,如圖 4.2 所示。

圖 4.2
這個子產品的代碼行數達到了四十多萬行,其中重複代碼竟然達到了驚人的 33.9%,超 過一半的代碼檔案混入了重複代碼。顯然,這裡估算的代碼行數并沒有真實地展現軟體規模,相反,因為重複代碼的緣故,可能還額外增加了軟體的複雜度。
Neal Ford 在文章 Emergent design through metrics 中談到了如何通過名額來指導設計。文中提及的 iPlasma 是一個用于面向對象設計的品質評估平台,或許我們可以通過該工具的名額(見表 4.1)來找到評價軟體規模的要素。
表 4.1 iPlasma 的名額及說明
在面向對象設計的軟體項目裡,除了代碼行數,包、類、方法的數量,繼承的層次以及方法的調用數,還有常常提及的圈複雜度,都或多或少會影響到整個軟體系統的規模。
你去過迷宮嗎?相似而回旋繁複的結構使得本來封閉狹小的空間被魔法般地擴充為一個無限的空間,變得無窮大,仿佛這空間被安置了一個循環,倘若沒有找到正确的退出條件,循環就會無休無止,永遠無法退出。
許多規模較小卻格外複雜的軟體系統,就好似這樣一座迷宮。此時,結構成了決定系統複雜度的關鍵因素。
結構之是以變得複雜,多數情況下還是系統的品質屬性決定的。例如,我們需要滿足高性能、高并發的需求,就需要考慮在系統中引入緩存、并行處理、CDN、異步消息以及支援分區的可伸縮結構。倘若我們需要支援對海量資料的高效分析,就得考慮這些海量的資料該如何分布存儲,并如何有效地利用各個節點的記憶體與 CPU 資源執行運算。
從系統結構的視角看,單體架構一定比微服務架構更簡單,更便于掌控,正如單細胞生物比人體的生理結構要簡單數百倍一樣。
那麼,為何還有這麼多軟體組織開始清算自己的軟體資産,花費大量人力物力對現有的單體架構進行重構,走向微服務化呢?
究其主因, 不還是系統的品質屬性在作祟嗎? 縱觀軟體設計的曆史,不是分久必合,合久必分,而是不斷拆分、繼續拆分、持續拆分的微型化過程。
分解的軟體元素不可能單兵作戰。怎麼協同,怎麼通信,就成了系統分解後面臨的主要問題。如果沒有控制好,這些問題固有的複雜度甚至會在某些場景下超過因為分解給我們帶來的收益。如圖 4.3 所示,由于對系統進行了分解,各個子系統或子產品之間形成了複雜的通信網結構。
圖 4.3
要理清這種通信網結構的脈絡,就得弄清楚子系統之間的消息傳遞方式,明确消息格式的定義;同時,這種分布式的部署結構,在實作這些功能的同時,還必須額外考慮跨程序通信可能出現的異常場景,例如如何確定消息的可靠傳遞,如何保證資料結果的一緻性。換言之,系統因為結構的繁複而增加了複雜度。
基于 CAP 理論,微服務這種分布式架構在滿足 A(Availability)與 P(Partition Toralence) 的前提下,至少要保證資料的最終一緻性,即系統中的所有資料副本經過一定時間後,最終能夠達到一緻的狀态。
分布式架構的通信特點讓我們必須要認為網絡通信是不可靠的,這就導緻在實作一緻性上,微服務比傳統的單體架構要複雜得多。假如采用補償模式來實作資料的最終一緻性, 就需要引入一個額外的協調服務,它負責協調各個需要保證一緻性的微服務,其職責為協調服務并按順序調用各個微服務,如果某個微服務調用異常(包括業務異常和技術異常), 就取消之前所有已經調用成功的微服務。同時,還需要考慮取消操作也可能失敗的情況, 即補償過程本身也需要滿足最終一緻性,這就要求在服務調用出現異常後,取消服務至少要被調用一次,而取消服務操作本身則必須是幂等的。
為了實作補償模式,我們需要記錄每次業務操作,同時還要确定失敗的步驟與狀态, 以便于定位補償的範圍。為了提高正常業務操作的成功率,還需要在設計時考慮引入重試 機制。服務執行失敗的原因各有不同,重試機制也需要提供與之對應的政策。例如對于系 統繁忙的異常,我們應采用等待重試機制;對于一些出現機率非常小的罕見異常,可以考慮立刻重試;如果失敗原因是由于某種業務原因導緻的,那麼即使重試也不可能保證操作成功,應采取終止重試政策。顯然,這些機制都會因為微服務的分解而帶來設計上的額外成本,它必然會導緻整個系統的結構變得更加複雜。有得必有失,軟體世界的自然規律其實是公平的。
在考慮微服務設計時,業界普遍認為服務分解與組織結構要保持一緻,即遵循康威定律:
任何組織在設計一套系統(廣義概念上的系統)時,所傳遞的設計方案在結構上都與該組織的溝通結構保持一緻。
Sam Newman 認為是“适應溝通路徑”使康威原則在軟體結構與組織結構中生效 1 的。他分析了一種典型的分處異地的分布式團隊,整個團隊共享單個服務的代碼所有權。由于分布式團隊的地域和時區界限,使得溝通成本變高,團隊之間隻能進行粗粒度的溝通。當協調變化的成本增加後,人們就會想方設法降低協調/溝通成本。直截了當的做法就是分解代碼,配置設定代碼所有權,分處異地的團隊各自負責一部分代碼庫,進而更容易地修改代碼。團隊之間會有更多關于如何內建兩部分代碼的粗粒度的溝通,最終,與組織結構内的溝通路徑比對所形成的粗粒度 API 形成了代碼庫中兩部分之間的邊界。
注意,比對設計方案的團隊是負責開發的團隊,而非使用軟體産品的客戶團隊。在軟體開發中,常常會遇見分布式的客戶團隊,例如不同的部門會在不同的地理位置,他們的使用場景也不盡相同,甚至使用者的角色也不相同,但在對軟體系統進行架構設計時,卻不能想當然地按照使用者角色、地理位置或部門組織來分解子產品(服務),并以為這遵循了康威定律。設計人員錯誤地把客戶的組織結構視為系統子產品(服務)的分解依據。
我曾經參與過一款通信産品的改進與維護工作。這款産品為通信營運商提供對寬帶網的授權、認證與計費工作。該産品的終端使用者主要有兩種角色:營業廳的營業員與購買寬帶網服務的消費者。
該産品的最初設計就自然而然地按照這兩種不同的角色劃分為背景管理系統與服務門戶兩個完全獨立的子系統,而在這兩個子系統中都存在資費套餐管理、客戶資訊維護等業務。
這種不合理的軟體系統結構劃分,屬于典型的職責配置設定不合理,不僅會産生大量重複代碼,還會因為結構失當而帶來許多不必要的通信與內建,增加軟體系統的複雜度。
國際報稅系統的架構演進
在我參與的一個國際報稅系統中,就根據使用者的角色進行了系統分解。針對報稅人, 設計了 Front End 子產品提供報稅等終端業務,而 Office End 子產品則面向業務人員和系統管理者,如圖 4.4 所示。
圖 4.4
随着需求增多,功能越來越複雜,系統各個子產品的邊界開始變得越來越模糊,形成了一個邏輯散亂的龐大代碼庫。重複代碼與重複資料俯拾皆是,而 Front End 與 Office End 之間的內建也非常複雜。負責開發這兩個子產品的團隊雖然屬于同一個項目組,但團隊之間存在極大的技術和業務壁壘,團隊成員對整個系統缺乏整體認識,知識沒有能夠在團隊之間傳遞起來。
當通過引入 Bounded Context 來劃分子產品的邊界,建立公開統一的 REST 服務後,遵循康威定律為分解開的服務建立特性團隊(Feature Team)就演變為順其自然的結果。整個系統中各個服務的重用性和可擴充性得到了更好的保障,服務與 UI 之間的內建也變得更加簡單。整個架構清晰可見,如圖 4.5 所示。
圖 4.5
無論是優雅的設計,還是拙劣的設計,都可能因為某種設計權衡而導緻系統結構變得複雜。唯一的差別在于前者是主動地控制結構的複雜度,而後者帶來的複雜度是偶發的,是錯誤的滋生,是一種技術債,它可能會随着系統規模的增大而導緻一種無序設計。
在 Pete Goodliffe 講述的“兩個系統的故事:現代軟體神話” 中詳細地羅列了無序設計系統的幾種警告信号:
代碼中沒有顯而易見的進入系統中的路徑。 不存在一緻性,不存在風格,也沒有統一的概念能夠将不同的部分組織在一起。 系統中的控制流讓人覺得不舒服,無法預測。 系統中有太多的“壞味道”,整個代碼庫散發着腐爛的氣味,是在大熱天裡散發着刺激氣體的一個垃圾堆。 資料很少被放在使用它的地方。 經常引入額外的巴羅克式緩存層,目的是試圖讓資料停留在更友善的地方。看一個設計無序的軟體系統,就好像隔着一層半透明的玻璃觀察事物一般,系統中的軟體元素都變得模糊不清,充斥着各種技術債。細節層面,代碼污濁不堪,違背了“高内 聚、松耦合”的設計原則,導緻許多代碼要麼放錯了位置,要麼出現重複的代碼塊;架構層面缺乏清晰的邊界,各種通信與調用依賴糾纏在一起,同一問題域的解決方案各式各樣, 讓人眼花缭亂,仿佛進入了沒有規則的無序社會。
我曾經為一個制造業客戶開發的業務工具項目提供架構與代碼評審的咨詢服務。當時,該工具産品的代碼庫隻有不到三萬六千行代碼,是一個簡單的基于 ASP.NET 開發的 BS (Brower/Server)架構系統。雖然項目規模并不大,但是在經曆了約半年的開發周期後,項 目品質與傳遞周期都不能得到足夠的保證。在之前傳遞的版本中,位于歐洲的銷售代表普遍對這個工具不滿意,是以客戶希望我們能夠在技術層面上提供一些咨詢建議。
該工具産品的開發存在諸多問題,例如在領域層充斥着大量的貧血對象,對架構的強依賴導緻“供應商鎖定”,在技術選型上也多有不當之處。但最大的問題還是系統缺乏清晰的邊界,如圖 4.6 所示。
圖 4.6
架構師雖然采用了經典的三層分層架構模式對關注點進行分離,卻沒有很明确地勾勒出各個分層的明确職責,開發人員也沒有按照這種分層架構來配置設定職責,在本來應該是視 圖呈現的代碼中混入了許多領域邏輯,進而導緻 UI 層越來越臃腫。而在領域層,卻又不恰當地滲入了對 ASP.NET UI 元件的處理邏輯。該産品代碼庫存在的另一個問題是缺乏一緻性。例如針對資料庫的通路,産品竟然提供了如下三種不同的解決方案。
Utils 通路方式,其代碼如圖 4.7 所示。
圖 4.7
DbHelper 通路方式,其代碼如圖 4.8 所示。
圖 4.8
ORM 通路方式,其代碼如圖 4.9 所示。
圖 4.9
顯然,選擇這三種迥然不同的通路方式并非出于技術原因,又或者受到某個品質屬性的限制,而是在設計時沒有做到統一的規劃,開發人員率性而為,内心會自然而然地選擇自己最熟悉、實作成本最低的技術方案,進而導緻通路資料庫的解決方案不一緻。
我們之是以不能預測未來,是因為未來總會出現不可預測的變化。這種不可預測性帶來的複雜度使得我們産生畏懼,因為不知道何時會發生變化,變化的方向又會走向哪裡, 是以導緻心裡滋生一種仿若失重一般的感覺。變化讓事物失去控制,受到事物牽扯的我們便會感到惶恐不安。
在設計軟體系統時,變化讓我們患得患失,不知道如何把握系統設計的度。若拒絕對變化做出理智的預測,那麼系統的設計會變得僵化,一旦變化發生,修改的成本就會非常大;若過于看重變化産生的影響,渴望涵蓋一切變化的可能,則一旦預期的變化不曾發生, 我們之前為變化付出的成本就再也補償不回來了。
從需求的角度講,變化可能來自業務需求,也可能來自品質屬性,而以對系統架構的 影響而言,尤以後者為甚,因為它可能牽涉整個基礎架構的變更。George Fairbanks 在《恰如其分的軟體架構》一書中介紹了郵件托管服務公司 RackSpace 的日志架構變遷,雖然業務功能沒有任何變化,但是郵件數量卻持續增長,為了滿足性能需求,架構經曆了三個完全不同的系統變遷:從最初的本地日志檔案,到中央資料庫,再到基于 HDFS 的分布式存儲,整個系統幾乎發生了颠覆性的變化。這并非 RackSpace 的架構設計師欠缺設計能力, 而是在公司草創之初,他們沒有能夠高瞻遠矚地預見到客戶數量的增長,導緻日志資料增多,以至于超出了已有系統支援的能力範圍。
俗話說“事後諸葛亮”,當對一個軟體系統的架構設計進行複盤時,總會發現許多設計決策是如此愚昧。殊不知這并非愚昧,而是在設計之初,我們手中掌握的籌碼不足以讓自己赢下這場面對未來的戰争罷了。這就是變化之殇!(未完待續)
本文節選自中生代技術社群出版架構圖書:架構寶典,根據篇幅略有删節