重拾面向物件軟體設計

語言: CN / TW / HK

你還在用面向物件的語言,寫著面向過程的程式碼嗎?

01

前言

Aliware

在歐洲文藝復興時期,一位偉大的數學家天文學家-哥白尼,在當時提出了日心說,駁斥了以地球為宇宙中心的天體思想,由於思想極其超前,直到半個世紀後開普勒伽利略等人經過後期研究,才逐步認可並確立了當時哥白尼思想的先進性。

無獨有偶,在軟體工程領域也上演著同樣的故事。半個世紀前 Kristen Nygaard 發明了 Simula 語言,這也是現在被認同的世界上第一個明確實現面向物件程式設計的語言,他提出了基於類的程式設計風格,確定了"萬物皆物件"這一面向物件理論的"終極思想",但在當時同樣未受到認可。Peter Norvig 在 Design Patterns in Dynamic Programming 對此予以了駁斥,並表述我們並不需要什麼面向物件。半個世紀後 Robert C.Martin、Bertrand Meyer、Martin Fowler 等人,再次印證並昇華了面向物件的設計理念。程式設計思想的演進也不是一蹴而就,但在這一個世紀得到了飛速的發展。

01

程式設計思想的演進

Aliware

從上個世紀五十年代馮·諾依曼創造第一臺計算機開始,一直到現在只有短短 70 年時間,從第一門計算機語言 FORTRAN,到現在我們常用的 C++,JAVA,PYTHON 等,計算機語言的演進速度遠超我們所使用的任何一門自然語言。從最早的面向機器,再到面向過程,到演化為現在我們所使用的面向物件。不變的是程式設計的宗旨,變化的是程式設計的思想。

01

面向機器

計算機是 01 的世界,最早的程式就是通過這種 01 機器碼來控制計算機的,比如 0000 代表讀取,0001 代表儲存等。理論上這才是世界上最快的語言,無需翻譯直接執行。但弊端也很明顯,那就是幾乎無法維護。執行 5 毫秒,程式設計 3 小時。由於機器碼無法維護,人們在此基礎上發明了組合語言,READ 代表 0000,SAVE 代表 0001,這樣更易理解和維護。雖然彙編在機器碼上更可視更直觀,但本質上還是一門面向機器的語言,依然還是存在很高的程式設計成本。

02

面向過程

面向過程是一種以事件為中心的程式設計思想,相比於面向機器的程式設計方式,是一種巨大的進步。我們不用再關注機器指令,而是聚焦於具體的問題。它將一件事情拆分成若干個執行的步驟,然後通過函式實現每一個環節,最終串聯起來完成軟體設計。

流程化的設計讓編碼更加清晰,相比於機器碼或彙編,開發效率得到了極大改善,包括現在仍然有很多場景更適合面向過程來完成。但軟體工程最大的成本在於維護,由於面向過程更多聚焦於問題的解決而非領域的設計,程式碼的重用性與擴充套件性弊端逐步彰顯出來,隨著業務邏輯越來越複雜,軟體的複雜性也變得越來越不可控。

03

面向物件

面向物件以分類的方式進行思考和解決問題,面向物件的核心是抽象思維。通過抽象提取共性,通過封裝收斂邏輯,通過多型實現擴充套件。面向物件的思想本質是將資料與行為做結合,資料與行為的載體稱之為物件,而物件要負責的是定義職責的邊界。面向過程簡單快捷,在處理簡單的業務系統時,面向物件的效果其實並不如面向過程。但在複雜系統的設計上,通用性的業務流程,個性化的差異點,原子化的功能元件等等,更適合面向物件的程式設計模式。

但面向物件也不是銀彈,甚至有些場景用比不用還糟,一切的根源就是抽象。根據 MECE 法則 將一個事物進行分類,if else 是軟體工程最嚴謹的分類。我們在設計抽象進行分類時,不一定能抓住最合適的切入點,錯誤的抽象比沒有抽象複雜度更高。里氏替換原則的創始人 Barbara Liskov 談抽象的力量 The Power of Abstraction。

03

面向領域設計

Aliware

01

真在”面向物件“嗎

// 撿入客戶到銷售私海
public String pick(String salesId, String customerId){
// 校驗是否銷售角色
Operator operator = dao.find("db_operator", salesId);
if("SALES".equals(operator.getRole())){
return "operator not sales";
}
// 校驗銷售庫容是否已滿
int hold = dao.find("sales_hold", salesId);
List<CustomerVo> customers = dao.find("db_sales_customer", salesId);
if(customers.size() >= hold){
return "hold is full";
}
// 校驗是否客戶可撿入
Opportunity opp = dao.find("db_opportunity", customerId);
if(opp.getOwnerId() != null){
return "can not pick other's customer";
}
// 撿入客戶
opp.setOwnerId(salesId);
dao.save(opp);
return "success";
}

這是一段 CRM 領域銷售撿入客戶的業務程式碼。這是我們熟悉的 Java-面嚮物件語言,但這是一段面向物件程式碼嗎?完全面向事件,沒有封裝沒有抽象,難以複用不易擴充套件。相信在我們程式碼庫,這樣的程式碼不在少數。為什麼?因為它將成本放到了未來。我們將此稱之為“披著面向物件的外衣,幹著面向過程的勾當。” 

在系統設計的早期,業務規則不復雜,邏輯複用與擴充套件體現得也並不強烈,而面向過程的程式碼在支撐這些相對簡單的業務場景是非常容易的。但軟體工程最大的成本在於維護,當系統足夠複雜時,當初那些寫起來最 easy 的程式碼,將來就是維護起來最 hard 的債務。

02

領域驅動設計

還有一種方式我們也可以這麼來寫,新增“商機”模型,通過商機來關聯客戶與銷售之間的關係。而商機的歸屬也分為公海、私海等具體歸屬場景。商機除了有必要的資料外,還應該收攏一些業務行為,撿入、開放、分發等。通過領域建模,利用面向物件的特性,確定邊界、抽象封裝、行為收攏,對業務分而治之。

當我們業務上說“商機分發到私海”,而我們程式碼則是“opportunity.pickTo(privateSea)”。這是領域驅動所帶來的改變,面向領域設計,面向物件程式設計,領域模型的抽象就是對現實世界的描述。但這並非一蹴而就的過程,當你只觸碰到大象的身板時,你認為這是一扇門,當你觸碰到大象的耳朵時,你認為是一片芭蕉。只有我們不斷抽象不斷重構,我們才能愈發接近業務的真實模型。

Use the model as the backbone of a language, Recognize that a change in the language is a change to the model.Then refactor the code, renaming classes, methods, and modules to conform to the new model

--- Eric Evans 《Domain-Driven Design Reference》

譯:使用模型作為語言的支柱,意識到言語的改變就是對模型的改變,然後重構程式碼,重新命名類,方法和模組以符合新模型。

03

軟體的複雜度

這是 Martin Flowler 在《Patterns of Enterprise Application Architecture》這本書中所提的關於複雜度的觀點,他將軟體開發分為資料驅動與領域驅動。很多時候開發的方式大家傾向於,拿到需求後看錶怎麼設計,然後看程式碼怎麼寫,這其實也是面向過程的一個表現。在軟體初期,這樣的方式複雜度是很低的,沒有複用沒有擴充套件,一人吃飽全家不餓。但隨著業務的發展系統的演進,複雜度會陡增。

而一開始通過領域建模方式,以面向物件思維進行軟體設計,複雜度的上升可以得到很好的控制。先思考我們領域模型的設計,這是我們業務系統的核心,再逐步外延,到介面到快取到資料庫。但領域的邊界,模型的抽象,從剛開始成本是高於資料驅動的。

The goal of software architecture is to minimize the human resources required to build and maintain the required system.

--- Robert C. Martin 《Clean Architecture》

譯:軟體架構的終極目標是,用最小的人力成本來滿足構建和維護該系統的需求

如果剛開始我們直接以資料驅動面向過程的流程式程式碼,可以很輕鬆的解決問題,並且之後也不會面向更復雜的場景與業務,那這套模式就是最適合這套系統的架構設計。如果我們的系統會隨著業務的發展逐漸複雜,每一次的釋出都會提升下一次釋出的成本,那麼我們應該考慮投入必要的成本來面向領域驅動設計。

04

抽象的品質

Aliware

抽象永遠是軟體工程領域最難的命題,因為它沒有規則,沒有標準,甚至沒有對錯,只分好壞,只分是否適合。同樣一份淘寶商品模型的領域抽象,可以算是業界標杆了,但它並非適合你的系統。那我們該如何駕馭“抽象”呢?UML 的創始人 Grady booch 在 Object Oriented Analysis and Design with Applications 一書中,提到了評判一種抽象的品質可以通過如下 5 個指標進行測量:耦合性、內聚性、充分性、完整性與基礎性。

01

耦合性

一個模組與另一個模組之間建立起來的關聯強度的測量稱之為耦合性。一個模組與其他模組高度相關,那它就難以獨立得被理解、變化或修改。TCL 語言發明者 John Ousterhout 教授也有同樣的觀點。我們應該儘可能減少模組間的耦合依賴,從而降低複雜度。

Complexity is caused by two things: dependencies and obscurity.

--- John Ousterhout 《A Philosophy of Software Design》

譯:複雜性是由兩件事引起的:依賴性和模糊性。

但這並不意味著我們就不需要耦合。軟體設計是朝著擴充套件性與複用性發展的,繼承天然就是強耦合,但它為我們提供了軟體系統的複用能力。如同摩擦力一般,起初以為它阻礙了我們前進的步伐,實則沒有摩擦力,我們寸步難行。

02

內聚性

內聚性與耦合性都是結構化設計中的概念,內聚性測量的是單個模組裡,各個元素的的聯絡程度。高內聚低耦合,是寫在教科書裡的觀點,但我們也並非何時何地都應該盲目追求高內聚。

內聚性分為偶然性內聚與功能性內聚。金魚與消防栓,我們一樣可以因為它們都不會吹口哨,將他們抽象在一起,但很明顯我們不該這麼幹,這就是偶然性內聚。最希望出現的內聚是功能性內聚,即一個類或模式的各元素一同工作,提供某種清晰界定的行為。比如我將消防栓、滅火器、探測儀等內聚在一起,他們是都屬於消防設施,這是功能性內聚。

03

充分性

充分性指一個類或模組需要應該記錄某個抽象足夠多的特徵,否則元件將變得不用。比如 Set 集合類,如果我們只有 remove、get 卻沒有 add,那這個類一定沒法用了,因為它沒有形成一個閉環 。不過這種情況相對出現較少,只要當我們真正去使用,完成它的一系列流程操作後,缺失的一些內容是比較容易發現並解決的。

04

完整性

完整性指類或模組需要記錄某個抽象全部有意義的特徵。完整性與充分性相對,充分性是模組的最小內涵,完整性則是模組的最大外延。我們走完一個流程,可以清晰得知道我們缺哪些,可以讓我們馬上補齊抽象的充分性,但可能在另一個場景這些特徵就又不夠了,我們需要考慮模組還需要具備哪些特徵或者他應該還補齊哪些能力。

05

基礎性

充分性、完整性與基礎性可以說是 3 個相互輔助相互制約的原則。基礎性指抽象底層表現形式最有效的基礎性操作(似乎用自己在解釋自己)。比如 Set 中的 add 操作,是一個基礎性操作,在已經存在 add 的情況下,我們是否需要一次性新增 2 個元素的 add2 操作?很明顯我們不需要,因為我們可以通過呼叫 2 次 add 來完成,所以 add2 並不符合基礎性。

但我們試想另一個場景,如果要判斷一個元素是否在 Set 集合中,我們是否需要增加一個 contains 方法。Set 已經有 foreach、get 等操作了,按照基礎性理論,我們也可以把所有的元素遍歷一遍,然後看該元素是否包含其中。但基礎性有一個關鍵詞叫“有效”,雖然我們可以通過一些基礎操作進行組合,但它會消耗大量資源或者複雜度,那它也可以作為基礎操作的一個候選者。

05

軟體設計原則

Aliware

抽象的品質可以指導我們抽象與建模,但總歸還是不夠具象,在此基礎上一些更落地更易執行的設計原則湧現出來,最著名的當屬面向物件的五大設計原則 S.O.L.I.D。

01

開閉原則 OCP

Software entities should be open for extension,but closed for modification

-- Bertrand Meyer 《Object Oriented Software Construction》

譯:軟體實體應當對擴充套件開放,對修改關閉。

開閉原則是 Bertrand Meyer 1988 年在《Object Oriented Software Construction》書中所提到一個觀點,軟體實體應該對擴充套件開放對修改關閉。

我們來看一個關於開閉原則的例子,需要傳進來的使用者列表,分型別進行二次排序,我們程式碼可以這樣寫。

public List<User> sort(List<User> users, Enum type){
if(type == AGE){
// 按年齡排序
users = resortListByAge(users);
}else if(type == NAME){
// 按名稱首字母排序
users = resortListByName(users);
}else if(type == NAME){
// 按客戶健康分排序
users = resortListByHealth(users);
}
return users;
}

上述程式碼就是一個明顯違背開閉原則的例子,當我們需要新增一種類似時,需要修改主流程。由於這些方法都定義在私有函式中,我們哪怕對現有邏輯做調整,我們也需要修改到這份程式碼檔案。

還有一種做法,可以實現對擴充套件開放對修改關閉,JDK 的排序其實已經為我們定義了這樣的標準。我們將不同的排序方式進行抽象,每種邏輯單獨實現,單個調整邏輯不影響其他內容,新增排序方式也無需對已有模組進行調整。

02

依賴倒置 DIP

High level modules shouldnot depend upon low level modules.Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions

--- Robert C.Martin C++ Report 1996

譯:高層模組不應該依賴低層模組,兩者都應該依賴抽象;抽象不應該依賴細節,細節應該依賴抽象。

Robert C.Martin 是《Clean Code》《Code Architecture》兩本經典書籍的作者,1996 年他在 C++ Report 中發表了一篇名為 The Dependency Inversion Principle 的文章。他認為模組間的依賴應該是有序的,高層不應該依賴低層,低層應該依賴高層,抽象不應該依賴細節,細節應該依賴抽象。

怎麼理解 Robert C.Martin 的這一觀點?我們看上面這張圖,我們的手可以握住這個杯子,是我們依賴杯子嗎?有人說我們需要調杯子提供的 hold 服務,我們才能握住它,所以是我們依賴杯子。但我們再思考一下,棍子我們是不是也可以握,水壺我們也可以握,但貓狗卻不行,為什麼?因為我們的杯子是按照我們的手型進行設計的,我們定義了一個可握持的 holdable 介面,杯子依賴我們的需求進行設計。所以是杯子依賴我們,而非我們依賴杯子。

依賴倒置原則並非一個新創造的理論,我們生活的很多地方都有在運用。比如一家公需要設立“法人”,如果這家公司出了問題,監管局就會找公司法人。並非監管局依賴公司提供的法人職位,它可以找到人,而是公司依賴監管局的要求,才設立法人職位。這也是依賴倒置的一種表現。

03

其他設計原則

這裡沒有一一將 S.O.L.I.D 一一列舉完,大家想了解的可以自行查閱。除了 SOLID 之外,還有一些其他的設計原則,同樣也非常優秀。

PLOA 最小驚訝原則

If a necessary feature has a high astonishment factor, it may be necessary to redesign the feature

-- Michael F. Cowlishaw

譯:如果必要的特徵具有較高的驚人因素,則可能需要重新設計該特徵。

PLOA 最小驚訝原則是斯坦福大家計算機教授 Michael F. Cowlishaw 提出的。不管你的程式碼有“多好”,如果大部分人都對此感到吃驚,或許我們應該重新設計它。JDK 中就存在一例違反 PLOA 原則的案例,我們來看下面這段程式碼。

/**
* Set a <tt>Formatter</tt>. This <tt>Formatter</tt> will be used
* to format <tt>LogRecords</tt> for this <tt>Handler</tt>.
* <p>
* Some <tt>Handlers</tt> may not use <tt>Formatters</tt>, in
* which case the <tt>Formatter</tt> will be remembered, but not used.
* <p>
* @param newFormatter the <tt>Formatter</tt> to use (may not be null)
* @exception SecurityException if a security manager exists and if
* the caller does not have <tt>LoggingPermission("control")</tt>.
*/
public synchronized void setFormatter(Formatter newFormatter) throws SecurityException {
checkPermission();
// Check for a null pointer:
newFormatter.getClass();
formatter = newFormatter;
}

在分享會上,我故意將這行註釋遮蓋起來,大家都猜不到 newFormatter.getClass() 這句程式碼寫在這裡的作用。如果要檢查空指標,完全可以用 Objects 工具類提供的方法,實現完全一樣,但程式碼表現出來的含義就千差萬別了。

public static <T> T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}

KISS 簡單原則

Keep it Simple and Stupid

-- Robert S. Kaplan

譯:保持愚蠢,保持簡單

KISS 原則是 Robert S. Kaplan 提出的一個理論,Kaplan 並非是一個軟體學家,他是平衡積分卡 Balanced Scorecard 創始人,而他所提出的這個理論對軟體行業依然適用。把事情變複雜很簡單,把事情變簡單很複雜。我們需要儘量讓複雜的問題簡明化、簡單化。

06

寫在最後

Aliware

軟體設計的最大目標,就是降低複雜性,萬物不為我所有,但萬物皆為我用。引用 JDK 集合框架創辦人  Josh Bloch  的一句話來結束。學習程式設計藝術首先要學會基本的規則,然後才能知道什麼時候可以打破這些規則。

You should not slavishly follow these rules, but violate them only occasionally and with good reason. Learning the art of programming, like most other disciplines, consists of first learning the rules and then learning when to break them.

--- Josh Bloch 《Effective Java》

譯:你不該盲目的遵從這些規則,應該只在偶爾情況下,有充分理由後才去打破這些規則

學習程式設計藝術首先要學會基本的規則,然後才能知道什麼時候可以打破這些規則

參閱書籍

1、《Object Oriented Analysis and Design with Applications》 http://niexiaolong.github.io/Object%20Oriented%20Analysis%20and%20Design%20with%20Applications.pdf

2、《Clean Architecture》

http://detail.tmall.com/item.htm?id=654392764249

3、《A Philosophy of Software Design》

http://www.amazon.com/-/zh/dp/173210221X/ref=sr_1_1?qid=1636246895