記錄一下筆者關於軟件設計的一些相關認知。在開始之前,先引入兩個概念目標
和途徑
(這里可能會有些咬文嚼字,不過主要是為了區分主觀和客觀的一些細微差異)。
1 目標和途徑
我們在做某一件事情的時候,總是會帶有一定的目的性的:比如說一日三餐,是為了給身體補充所需的能量。那么這三餐具體如何落實呢,則會有多種多樣的方式。比如你可以選擇吃碳水食物、蔬菜、肉類、牛奶或者蛋類等等;也可以選擇通過靜脈注射一些所需的葡萄糖或者蛋白質。總之,能夠為身體補充能量就可以了。
1.1 目標
那么在上述的小例子中,我們的目的
就是給身體補充能量,用以維持正常的生命活動所需。當然也可以說是我們的目標
,不過目標
側重於過程,目的則更強調結果。
1.2 途徑
從上面的例子中可以看出有多種方式可以達成我們的上述目的
。其中每一種方式都是一條達成目的
的途徑
,當然我們為了補充均衡的能量,通常會搭配組合幾種不同的食物,我把這個稱之為手段或者方法。手段
和方法
帶有一定的主觀性;而途徑
則是在描述客觀的可供選擇的一種方式。
2 軟件的目的
在開始討論軟件設計之前先問自己一個最基本的問題:我們為什么需要軟件?
筆者認為是為了解決現實中某個領域的相關問題而存在的。就好比最初的計算機是用來計算導彈的彈道的。生活中常用的QQ和微信是為了滿足人們的社交通信需求的,淘寶京東等是滿足了人們的買買買的需求。
所以,軟件存在的目的就是它能解決一些領域的相關問題,這是它存在的唯一理由。
比如在黑客帝國這部電影中,不再被使用的程序只有一個下場,那就是被刪除掉。
3 軟件設計的目標
假如一開始就有了軟件,其實要不要軟件設計都不重要了。但是問題在於軟件不是憑空產生的,不是從0到1沒有中間過程就直接得到了想要的軟件的。在軟件從0到1的過程,就是軟件設計的作用范圍(所以在這里我用軟件設計的目標這個概念)。因為軟件存在的目的在於它能解決一些領域的相關問題,那么首先對軟件的最低要求就是它能用,能用來解決問題。比如一個數學上的加減乘除計算器,最低最低的要求是你要能把結果算對吧。所以軟件設計的目標是什么?筆者認為就是控制這個從0到1的過程,避免其失控(一旦失控你可能就連最低最低的軟件的要求都達不到了)。
《領域驅動設計:軟件核心復雜性應對之道》一書的副標題也是這個含義。它的側重點在於如何利用面向對象的方式應對軟件本身的復雜性,從而避免其失控。
那么筆者對軟件設計的目標的認知就是:避免軟件的失控。為什么是目標而不是目的呢?是因為軟件設計在軟件的整個生命周期中都是存在着的,這是一個持續的過程,直到軟件不再被使用的那一天;而非只在剛開始設計一下,后續就一成不變了。
4 失控的根本原因
上面推導出軟件設計的目標是避免軟件的失控。那么是什么東西導致的失控? 你面臨的業務太復雜?項目遺留的代碼太爛?團隊成員水平參差不齊?工期太緊張導致你無暇做設計規划?也許吧,這些或多或少都確實是已經存在的事實。
- 業務太復雜難道是失控的原因嗎?回想一下軟件的目的是什么?解決一些領域的相關問題,那么我們可以讓業務的復雜性會消失或者降低嗎?答案是肯定的,不會!這里就有人要說你放屁。。。你敢說我們無法降低業務復雜性,打你噢。你就是打死我復雜性也不會降低的,,,復雜性是業務本身存在的客觀屬性,是不會以人的意志來改變的,除非你不做它了。就像你現在要在淘寶買一個手機,你人在北京,賣方在廣州,無論你用什么快遞方式,從廣州到北京這段物理距離上的時間消耗是無法消除的。你說你比較着急,那好,賣方給你選擇空運,很快你就收到貨了。你說空運這不是降低了快遞時間,和降低復雜性不是一樣的嗎? 其實並不是,因為復雜性指的是無論你用什么快遞方式,從廣州到北京這段物理距離上的時間消耗是無法消除的,指的是這個過程你無法消除。但是總覺得怪怪的對嗎?是的,看起來是怪怪的,明明我收到貨的時間縮短了,怎么復雜性沒有改變呢?所以這里就引申出另外一個概念:業務交互方式所帶來的影響。這個影響非常之大,但是往往被我們所忽略,比如你選擇購買發貨地是北京的賣方了,是不是時間又進一步大大縮短了?實際業務上也是這樣的,業務本身具備的復雜性,以及我們在把業務轉化為軟件后的交互方式所帶來的影響,業務本身的復雜性我們無法降低和消除,但是后者交互方式則是可以控制的,這也是軟件設計的一部分,所以其實上面我們選擇空運是改變了這部分。就好比你是一個B/S的應用軟件,你的用戶在瀏覽器中看到了Web頁面。這背后你的Web頁面從服務器到用戶瀏覽器的過程和瀏覽器渲染頁面的過程是無論如何也無法消除的,但是瀏覽器可以緩存它,當你下次再打開這個頁面時,它就可以省掉上述的交互過程。
- 項目遺留的代碼太爛是失控的原因嗎?其實也不是,這是失控的一種表現結果。
- 團隊成員水平參差不齊是失控的原因嗎?也不是,這雖然是客觀存在的事實,但是你這樣把責任推到隊友身上不合適吧,說不定隊友也是這么看你的呢。
- 工期太緊張導致你無暇做設計規划是失控的原因嗎? 當然也不是,這個是借口。。。就像你今天起床快要遲到了,你會選擇光屁股不穿衣服就出門嗎?
除了上述的一些事實,當然還有其他的一些因素,看起來都不像是導致失控的罪魁禍首。那么究竟是什么導致的失控???仔細回想一下,當我們覺得項目失控的時候通常是什么場景?
- 有個已知的bug,你改動的時候發現牽扯的東西太多了,牽一發而動全身,你不敢下手。你覺得代碼無法控制了。。。
- 有個未知的bug,你找了好久找不到,代碼太亂了。你覺得一股無力感。。。
- 有個新功能來了,你發現你要改這里那里,但是完全不知道改了會不會破壞現有的功能,也不知道新功能是不是真的可以work。你覺得你無法掌控這些代碼了。。。
- 還有一些其他的情況,總之就是你覺得你無法掌控代碼的真實行為了,你不知道你的代碼會產生什么樣的結果,就像薛定諤的代碼一樣。。。
那么還有一個場景,當你要開展一個新的項目,所有的一切都是新的,沒有任何歷史債務負擔,這時候你是什么感覺?信心滿滿啊肯定是,這時候你不會覺得你會對接下來的代碼失去控制,因為你現在一行代碼都還沒有。。。
所以是什么導致的失控?現存的無力維護(bug、新功能都是維護)的代碼導致的失控,同時這也是失控的表現結果。那么你為什么會無力維護這些代碼,因為它的真實行為和你理解的行為出現了偏差,你覺得它不可控了。這時候就是真的失控了,代碼爛不爛其實並不是重點,只要你還能維護,這些都不是問題。
代碼只會按照你編寫的行為去執行,而不是按照你認為的行為去執行。
那么如何避免失控?編寫可維護的代碼。打死你噢,解釋這么半天憋出這么一句廢話,誰不知道要編寫可維護的代碼啊。。。
我只能說別着急,繼續慢慢往下看。。。
5 目標-可維護性
既然我們的目標是避免失控,避免失控的途徑則是編寫可維護的代碼。那么我就把可維護性作為軟件設計的終極目標,而且沒有之一。也稱之為元原則,就是說我們目前所接觸到的各自編程原則、建議和最佳實踐等等都可以通過可維護性推導細化出來,並且不可與之相違背。
打個比喻,就好比憲法是其他一切法律的基礎,任何法律如果違背了憲法,那么就是無效的。
那么根據可維護性可推導出來3個核心的原則:可理解性、可測試性和可隔離性。
5.1 可理解性
這條原則看起來很有主觀性的傾向,但是其實並不是。
比如說你剛寫了一段代碼,你覺得容易理解,他看起不容易理解;或者說代碼是他寫的,他看起來很容易理解,但是到你這里無法一下子理解他的思維,然后你就覺得不好理解。如果出現了這樣的情況,那么則統統都是不可理解的。這時候你要說了:你要一棍子打死雙方啊。是的,正是如此。再回想一下我們的目標是什么?可維護性! 這里的維護不單單是說你的代碼你來維護,而是大家互相交叉着;你新增了一個功能,后續負責其他的事情去了,那么這時候就由你的隊友來負責維護了;或者你接手維護別人的代碼。
所以我們需要一個客觀上的可理解性。那么到底什么才能叫客觀?沒法度量啊!其實也不復雜,就是看當你讀到一段代碼的時候,你是否需要額外的思考,額外的腦中維持一個上下文的環境才能明白這段代碼的意圖,如果需要,那么就是不可理解的,至少也是不易理解的。更簡單點說就是這段代碼應該讓你不用思考就看的明白它的意圖。比如下面的一個小例子,功能是完全等價的,但是差異非常微妙。
// 1
if(userList.isNotEmpty()){
}
// 2
if(userList.isEmpty() == false){
}
// 3
if(!userList.isEmpty()){
}
// 4
if(userList.length() != 0){
}
你覺得可理解性怎么排? 答案是肯定的吧?1 > 2 > 3 > 4
。
- 1是不是你根本就不用思考,直接讀下來就知道其含義?
- 2則是有一個
==fasle
的過程,需要你進行簡單的思考。 - 3則是接近於2,但是比2更差一點,因為取反符號在前面,但是其決定性的值則在后面,而你的閱讀順序是從左向右,所以你需要一個比2稍微更復雜一點的思考過程。
- 前三個還都一眼能看出來是空或者非空的語境,但是4就更差了,4的字面意思是長度不等於0,邏輯上其實和非空是等價的,但是你需要在腦中做這樣的一個映射長度!=0等同於非空,這個的抽象層級明顯更低了一個層級。
不知道能否體會其中差細微差異。那么你覺得這些理解是客觀的還是主觀的呢?
5.2 可測試性
可理解性可以確保你可以快速的理解現存代碼的意圖,但是其真實的行為呢?是不是和你所認為的行為就是一致的?上面我說過:“代碼只會按照你編寫的行為去執行,而不是按照你認為的行為去執行”。
那么如何確保你真實的行為和你所認為的行為是一致的?那就是測試。把你認為的行為也寫成代碼,去驗證你的業務代碼執行的時候是不是會按照你給定的輸入得到你期望的輸出結果。借助自動化的CI,就可以在你每次改動代碼時把現有的所有測試都運行一遍,然后你至少可以獲得3點收益:
- 代碼真的時按照你認為的行為去執行的。
- 確保你的改動不會破壞現有的代碼行為。
- 倒逼你的代碼進行合理的分解和抽象,不然你很難編寫有效的測試。
當然你可能把測試寫錯了,,,這種概率就小多了吧。況且假設你真的寫錯了測試,時間久了,這個錯誤也就變成了feature。為什么呢?也許你代碼的消費方已經按照它實際的行為去處理了,這時候你貿然把這個bug修復了,結果可能時消費方反而不能正常工作了。這時候這個錯誤的測試其實也就變成了消費方的一種契約測試。確保你不會把它改對,,,
比如C#的類庫中有個
DateTime
,在處理時區問題時很多詭異的行為,這時候微軟已經無法修正它了,只好再單獨新增了一個DateTimeOffset
,兩者共存,慢慢的遷移過去。
5.3 可隔離性
那么現在你可以快速的理解現存的代碼了,也可以確保你的新代碼不會破壞已有的功能,也確認你的代碼行為是你所認為的行為了。是不是就可以愉快的合並代碼並且上線發布了?是的,差不多可以了。但是,凡是總有例外,我們不能把全部希望都寄托在我們能嚴格落實上述兩點。總是要有個備選方案對吧?
可隔離性就是這樣的一個備選方案,其意圖就是隔離你的代碼行為,哪怕它就是腐爛變質成了不可維護的代碼,只要不影響其他的模塊,那么就還算是可控的。就像萬噸巨輪,底層的隔水艙總是一個個的獨立的,一個進水了也不影響其他的,從而避免整體的失控。
6 途徑
還記得文章開始介紹的目標和途徑的概念吧,上述的3個原則是我們的目標,那么想要達成這樣的目標有哪些途徑可供使用呢?
6.1 命名
曾經有這么一句話,計算機領域有兩大難題:命名和緩存失效。一個好名字的重要性不必多說了吧?此外我還有一個心得體會:如果你覺得命名出現了困難,那么請從頭審視一下你的設計,或許你走錯了方向了。我認為一旦出現了命名困難的問題,那絕對就是你的設計出現了問題。也許時你的方法職責太多了,你無法用簡潔的名字描述清楚,也許是你的字段所表達的含義不清,導致你無法准確的用一個簡單的詞語描述它。
目標 | 效果 | 解釋 |
---|---|---|
可理解性 | ++ | 增加可讀性。 |
可測試性 | 無 | 無影響。 |
可隔離性 | 無 | 無影響。 |
6.2 單一職責
幾乎每個人都明白單一職責的重要性,但是卻很容易就忽略它。比如下面的小例子:
// 1
public String sum(
final Collection<BigDecimal> 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<BigDecimal> bigDecimalCollection
) {
return bigDecimalCollection
.stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
1的職責是不是有點多?
目標 | 效果 | 解釋 |
---|---|---|
可理解性 | ++ | 一個關注點使得代碼可理解性大大的提升。 |
可測試性 | ++ | 也使得測試更容易實施。 |
可隔離性 | ++ | 單一單一,那不就是隔離開了嗎? |
6.3 數據模型匹配業務
數據模型匹配的含義是說讓你的代碼真實的表達實際的業務意圖,而且這個意圖必須要落實到數據層面,而非代碼層面。簡而言之就是讓你的數據體現你的業務,而不是你的代碼體現你的業務。感覺有點繞噢,什么鬼意思?我舉個小例子:個稅計算
// 1
(empployee.salary - 3500) * taxRate;
// 2 employee.exemption = 3500
(empployee.salary - employee.exemption) * taxRate;
你覺得哪種更合適?1就是業務被體現在了代碼中,這時候2019年了,個稅免征額提高到了5000,你怎么辦?改代碼唄,3500改成5000不就完事了。對,完事了,那么歷史的數據怎么辦?有人要對比一下新舊版本的差異,怎么算?沒辦法,你被逼着寫了兩個版本,2019年前一個版本的代碼,2019年后的一個版本,然后混亂就開始了。
所以根本問題在哪?就是因為3500這個數字看起來雖然不起眼,但是它本身是業務的一部分,結果卻被安置到了代碼中。這就是典型的數據模型不匹配業務。這種細節有時候一開始很難察覺到,但是一旦發現可能就已經很難挽回了,代碼可以隨便改,但是已經存在的歷史數據怎么辦? 上述的例子還好說點,你可以刷一下歷史數據給補上去。但是很多時候數據一開始沒有記錄,后續就無論如何也無法修補了,導致你的代碼被死死的捆綁住,無法再添加新功能了。
筆者非常認同Linus torvalds的一句話:“爛程序員關心的是代碼。好程序員關心的是數據結構和它們之間的關系。”[1]。Git的數據結構非常之穩定,它的底層實際上是一個內容尋址文件系統,在這樣的一個底層數據結構之上,十幾年來Git新增了n多個功能和命令,但是卻一致保持着的兼容性(你用Git早期版本初始化操作一個repo,到了現在的最新版依然是完全匹配的)。
目標 | 效果 | 解釋 |
---|---|---|
可理解性 | ++ | 匹配的模型可以表達真實的業務意圖,沒有中間轉換的環節,可以讓你再理解代碼時沒有額外的心智負擔。 |
可測試性 | + | 使得測試更能直觀的描述真實的業務行為。 |
可隔離性 | + | 合理的模型划分可以有效的減少不必要的依賴,從而保持相對獨立。 |
6.4 抽象層級
把大象放進冰箱需要幾步?
- 把冰箱門打開。
- 把大象放進去。
- 把冰箱門關上。
就這么簡單,這三件事都是在一個抽象的層級上的。那么再細化一些,打開冰箱門需要幾步?還有現在沒大象,我要去從動物園先弄過來一個,怎么辦?這些細節和上述的三個步驟是不是在一個抽象層級上? 肯定不是吧!但是我們通常很多時候都是在干着這樣的事情,比如業務代碼中夾雜着如何拼接SQL語句的代碼。當你讀到這樣的代碼的時候會覺得很亂,為什么感覺亂?就是因為其涵蓋了不同抽象層級的代碼在一起,導致你在前腳還在想着如何把大象放進去這件事的時候,突然發現接下來的是我怎么才能從動物園弄個大象出來這些瑣事。還記得上面的一個判斷非空的一小段代碼吧?
// 1
if(userList.isNotEmpty()){
}
// 4
if(userList.length() != 0){
}
4干的就樣的事情,雖然很細微,但是就是這樣一個一個細微的不同抽象層級的代碼混在一塊,就把你的代碼搞亂了,搞的可理解性急劇下降。
目標 | 效果 | 解釋 |
---|---|---|
可理解性 | ++ | 閱讀代碼時避免分心去考慮一些不必要的細節問題。 |
可測試性 | ++ | 比如我用一個大象的毛絨玩具也可以完成第2步吧?這就大大的簡化了測試的關注點和編寫。 |
可隔離性 | ++ | 屏蔽了一些底層的細節。 |
6.5 奧卡姆剃刀
這又是個什么鬼?怎么剃刀都出來了,還嫌發際線不夠高嗎?其實不是的,這個一個關於簡單行的原則,也稱之為“如無必要,勿增實體”。就是說如果有兩個途徑可以完成同樣一件事情,那就選擇更簡單假設更少的那一個。
目標 | 效果 | 解釋 |
---|---|---|
可理解性 | + | 選擇更簡單的有助於理解。 |
可測試性 | 無 | 無影響。 |
可隔離性 | 無 | 無影響。 |
7 一些誤區
看到這里估計有人要忍不住要批判我了:
- 可復用性呢?GoF23種設計模式都強調構建可復用性的軟件,可復用性跑哪去了?被你吃了啊。
- 可靠性呢?健壯性呢?
- 高可用性呢?
等等吧,就像當年軟工課程上羅列的各種指標,或者各種的模式和架構等等。其實不是說這些東西不重要,或者我不認可這些東西,我認可,也理解它們的重要性。但是有一點要徹底搞清楚,哪些是我們的目標?哪些是我們的途徑?
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 引用
本文首發於:https://linianhui.github.io/talk/objective-and-approach-of-software-design/
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 ↩︎