實用的軟件架構方法

導讀

軟件架構就是軟件的基本結構,它是有關軟件整體結構與組件的抽象描述,用於指導大型軟件系統各個方面的設計。軟件架構是一個系統的草圖。軟件架構描述的對象是直接構成系統的抽象組件。各個組件之間的連接則明確和相對細緻地描述組件之間的通訊。在實現階段,這些抽象組件被細化爲實際的組件,比如具體某個類或者對象。在面向對象領域中,組件之間的連接通常用接口來實現。由此可見,軟件架構的意義非常重大。那麼,實用的軟件方法都有哪些呢?

本文最初發表於 Medium 博客,經原作者 Eugene Ghanizadeh 授權,由 InfoQ 中文站翻譯並分享。

前言

對代碼庫而言,架構通常是最重要的方面之一。架構對代碼庫質量、可維護性和可靠性都有着重要的影響。這也是軟件工程中最有爭議的一個話題,往往會激起項目貢獻者之間的激烈爭論,這些爭論似乎沒有任何潛在的邏輯解決方案,比如“對我們當前項目來說,什麼纔是好的架構?”這樣的問題,很多時候似乎並沒有一個明確的答案。

如果你去詢問經驗豐富的軟件工程師“什麼是好的軟件架構?”這種一般性的問題,也許你會聽到這些話:

好的軟件架構是簡單而優雅的。

(這一普遍的回答,太過含糊,過於主觀,並不能作爲關於該問題的客觀討論和決策的適當依據。)

好的軟件架構,增加了可維護性。

(這個答案稍好一些,但仍然無助於回答這個問題:“如何衡量可維護性,除了一兩年內聽到的從事架構設計的程序員說,這個架構設計要麼容易維護,要麼難以維護?”。)

好的軟件架構具有高內聚性和鬆散耦合。

(這是一個更好的答案,因爲你甚至可以精確地度量耦合,但耦合仍然是一個不可避免的現象,這並不能爲你提供任何線索,比如多少耦合對你的情況來說是可接受的,或者多少內聚纔算內聚過多。)

好的軟件架構正確地結合了已建立的設計模式 / 很好地利用這個或那個範例 / 等等。

(是的,已經建立的實踐、範例和模式確實有助於提出更好的設計,但我如何才能知道哪種範例最適合項目呢?或者我應該在多大程度上堅持一個特定的模式呢?)

所有這些答案要麼過於主觀,要麼僅僅只是提出了一些更客觀的工具,如範例、模式和度量標準,而沒有任何客觀指標來說明應該如何使用這些工具。在“什麼是好的架構”這個核心問題上存在着如此多的主觀性,因此,關於如何爲項目設計好的架構的討論很容易導致主觀的矛盾,而這些矛盾可能永遠不會得到客觀的解決,也就不足爲奇了。

然而,如果我們後退一步,看看軟件架構實際上是什麼,它扮演着什麼角色,使它成爲軟件開發中不可分割的一部分,也許我們可以指定更具體的、可量化的度量標準,以此作爲我們評估各種架構選擇和設計的基礎。

什麼是軟件架構?

一般來說,特定代碼庫或項目的架構是隱式和顯式的規則和約定,指導如何設計每個組件,以及如何與其他組件通信。這些規則可以影響到項目的任何方面,從它所基於的範例或框架,或者它被分解到的抽象層,到命名約定、文件夾和模塊的結構,等等。它會影響哪些代碼可以知道哪些其他代碼元素的存在,或者這些元素應該在何時以何種方式相互通訊。

目的是什麼?

爲了理解軟件結構的目的,我們可以想象,如果項目沒有軟件架構的話會發生什麼情況。假設一個項目沒有任何一致同意的規則或約定的話,會怎麼樣?這些規則或約定用於建模和創建各種組件,或者指定它們之間的通訊方式。

如果有許多人要並行處理這個項目,但由於缺乏一致同意的約定和規則,每個人就會使用自己的約定和思維模式來編寫自己的元素。如此一來,會導致不同的風格、模型、模式和 API,這不僅使貢獻者更難處理另一個貢獻者的代碼,而且也使不同的元素和組件更難相互通訊,大大增加了開發時間,不管是在初始階段還是後來的變更和迭代開發。

所以,基本上就是一致性?

並非如此,這裏還有好的架構和差的架構之間的區別。讓我們想象一個具有特定架構的特定代碼庫。隨着時間的推移,項目將會面臨一些可能無法預料的變更和迭代,並且,面對任何這樣的變化,會發生以下情況之一(或者多種組合):

  1. 該架構完全符合預期的更改,甚至沒有指導性的更改。一切都很好,沒什麼可看的。
  2. 該架構沒有說明任何預期更改的內容。有時候,開發人員可能會與同事討論、決定和交流架構中必要的補充,而在其他時候,(例如,可能由於某個截止日期),他們可能只是根據與已建立的架構不一致的模型和指導方針來實現更改,從而導致代碼庫一致性的下降。
  3. 開發人員認爲架構與預期的更改之間存在衝突,例如,因爲它增加了大量的開銷,或者由於變更本身非常簡單而直接,從而使架構變得非常混亂。他們可能會選擇尊重架構,並以更高的成本來實現更改,或者可能會選擇打破原有模式,而這反過來又會引起代碼庫一致性的急劇下降。

在其中兩個情況中,很有可能在更改之後,代碼庫的一致性會降低,並且隨着時間的推移,這種不一致性還會增加,以至於看起來像是項目一開始就沒有采用架構設計的樣子。從實用主義的角度來看,我們現在可以將主要符合第一種情況的架構稱爲好的架構,而將導致第二種和第三種情況的架構,稱爲差的架構。

衡量好的架構

因此,根據我們前面討論的內容,一個好的架構基本上應該:

  1. 儘可能多地考慮未來的變化。
  2. 讓這些更改對開發人員來說更容易,而不是更困難。

當然,第一個並不是一個真正可以衡量的指標,但它確實爲我們提供了一個切實可行的方向。試着設想未來可能的變化,例如,設想項目的未來階段,堆棧的某些部分可能會因爲外部需求而被替換掉,等等,並思考我們的架構決策和設計將如何面對這些變化。

但是,第二個則帶來了更多可量化的指標。從表面上看,“容易”和“困難”似乎都是主觀的術語,沒有合適的方法來衡量它們。然而,任何代碼庫的改變最終都是人(程序員)和計算機(鍵盤)交互的結果,幸運的是,我們已經有一個專門用於測量這種交互難易程度的計算機科學領域,這個領域稱爲人機交互(Human-Computer Interaction,HCI),這個名稱真是恰如其分。

對於那些不熟悉該領域的人來說,人機交互是一個致力於儘可能量化的方式分析人機交互各個方面的領域。在這些方面中,最重要的是難度(更確切地說,是任何給定交互的難度指數)。對於許多基本的交互,我們能夠計算這個指數,事實上我們已經這樣做了,這就是爲什麼窗口會有“最小化”、“最大化”、“關閉”的按鈕,如果你正在電腦上閱讀本文的話,你會在屏幕的角落看到這些按鈕(基本上,角落會導致與難度指數成反比關係的參數大幅增加)。非常方便的是,難度指數也與執行任務所需的時間(一系列人機交互)成線性關係,在我們的用例中,這可以轉化爲任務本身的字面成本(字面意義上的金錢成本)

這一切意味着什麼?簡單地說:

好的架構降低了未來更改的成本。

如何評估特定架構決策的成本?首先,想象一下你可能要進行的一些更改,例如,你可能希望在上面提到的項目的下一階段添加特性,或者甚至對一些隨機選取的方法 / 函數 / 類的簽名進行隨機更改。然後評估實現這一更改可能包括的一系列交互,從真正基本的交互(例如需要鍵入多少字符),到開發人員可能需要查看代碼的其他部分,再到某些特定概念或函數的可理解性。你可以更精確地估計底層級別的交互難度(使用一些基本的人機交互規則),或者根據它們的難度假定任意的常數,然後大致估計更高級別的交互難度(或許要去掉那些過於抽象的交互),那麼你就可以很好地估算出這些潛在變化的難度(以及成本)了。

實踐起來是什麼樣子的?

讓我們舉一個簡化的例子,將這種方法付諸實踐。爲簡單起見,讓我們假設開發人員不能訪問任何特定的 IDE(特別是使用跨文件搜索工具或其他重構工具)。假設這個項目的結構如下:

src/
| -- module-a/
| -- | -- index.ts
| -- | -- | -- function aOne()
| -- | -- | -- function aTwo()
| -- | -- a-one.ts
| -- | -- | -- function aOneFuncOne()
| -- | -- | -- function aOneFuncTwo()
| -- | -- a-two.ts
| -- | -- | -- function aTwoFuncOne()
| -- | -- | -- function aTwoFuncTwo()
| -- module-b/
| -- | -- index.ts
| -- | -- | -- function bOne()
| -- | -- | -- function bTwo()
| -- | -- b-one.ts
| -- | -- | -- function bOneFuncOne()
| -- | -- | -- function bOneFuncTwo()
| -- | -- b-two.ts
| -- | -- | -- function bTwoFuncOne()
| -- | -- | -- function bTwoFuncTwo()

現在,假設更改 src/module-a/a-one/aOneFuncOne() 的名稱或簽名。如果沒有任何既定規則的話,我就需要檢查其他 11 個函數的主體,看看它們是否使用了 aOneFuncOne() ,以及它們是否需要更改。請注意,與 src/module-b/b-one 的函數相比,src/module-b/b-one 函數的檢查難度並不相同,因爲 a-one.ts 函數已經與 aOneFuncOne() 在同一個文件中。列出交互作用,對於前者我惟有如此做:

  • 讀取 aOneFuncTwo() 的主體,看看是否需要更改,如果需要的話,就執行更改。

對於後者,交互列表如下所示:

  • 列出 src/ 的所有子模塊。
  • 列出 src/module-b/ 的所有文件。
  • 打開 src/module-b/b-one.ts
  • 讀取 bOneFuncOne()bOneFuncTwo() 的主體,看看是否需要更改,如果需要,就執行更改。

這些交互都需要一些時間(以及認知上的努力),因爲它們每個交互都至少涉及一次點擊(或許還需要一些滾動和一些鍵入)。類似地,檢查 src/module-a 中的其他文件,要比檢查 src/module-b 中的文件更容易,因爲它需要更少的原始交互,儘管這比只檢查 src/module-a/a-one.ts 中的其他函數更困難,原因是需要更多的交互。

現在,如果架構強制執行 src/module-a 以外的模塊中的文件,只能使用 src/module-a/index.ts 中定義的函數的規則,這樣更改的難度就大爲降低了。當然,這樣做的缺點是,如果有一天,我需要在 module-b 中的某個地方使用 aOneFuncOne(),那麼我還需要更改 module-a/index.ts 以符合該規則,這一開銷的概率和交互成本我們可以再次非常精確地估計。

我甚至可以更進一步,要求 src/module-a/a-one.ts 顯式地提到它導出到其他模塊的函數(因爲 TypeScript 需要你這樣做,而不管你的架構設計如何),然後我就可以檢查 aOneFuncOne() 是否爲導出的函數,如果不是,那麼進行更改的成本也會大大降低。

類似地,如果其他文件必須通過顯式導入語句顯式地導入 aOneFuncOne()(即 import { aOneFuncOne } from 'src/module-a/a-one ),那麼檢查需要更改哪些函數就會容易得多。現在,如果我在開始時有另一個(未提到的文件)帶有導入命令,其中有 10 個其他函數,但實際上只有一個使用了 aOneFuncOne(),我最好將這個異常值放到它自己的文件中,因爲這樣可以減少檢測和再次進行更改所需的交互次數。

請注意,將我所有 12 個函數全部放在 src/module-a/a-one.ts 中也不是一個好主意,儘管此舉可以降低檢查所有其他函數的難度,但對於檢查幾個其他函數主體來說,它仍然是一個低於標準的解決方案。

考慮到所有這些因素,我們現在就可以在決定兩個架構設計之間做出更爲客觀的判斷,取決於我的函數在每個設計中的相互依賴程度,以及這些相互依賴的函數在結構上有多近或多遠。請注意,這只是另一種說法,即我們應該選擇更有內聚性和鬆散耦合的架構,但是這一次,我們有更爲具體的度量標準來評估我們所討論的內聚性或鬆散耦合的程度。

可讀性 / 直覺性如何?

在我們的示例中,忽略了計算某些交互難度的一個關鍵因素:實際上,閱讀和導航某些代碼的難度並不是恆定不變的。相反,它受到代碼可讀性(局部範圍)和架構底層模型的直覺性(在較大的範圍內)的極大影響。一堆隨機的字符,更難以閱讀,從中找出內容也很困難,而且,這種缺乏直覺性的結構,會將導航代碼庫的交互變成一種盲目且極易出錯的蠻力行爲,而不是通過對底層模型的直觀理解來實現輕鬆快速的導航。

幸運的是,出於可讀性的考慮,有很多關於如何衡量和改進它們的分析研究,這些都很不錯。還有大量更通用的人機交互研究,這些研究在這方面可能很有幫助(有些甚至對找出最有效率的編輯器顏色主題很有用)。

直覺看起來似乎更加難以捉摸,因爲它肯定更主觀。然而,我們只需考慮“架構有意義”中的直覺性,這樣就可以降低更改的成本,這反過來意味着“架構對將要進行更改的人來說是有意義的”。除此之外,我們還知道,如果(幾乎但不總是)某些事與他們已經知道的和 / 或經歷過的有關,並且更多地考慮重複的經歷或更近的經歷,那麼對於某些人來說(或者至少需要他們花費更少的時間和精力去理解它)更有意義。結合這兩個事實,我們對直觀的架構設計就有了更客觀的看法:

一個架構,如果更接近於你團隊已熟悉和有經驗的架構類型,那麼這個架構對他們來說更直觀。

請注意,這個事實透露出來的含義是,對於特定項目來說,最好的架構設計和選擇,不僅由項目本身決定,還由將要進行該項目的團隊決定。如果你選擇的架構風格具有明顯的學習曲線(著名的 MVC 就是其中一個著名的例子),那麼對於已經熟悉該風格的團隊來說,這可能是一個非常好的選擇;但對於沒有這種經驗的團隊來說,就不太合適了。此外,如果未來的團隊組成,從有經驗的人員變成幾乎沒有經驗的人員,那麼就必須相應地考慮重構整個架構的成本,與團隊中每個新成員的學習成本相比較。

結論

這個例子太簡單了,現實中能行麼?

儘管我們的示例在本質上是相對抽象和簡單的,但它涉及了軟件架構的最基本方面之一:耦合和內聚。更詳細和複雜的示例,大多數都可以歸結爲同樣的考慮和優化(以及問題域模型的直覺性)。例如,MVC 架構將代碼庫分爲三層,基於這樣一個假設,即同一層(例如視圖)比其他層(如模型層)中的代碼更依賴於同一層中的其他代碼(其他視圖邏輯)。例如,當視圖邏輯高度依賴於某個渲染框架 / 庫,而不是依賴於控制器層邏輯時,這種分離就是正確的,並且這種分離大大降低了更改的成本,例如換出渲染框架 / 庫(這就是爲什麼在當時是一種比較流行的模式,這種變化更有可能發生的原因)。

這是否意味着我不再需要依賴已建立的範例和模式?

不是這樣的。這是一種客觀和定量地分析各種架構設計 / 決策 / 甚至模式和範例的方法,而不是提出它們。如果軟件的架構是問題所在(“我應該使用哪些規則來設計代碼庫,以減少未來變更的成本?”),然後,各種架構範例和模式爲這個問題提供瞭解決方案 / 解決方案模板,前面提到的難度度量方法讓你可以分析、比較這些解決方案。

此外,已建立的概念、模式和範例都是基於相同的考慮(直接或間接)設計的解決方案。如前所述,MVC(或任何其他基於層的架構)傾向於包含對特定層的最可能的更改,以降低其潛在的成本,正如我們上面所看到的,內聚性和耦合基本上是從變更難度優化產生的。但是,這種方法允許你正確地驗證那些模式和範例最適用於你的案例,那些模式和範例最終可能會造成更多的開銷。

我意識到,說到底,軟件架構仍然是軟件工程師們熱議的話題。圍繞關於什麼是理想的軟件架構這一問題,狂熱分子們有着自己的信念,而不是認爲程序員不分享這些觀點,就不配程序員的頭街。甚至在更靈活的工程師中,也有這樣的觀點:對於每一個問題(因此就是項目),都有一個獨一無二、精美優雅、藝術品級的架構設計,它是未來的證明,直到世界末日也是如此。但我擔心,在大多數情況下,這些激進的想法是源於這樣一個事實,即人們沒有接觸到爲什麼需要好的項目架構的實際原因,更不用說這意味着什麼了。但是,如果我們拋開這些不合理的偏見,以最超然和務實的態度來看待軟件架構,一切都會變得非常清楚:

對那些進行更改的人們來說,好的架構可以降低未來變更的成本。

作者介紹:

Eugene Ghanizadeh,擁有多重角色:程序員、設計師、產品經理,甚至在某個時候還是人力資源專家。也曾經當過老師。https://github.com/loreanvictor

原文鏈接:
A Pragmatic Approach to Software Architecture

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章