[漫談] 軟體設計的目標和途徑
阿新 • • 發佈:2020-09-02
記錄一下筆者關於軟體設計的一些相關認知。在開始之前,先引入兩個概念`目標`和`途徑`(這裡可能會有些咬文嚼字,不過主要是為了區分主觀和客觀的一些細微差異)。
# 1 目標和途徑
我們在做某一件事情的時候,總是會帶有一定的目的性的:比如說一日三餐,是為了給身體補充所需的能量。那麼這三餐具體如何落實呢,則會有多種多樣的方式。比如你可以選擇吃碳水食物、蔬菜、肉類、牛奶或者蛋類等等;也可以選擇通過靜脈注射一些所需的葡萄糖或者蛋白質。總之,能夠為身體補充能量就可以了。
## 1.1 目標
那麼在上述的小例子中,我們的`目的`就是給身體補充能量,用以維持正常的生命活動所需。當然也可以說是我們的`目標`,不過`目標`側重於過程,目的則更強調結果。
## 1.2 途徑
從上面的例子中可以看出有多種方式可以達成我們的上述`目的`。其中每一種方式都是一條達成`目的`的`途徑`,當然我們為了補充均衡的能量,通常會搭配組合幾種不同的食物,我把這個稱之為手段或者方法。`手段`和`方法`帶有一定的主觀性;而`途徑`則是在描述客觀的可供選擇的一種方式。
# 2 軟體的目的
在開始討論軟體設計之前先問自己一個最基本的問題:我們為什麼需要軟體?
筆者認為是為了解決現實中某個領域的相關問題而存在的。就好比最初的計算機是用來計算導彈的彈道的。生活中常用的QQ和微信是為了滿足人們的社交通訊需求的,淘寶京東等是滿足了人們的買買買的需求。
**所以,軟體存在的目的就是它能解決一些領域的相關問題,這是它存在的唯一理由**。
> 比如在黑客帝國這部電影中,不再被使用的程式只有一個下場,那就是被刪除掉。
# 3 軟體設計的目標
假如一開始就有了軟體,其實要不要軟體設計都不重要了。但是問題在於軟體不是憑空產生的,不是從0到1沒有中間過程就直接得到了想要的軟體的。**在軟體從0到1的過程,就是軟體設計的作用範圍(所以在這裡我用軟體設計的目標這個概念)**。因為軟體存在的目的在於它能解決一些領域的相關問題,那麼首先對軟體的最低要求就是它能用,能用來解決問題。比如一個數學上的加減乘除計算器,最低最低的要求是你要能把結果算對吧。所以軟體設計的目標是什麼?筆者認為就是控制這個從0到1的過程,避免其失控(一旦失控你可能就連最低最低的軟體的要求都達不到了)。
>[《領域驅動設計:軟體核心複雜性應對之道》](https://book.douban.com/subject/5344973/)一書的副標題也是這個含義。它的側重點在於如何利用面向物件的方式應對軟體本身的複雜性,從而避免其失控。
**那麼筆者對軟體設計的目標的認知就是:避免軟體的失控。為什麼是目標而不是目的呢?是因為軟體設計在軟體的整個生命週期中都是存在著的,這是一個持續的過程,直到軟體不再被使用的那一天;而非只在剛開始設計一下,後續就一成不變了**。
# 4 失控的根本原因
上面推匯出軟體設計的目標是**避免軟體的失控**。那麼是什麼東西導致的**失控**? 你面臨的業務太複雜?專案遺留的程式碼太爛?團隊成員水平參差不齊?工期太緊張導致你無暇做設計規劃?也許吧,這些或多或少都確實是已經存在的事實。
1. 業務太複雜難道是失控的原因嗎?回想一下**軟體的目的**是什麼?**解決一些領域的相關問題**,那麼我們可以讓業務的複雜性會消失或者降低嗎?答案是肯定的,**不會**!這裡就有人要說你放屁。。。你敢說我們無法降低業務複雜性,打你噢。你就是打死我複雜性也不會降低的,,,**複雜性是業務本身存在的客觀屬性,是不會以人的意志來改變的,除非你不做它了**。就像你現在要在淘寶買一個手機,你人在北京,賣方在廣州,無論你用什麼快遞方式,從廣州到北京這段物理距離上的時間消耗是無法消除的。你說你比較著急,那好,賣方給你選擇空運,很快你就收到貨了。你說空運這不是降低了快遞時間,和降低複雜性不是一樣的嗎? 其實並不是,因為複雜性指的是**無論你用什麼快遞方式,從廣州到北京這段物理距離上的時間消耗是無法消除的**,指的是這個過程你無法消除。但是總覺得怪怪的對嗎?是的,看起來是怪怪的,明明我收到貨的時間縮短了,怎麼複雜性沒有改變呢?所以這裡就引申出另外一個概念:**業務互動方式所帶來的影響**。這個影響非常之大,但是往往被我們所忽略,比如你選擇購買發貨地是北京的賣方了,是不是時間又進一步大大縮短了?**實際業務上也是這樣的,業務本身具備的複雜性,以及我們在把業務轉化為軟體後的互動方式所帶來的影響,業務本身的複雜性我們無法降低和消除,但是後者互動方式則是可以控制的,這也是軟體設計的一部分,所以其實上面我們選擇空運是改變了這部分**。就好比你是一個B/S的應用軟體,你的使用者在瀏覽器中看到了Web頁面。這背後你的Web頁面從伺服器到使用者瀏覽器的過程和瀏覽器渲染頁面的過程是無論如何也無法消除的,但是瀏覽器可以快取它,當你下次再開啟這個頁面時,它就可以省掉上述的互動過程。
2. 專案遺留的程式碼太爛是失控的原因嗎?其實也不是,這是失控的一種表現結果。
3. 團隊成員水平參差不齊是失控的原因嗎?也不是,這雖然是客觀存在的事實,但是你這樣把責任推到隊友身上不合適吧,說不定隊友也是這麼看你的呢。
4. 工期太緊張導致你無暇做設計規劃是失控的原因嗎? 當然也不是,這個是藉口。。。就像你今天起床快要遲到了,你會選擇光屁股不穿衣服就出門嗎?
除了上述的一些事實,當然還有其他的一些因素,**看起來都不像是導致失控的罪魁禍首**。那麼究竟是什麼導致的失控???仔細回想一下,當我們覺得專案失控的時候通常是什麼場景?
1. 有個已知的bug,你改動的時候發現牽扯的東西太多了,牽一髮而動全身,你不敢下手。你覺得程式碼無法控制了。。。
2. 有個未知的bug,你找了好久找不到,程式碼太亂了。你覺得一股無力感。。。
3. 有個新功能來了,你發現你要改這裡那裡,但是完全不知道改了會不會破壞現有的功能,也不知道新功能是不是真的可以work。你覺得你無法掌控這些程式碼了。。。
4. 還有一些其他的情況,總之就是你覺得你無法掌控程式碼的真實行為了,你不知道你的程式碼會產生什麼樣的結果,就像薛定諤的程式碼一樣。。。
那麼還有一個場景,當你要開展一個新的專案,所有的一切都是新的,沒有任何歷史債務負擔,這時候你是什麼感覺?信心滿滿啊肯定是,這時候你不會覺得你會對接下來的程式碼失去控制,因為你現在一行程式碼都還沒有。。。
所以是什麼導致的失控?**現存的無力維護(bug、新功能都是維護)的程式碼導致的失控**,同時這也是失控的表現結果。那麼你為什麼會**無力維護**這些程式碼,因為它的真實行為和你理解的行為出現了偏差,你覺得它不可控了。這時候就是真的失控了,程式碼爛不爛其實並不是重點,只要你還能維護,這些都不是問題。
> 程式碼只會按照你編寫的行為去執行,而不是按照你認為的行為去執行。
那麼如何避免失控?**編寫可維護的程式碼**。打死你噢,解釋這麼半天憋出這麼一句廢話,誰不知道要編寫可維護的程式碼啊。。。
我只能說彆著急,繼續慢慢往下看。。。
# 5 目標-可維護性
既然我們的目標是避免失控,避免失控的途徑則是**編寫可維護的程式碼**。那麼我就把**可維護性**作為軟體設計的終極目標,而且沒有之一。也稱之為**元原則**,就是說我們目前所接觸到的各自程式設計原則、建議和最佳實踐等等都可以通過**可維護性**推導細化出來,並且不可與之相違背。
> 打個比喻,就好比憲法是其他一切法律的基礎,任何法律如果違背了憲法,那麼就是無效的。
那麼根據**可維護性**可推匯出來3個核心的原則:**可理解性**、**可測試性**和**可隔離性**。
## 5.1 可理解性
這條原則看起來很有主觀性的傾向,但是其實並不是。
比如說你剛寫了一段程式碼,你覺得容易理解,他看起不容易理解;或者說程式碼是他寫的,他看起來很容易理解,但是到你這裡無法一下子理解他的思維,然後你就覺得不好理解。**如果出現了這樣的情況,那麼則統統都是不可理解的**。這時候你要說了:你要一棍子打死雙方啊。是的,正是如此。再回想一下我們的目標是什麼?**可維護性!** 這裡的維護不單單是說你的程式碼你來維護,而是大家互相交叉著;你新增了一個功能,後續負責其他的事情去了,那麼這時候就由你的隊友來負責維護了;或者你接手維護別人的程式碼。
所以我們需要一個客觀上的**可理解性**。那麼到底什麼才能叫客觀?沒法度量啊!其實也不復雜,就是看當你讀到一段程式碼的時候,你是否需要額外的思考,額外的腦中維持一個上下文的環境才能明白這段程式碼的意圖,如果需要,那麼就是不可理解的,至少也是不易理解的。**更簡單點說就是這段程式碼應該讓你不用思考就看的明白它的意圖**。比如下面的一個小例子,功能是完全等價的,但是差異非常微妙。
```js
// 1
if(userList.isNotEmpty()){
}
// 2
if(userList.isEmpty() == false){
}
// 3
if(!userList.isEmpty()){
}
// 4
if(userList.length() != 0){
}
```
你覺得可理解性怎麼排? 答案是肯定的吧?`1 > 2 > 3 > 4`。
1. 1是不是你根本就不用思考,直接讀下來就知道其含義?
2. 2則是有一個`==fasle`的過程,需要你進行簡單的思考。
3. 3則是接近於2,但是比2更差一點,因為取反符號在前面,但是其決定性的值則在後面,而你的閱讀順序是從左向右,所以你需要一個比2稍微更復雜一點的思考過程。
4. 前三個還都一眼能看出來是**空**或者**非空**的語境,但是4就更差了,4的字面意思是長度不等於0,邏輯上其實和**非空**是等價的,但是你需要在腦中做這樣的一個對映**長度!=0**等同於**非空**,這個的抽象層級明顯更低了一個層級。
不知道能否體會其中差細微差異。那麼你覺得這些理解是客觀的還是主觀的呢?
## 5.2 可測試性
可理解性可以確保你可以快速的理解現存程式碼的意圖,但是其真實的行為呢?是不是和你所認為的行為就是一致的?上面我說過:“**程式碼只會按照你編寫的行為去執行,而不是按照你認為的行為去執行**”。
那麼如何確保你真實的行為和你所認為的行為是一致的?那就是**測試**。把你認為的行為也寫成程式碼,去驗證你的業務程式碼執行的時候是不是會按照你給定的輸入得到你期望的輸出結果。藉助自動化的CI,就可以在你每次改動程式碼時把現有的所有測試都執行一遍,然後你至少可以獲得3點收益:
1. 程式碼真的時按照你認為的行為去執行的。
2. 確保你的改動不會破壞現有的程式碼行為。
3. 倒逼你的程式碼進行合理的分解和抽象,不然你很難編寫有效的測試。
當然你可能把測試寫錯了,,,這種概率就小多了吧。況且假設你真的寫錯了測試,時間久了,這個錯誤也就變成了**feature**。為什麼呢?也許你程式碼的消費方已經按照它實際的行為去處理了,這時候你貿然把這個bug修復了,結果可能時消費方反而不能正常工作了。這時候這個錯誤的測試其實也就變成了消費方的一種契約測試。確保你不會把它改對,,,
> 比如C#的類庫中有個`DateTime`,在處理時區問題時很多詭異的行為,這時候微軟已經無法修正它了,只好再單獨新增了一個`DateTimeOffset`,兩者共存,慢慢的遷移過去。
## 5.3 可隔離性
那麼現在你可以快速的理解現存的程式碼了,也可以確保你的新程式碼不會破壞已有的功能,也確認你的程式碼行為是你所認為的行為了。是不是就可以愉快的合併程式碼並且上線釋出了?是的,差不多可以了。但是,凡是總有例外,我們不能把全部希望都寄託在我們能嚴格落實上述兩點。總是要有個備選方案對吧?
可隔離性就是這樣的一個備選方案,其意圖就是隔離你的程式碼行為,哪怕它就是腐爛變質成了不可維護的程式碼,只要不影響其他的模組,那麼就還算是可控的。就像萬噸巨輪,底層的隔水艙總是一個個的獨立的,一個進水了也不影響其他的,從而避免整體的失控。
# 6 途徑
還記得文章開始介紹的**目標**和**途徑**的概念吧,上述的3個原則是我們的目標,那麼想要達成這樣的目標有哪些途徑可供使用呢?
## 6.1 命名
曾經有這麼一句話,計算機領域有兩大難題:命名和快取失效。一個好名字的重要性不必多說了吧?**此外我還有一個心得體會:如果你覺得命名出現了困難,那麼請從頭審視一下你的設計,或許你走錯了方向了。我認為一旦出現了命名困難的問題,那絕對就是你的設計出現了問題。也許時你的方法職責太多了,你無法用簡潔的名字描述清楚,也許是你的欄位所表達的含義不清,導致你無法準確的用一個簡單的詞語描述它**。
| 目標 | 效果 | 解釋 |
| :------- | :--- | :----------- |
| 可理解性 | ++ | 增加可讀性。 |
| 可測試性 | 無 | 無影響。 |
| 可隔離性 | 無 | 無影響。 |
## 6.2 單一職責
幾乎每個人都明白單一職責的重要性,但是卻很容易就忽略它。比如下面的小例子:
```java
// 1
public String sum(
final Collection bigDecimalCollection
) {
final BigDecimal sumResult = bigDecimalCollection
.stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
final DecimalFormat format = new DecimalFormat("#,##0.00");
return format.format(sumResult);
}
// 2
public BigDecimal sum(
final Collection bigDecimalCollection
) {
return bigDecimalCollection
.stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
```
1的職責是不是有點多?
| 目標 | 效果 | 解釋 |
| :------- | :--- | :------------------------------------- |
| 可理解性 | ++ | 一個關注點使得程式碼可理解性大大的提升。 |
| 可測試性 | ++ | 也使得測試更容易實施。 |
| 可隔離性 | ++ | 單一單一,那不就是隔離開了嗎? |
## 6.3 資料模型匹配業務
資料模型匹配的含義是說讓你的程式碼真實的表達實際的業務意圖,而且這個意圖必須要落實到資料層面,而非程式碼層面。**簡而言之就是讓你的資料體現你的業務,而不是你的程式碼體現你的業務**。感覺有點繞噢,什麼鬼意思?我舉個小例子:個稅計算
```java
// 1
(empployee.salary - 3500) * taxRate;
// 2 employee.exemption = 3500
(empployee.salary - employee.exemption) * taxRate;
```
你覺得哪種更合適?1就是業務被體現在了程式碼中,這時候2019年了,個稅免徵額提高到了5000,你怎麼辦?改程式碼唄,3500改成5000不就完事了。對,完事了,那麼歷史的資料怎麼辦?有人要對比一下新舊版本的差異,怎麼算?沒辦法,你被逼著寫了兩個版本,2019年前一個版本的程式碼,2019年後的一個版本,然後混亂就開始了。
所以根本問題在哪?就是因為3500這個數字看起來雖然不起眼,但是它本身是業務的一部分,結果卻被安置到了程式碼中。這就是典型的資料模型不匹配業務。這種細節有時候一開始很難察覺到,但是一旦發現可能就已經很難挽回了,程式碼可以隨便改,但是已經存在的歷史資料怎麼辦? 上述的例子還好說點,你可以刷一下歷史資料給補上去。但是很多時候資料一開始沒有記錄,後續就無論如何也無法修補了,導致你的程式碼被死死的捆綁住,無法再新增新功能了。
筆者非常認同Linus torvalds的一句話:“爛程式設計師關心的是程式碼。好程式設計師關心的是資料結構和它們之間的關係。”[^bad-good-programmer]。Git的資料結構非常之穩定,它的底層實際上是一個內容定址檔案系統,在這樣的一個底層資料結構之上,十幾年來Git新增了n多個功能和命令,但是卻一致保持著的相容性(你用Git早期版本初始化操作一個repo,到了現在的最新版依然是完全匹配的)。
| 目標 | 效果 | 解釋 |
| :------- | :--- | :--------------------------------------------------------------------------------------------- |
| 可理解性 | ++ | 匹配的模型可以表達真實的業務意圖,沒有中間轉換的環節,可以讓你再理解程式碼時沒有額外的心智負擔。 |
| 可測試性 | + | 使得測試更能直觀的描述真實的業務行為。 |
| 可隔離性 | + | 合理的模型劃分可以有效的減少不必要的依賴,從而保持相對獨立。 |
## 6.4 抽象層級
把大象放進冰箱需要幾步?
1. 把冰箱門開啟。
2. 把大象放進去。
3. 把冰箱門關上。
就這麼簡單,這三件事都是在一個抽象的層級上的。那麼再細化一些,開啟冰箱門需要幾步?還有現在沒大象,我要去從動物園先弄過來一個,怎麼辦?這些細節和上述的三個步驟是不是在一個抽象層級上? 肯定不是吧!**但是我們通常很多時候都是在幹著這樣的事情,比如業務程式碼中夾雜著如何拼接SQL語句的程式碼。當你讀到這樣的程式碼的時候會覺得很亂,為什麼感覺亂?就是因為其涵蓋了不同抽象層級的程式碼在一起,導致你在前腳還在想著如何把大象放進去這件事的時候,突然發現接下來的是我怎麼才能從動物園弄個大象出來這些瑣事**。還記得上面的一個判斷非空的一小段程式碼吧?
```js
// 1
if(userList.isNotEmpty()){
}
// 4
if(userList.length() != 0){
}
```
4乾的就樣的事情,雖然很細微,但是就是這樣一個一個細微的不同抽象層級的程式碼混在一塊,就把你的程式碼搞亂了,搞的可理解性急劇下降。
| 目標 | 效果 | 解釋 |
| :------- | :--- | :-------------------------------------------------------------------------------- |
| 可理解性 | ++ | 閱讀程式碼時避免分心去考慮一些不必要的細節問題。 |
| 可測試性 | ++ | 比如我用一個大象的毛絨玩具也可以完成第2步吧?這就大大的簡化了測試的關注點和編寫。 |
| 可隔離性 | ++ | 遮蔽了一些底層的細節。 |
## 6.5 奧卡姆剃刀
這又是個什麼鬼?怎麼剃刀都出來了,還嫌髮際線不夠高嗎?其實不是的,這個一個關於簡單行的原則,也稱之為“**如無必要,勿增實體**”。就是說如果有兩個途徑可以完成同樣一件事情,那就選擇更簡單假設更少的那一個。
| 目標 | 效果 | 解釋 |
| :------- | :--- | :----------------------- |
| 可理解性 | + | 選擇更簡單的有助於理解。 |
| 可測試性 | 無 | 無影響。 |
| 可隔離性 | 無 | 無影響。 |
# 7 一些誤區
看到這裡估計有人要忍不住要批判我了:
1. 可複用性呢?GoF23種設計模式都強調構建可複用性的軟體,可複用性跑哪去了?被你吃了啊。
2. 可靠性呢?健壯性呢?
3. 高可用性呢?
等等吧,就像當年軟工課程上羅列的各種指標,或者各種的模式和架構等等。其實不是說這些東西不重要,或者我不認可這些東西,我認可,也理解它們的重要性。**但是有一點要徹底搞清楚,哪些是我們的目標?哪些是我們的途徑?**
## 7.1 可複用性只是一種現象
**可複用性難道是我們追求的目標嗎?我的回答是:否,我們的目標是軟體的可維護性**!那麼你說複用就會增加可維護性,其實不盡然,不合適的複用反而會降低可維護性,這是一把雙刃劍,借用著哥的一句話:“**越通用越無用**”。那麼你說不是目標也是途徑吧!那麼我的回答是:也不是途徑,你這條途徑可能會違憲,你覺得它合適嗎?也不是目標,也不是途徑,那麼它到底是什麼?答:只是一種現象,如果你落實了上述的5條途徑中的某些途徑,你會發現你的程式碼自然而然就可以複用了。
## 7.2 設計模式源自缺陷
首先我們看一下設計模式是什麼: “是一套被反覆使用、多數人知曉的、經過分類編目的、程式碼設計經驗的總結。使用設計模式是為了可重用程式碼、讓程式碼更容易被他人理解、保證程式碼可靠性、程式的重用性。” 也就是說它是經過驗證的一些最佳實踐的經驗性程式碼。**那麼問題來了,什麼時候才需要最佳實踐?**,當你對你所使用的工具出現迷惑的時候,不太清楚怎麼處理才好的時候,你需要借鑑一下其他人總結出來的比較好的處理方案才能完成你的工作的時候。這個處理方案,就是設計模式。那麼此時你想一想,GoF23的設計模式是在彌補什麼的缺陷?OO的啊,人家的副標題是“**可複用面向物件軟體的基礎**”。
當然設計模式也不是OO的專有的東西,凡是通用的那些已命名的最佳實踐,都可以稱之為設計模式。
## 7.3 OOP不是目的
很多時候在討論程式碼的時候,看著程式碼覺得不舒服,一言不合就互相給對方扣上了一頂帽子,你的程式碼一點也不OO!這其實大可不必,OO是來解決一些問題的,但是它並不能解決全部問題,那麼多static的類或者方法,它OO嗎?OO只是解決我們問題的一種途徑,也不是唯一的途徑,千萬不可把工具當目的。
## 7.4 DDD帶來的問題比解決的問題更多
DDD自從誕生之初就面臨很多爭議。DDD本身出發點非常好(**軟體核心複雜性應對之道**)。DDD是基於OO,在OO之上擴充了很多概念,希望藉此最大程度的發揮出OO的優勢。但是其擴充的概念太多了,而且千人千面,每個人心中的理解都不盡相同,而且可以說南轅北轍的都有,這就使得它**非常難以在團隊中達成理解上的共識**。也就導致實施落地上的種種困難,即使一開始落地了一部分,隨著時間的推移,則會變得越來越難以為繼,好像側重點都跑到了我這麼寫到底符合DDD的思想嗎?而對業務的關注的變成了二等公民,這簡直是個災難,這時候程式碼的可理解性就非常脆弱了。所以根據奧卡姆剃刀原則,剃掉它是最優的選擇。
# 8 總結
以上是筆者關於軟體設計的一些思考過程:筆者認為其目標是避免軟體的失控以及相關的途徑措施,以及對一些常見到的一些概念的看法。如有不妥之處,歡迎來討論。
# 9 引用
本文首發於:
[^bad-good-programmer]: git actually has a simple design, with stable and reasonably well-documented data structures. In fact, I'm a huge proponent of designing your code around the data, rather than the other way around, and I think it's one of the reasons git has been fairly successful […] I will, in fact, claim that the difference between a bad programmer and a good one is whether he considers his code or his data structures more important. Bad programmers worry about the code. Good programmers worry about data structures and their relationships. [Message to Git mailing list](https://lwn.net/Articles/193245/)