說明
框架設計、設計原則、設計模式
講師:李智慧
對象對象編程與面向對象分析
面向對象編程不是使用面向對象的編程語言進行編程,而是利用多態特性進行編程。
面向對象分析是將客觀世界,即編程的業務領域進行對象分析。
- 充血模型和貧血模型
- 領域驅動設計DDD(Domain Driven Design)
面向對象設計的目的和原則
面向對象設計的目的
- 強內聚、低耦合,從而使系統
☞ 易擴展 - 易於增加新的功能
☞ 更強壯 - 不容易被粗心的程序員破壞
☞ 可移植 - 能夠在多樣的環境下運行
☞ 更簡單 - 容易理解、容易維護
面向對象設計的原則
- 爲了達到上述設計目標,最具代表性Robert C. Martin總結出了多種指導原則。
- “原則” 是獨立於編程語言的,甚至於可以用於非面向對象的編程語言中。
設計模式(Design Patterns)
設計模式是用於解決某一種問題的通用的解決方案。
設計模式也是語言中立的。
設計模式貫徹了設計原則。
Gang of Four (Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides)提出了三大類23種基本的設計模式:
- 創建模式
- 行爲模式
- 結構模式
在更細分的領域中還可以總結出許多設計模式:
- 併發編程模式
- Java EE模式
框架(Frameworks)
框架是用來實現某一類應用的結構性的程序,是對某一類架構方案可複用的設計與實現
- 如同框架架構的大廈的框架
- 簡化應用開發者的工作
- 實現了多種設計模式,使應用開發者不需要花太大的力氣,就能設計出結構良好的程序來
不同領域的框架
- 微軟公司爲Windows編程開發了MFC框架。
- Java爲它的GUI(圖形用戶界面)開發了AWT框架。
- 還有許多開源的框架:MyBatis,Spring等。
- Web服務器也是框架:Tomcat
框架 VS 工具
框架調用應用程序代碼(比如:Spring Boot, Spring Cloud, JUnit)
應用程序代碼調用工具(比如:Log4j)
架構師用框架保證架構的落地
架構師用工具提高開發效率
軟件設計的"臭味"
軟件設計的最終目的,是使軟件達到“強內聚、松耦合”,從而使軟件:
- 易擴展 - 易於增加新的功能
- 更強壯 - 不容易被粗心的程序員破壞
- 可移植 - 能夠在多樣的環境下運行
- 更簡單 - 容易理解、容易維護
與之相反,一個 “不好的” 軟件, 會發出如下 “臭味” :
- 僵硬 - 不易改變。
- 脆弱 - 只想改 A,結果 B 被意外破壞。
- 不可移植 - 不能適應環境的變化。
- 導致誤用的陷阱 - 做錯誤的事比做正確的事更容易,引誘程序員破壞原有設計。
- 晦澀 - 代碼難以理解。
- 過度設計、 copy-paste 代碼。
僵化性(Rigidity)
很難對系統進行改動,因爲每個改動都會迫使許多對系統其它部分的改動。
- 如果單一的改動會導致依賴關係的模塊中的連鎖改動,那麼設計就是僵化的,必須要改動的模塊越多,設計就越僵化。
脆弱性(Fragility)
對系統的改動會導致系統中和改動的地方無關的許多地方出現問題。
- 出現新問題的地方與改動的地方沒有概念上的關聯。要修正這些問題又會引出更多的問題,從而使開發團隊就像一隻不停追逐自己尾巴的狗一樣。
牢固性(Immobility)
很難解開系統的糾結,使之成爲一些可在其它系統中重要的組件。
- 設計中包含了對其它系統有用的部分,而把這部分從系統中分離出來所需要的努力和風險是巨大的。
粘滯性(Viscosity)
做正確的事情比做錯誤的事情要困難。
- 面臨一個改動的時候,開發人員常常會發現會有多種改動的方法。
有的方法會保持系統原來的設計,而另外一些則會破壞設計,當那些可以保持系統設計的方法
比那些破壞設計的方法很難應用,就表明設計具有高的粘滯性,做錯誤的事情就很容易。
不必要的複雜性(Needless Complexity)
設計中包含不具任何直接好處的基礎結構
- 如果設計中包含有當前沒有用的組成部分,它就含有不必要的複雜性。當開發人員預測需求的變化,並在軟件中放置了那些潛在的變化的代碼時,常常會出現這種情況。
不必要的重複(Needless Repetition)
設計中包含有重複的結構,而該重複的結構本可以使用單一的抽象進行統一。
- 當 copy, cut, paste 編程的時候,這種情況就會發生。
晦澀性(Opacity)
很難閱讀、理解。沒有很好的表現出意圖。
- 代碼可以用清晰、富有變現力的方式編寫,也可以用晦澀、費解的方式編寫。一般說來,隨着時間的推移,代碼會變得越來越晦澀。
一個設計腐化過程的例子
- 編寫一個從鍵盤讀入字符並輸出到打印機的程序
Copy 程序結構圖
Copy 程序
void Copy()
{
int c;
while ((c=RdKbd()) != EOF)
WrtPrt(c);
}
幾個月以後老闆來找你,說有時希望 copy 程序能從紙帶機中讀入信息。
Copy 程序的第一次修改成果
bool ptFlag = false;
// remember to reset this flag
void Copy()
{
int c;
while ((c=(ptflag ? Rdpt() : RdKbd())) != EOF)
WrtPrt(c);
}
再過幾周後,你的老闆告訴你,客戶有時候需要輸出到紙帶打孔機上。
Copy 程序第二次修改成果
bool ptFlag = false;
bool punchFlag = false;
// remember to reset these flags
void Copy()
{
int c;
while ((c=(ptflag ? Rdpt() : RdKbd())) != EOF)
punchFlag ? WrtPunch(c) : WrtPrt(c);
}
一個遵循 OOD 原則的設計
public interface Reader {
public int read();
}
public class KeyboardReader extends Reader {
public int read() {
return readKeyboard();
}
}
Reader reader = new KeyboardReader();
Writer writer = new Printer();
public void copy() {
int c:
while ((c=reader.read()) != EOF)
Writer.write();
}
Button/Dailer 僵化例子
設計一個控制電話撥號的軟件。
下面是一個 “撥打電話” 的 Use Case 描述:
- 我們按下數字按鈕,屏幕上顯示號碼,揚聲器發出按鍵音。
- 我們按下 Send 按鍵,系統接通無線網絡,同時屏幕上顯示正在撥號。
如何找出對象:大部分名詞就是對象。
比如數字按鈕,屏幕,揚聲器,Send按鍵,系統,無線網絡。
類圖
合作圖
根據 UML 想象程序代碼
public class Button {
public final static int SEND_BUTTON = -99;
private Dialer dialer;
private int token;
public Button(int token, Dialer dialer) {
this.token = token;
this.dialer = dialer;
}
public void press() {
switch (token) {
case 0: case 1: case 2: case 3: case 4:
case 5: case 6: case 7: case 8: case 9:
dialer.enterDigit(token);
break;
case SEND_BUTTON:
dialer.dial();
break;
default:
throw new UnsupportedOperationException("unknown button pressed: token=" + token);
}
}
}
public class Dialer {
public void enterDigit(int digit) {
screen.display(digit);
speaker.beep(digit);
}
public void dial() {
screen.display("dialing...");
radio.connect();
}
}
有什麼臭味嗎?
- 僵化 - 不易增加、修改:
☞ 增加一種 Button 類型,就需要對 Button 類進行修改;
☞ 修改 Dialer,可能會影響 Button。 - 脆弱 - switch case / if else 語句是相當脆弱的。
☞ 當我想修改 Send 按鈕的功能時,有可能不小心破壞數字按鈕;
☞ 當這種函數很多時,我很有可能會漏掉某個函數,或其中的某個條件分支。 - 不可移植 - 設想我們要設計密碼鎖的按鈕,它只需要數字按鍵,但 Button 的設計使它必須 “附帶” 一個 “Send” 類型的按鈕。
OOD原則一:開/閉原則(OCP)
OCP - Open/Closed Principle
- 對於擴展是開放的(Open for extension)
- 對於更改是封閉的(Closed for modification)
- 簡言之:不需要修改軟件實體(類、模塊、函數等),就應該能實現功能的擴展。
傳統的擴展模塊的方式就是修改模塊的源代碼。如何實現不修改而擴展呢?
- 關鍵是抽象!
改進 Button:方法一 繼承
改進 Button:方法二 策略模式
改進 Button:方法三 適配器模式
改進 Button:方法四 觀察者模式
撥號的同時,該按鈕的燈就亮一下。
Button/Dailer 改進後代碼實現 – 觀察者模式
public interface ButtonListener {
void buttonPressed();
}
public class Button {
private List<ButtonListener> listeners;
public Button() {
this.listeners = new LinkedList<ButtonListener>();
}
public void addListener(ButtonListener listener) {
assert listener != null;
listeners.add(listener);
}
public void press() {
for (ButtonListener listener: listeners) {
listener.buttonPressed();
}
}
}
public class Phone {
private Dialer dialer;
private Button[] digitButtons;
private Button sendButtons;
public Phone() {
dialer = new Dailer();
digitButton = new Button[10];
for (int i = 0; i < digitButtons.length; i++) {
digitButton[i] = new Button();
final int digit = i;
digitButtons[i].addListener(new ButtonListener() {
public void buttonPressed() {
dialer.enterDigit(digit);
}
});
}
sendButton = new Button();
sendButton.addListener(new ButtonListener() {
public void buttonPressed() {
dialer.dial();
}
});
}
public static void main(String[] args) {
Phone phone = new Phone();
phone.digitButton[9].press();
phone.digitButton[1].press();
phone.digitButton[1].press();
phone.sendButton.press();
}
}
OOD原則二:依賴倒置原則(DIP)
DIP - Dependency Inversion Principle
- 高層模塊不能依賴低層模塊,而是大家都依賴於抽象;
- 抽象不能依賴實現,而是實現依賴抽象。
DIP 倒置了什麼?
- 模塊或包的依賴關係
- 開發順序和職責
軟件的層次化
- 高層決定低層
- 高層被重用
比如Controller是高層不能依賴於低層Service,就算是依賴了IService接口,IService也是爲Service服務的,這裏還是高層依賴了低層。如何解決?
解決方案:
比如是個註冊場景,Controller定了一個Register的接口,Service要去實現Register接口。
遵循 DIP 的層次依賴關係
違反 DIP 案例
遵循 DIP 的解決方案:
框架的核心
好萊塢規則:
- Don’t call me, I’ll call you.
倒轉的層次依賴關係
找出 Button 背後的抽象
Button 的本質是什麼?
- 檢測用戶的按鍵指令,並傳遞給目標對象。
用什麼機制檢測用戶的按鍵?
- 不重要
目標對象是什麼?
- 不重要
OOD原則三:Liskov替換原則(LSP)
在 Java / C++ 這樣的靜態類型語言中,實現 OCP 的管線在於抽象,而抽象的威力在於多態和繼承。
- 一個正確的繼承要符合什麼要求?
- 答案:Liskov 替換原則
1988年,Barbara Liskov 描述這個原則:
- 若對每個類型 T1 的對象 O1, 都存在一個類型 T2 的對象 O2, 使得在所有針對 T2 編寫的程序 P 中,用 O1 替換 O2 後,程序 P 的行爲功能不變,則 T1 是 T2 的子類型。
- 簡言之:子類型(Subtype)必須能夠替換掉它們的基類型(Base Type)。
舉例說明
假設: Horse 是 WhiteHorse 和 BlackHorse 的基類
在使用 Horse 對象的任何場合,我們可以把 WhiteHorse 對象傳進去,以取代 Horse 對象,程序仍然正確。
LSP 的反命題不成立
墨子曾經曰過:《墨子 小取》
- “娣,美人也,愛娣,非愛美人也…”
違反 LSP 的案例一
void drawShape(Shape shape) {
if (shape instanceof Circle) {
drawCircle((Circle)shape);
} else if (shape instanceof Square) {
drawSquare((Square)shape);
} else {
......
}
}
違反 LSP 的案例二
不符合 IS-A 關係的繼承,一定不符合 LSP
JDK 中的錯誤設計:
違反 LSP 的案例三
下面是一個 “長方形” 類:
public class Rectangle {
private double width;
private double height;
public void setWidth(double w) { width = w; }
public void setHeight(double h) { height = h; }
public double getWidth() { return width; }
public double getHeight() {return height; }
}
接着讓我們創建一個 “正方形” 類。正方形 IS-A 長方形嗎?
Rectangle 包含 width 和 height,但 Square 只需要 side 就可以了。
public class Square extends Rectangle {
public void setWidth(double w) {
width = height = w;
}
public void setHeight(double h) {
height = width = h;
}
}
加入有一個方法:
void testArea(Rectangle rect) {
rect.setWidth(3);
rect.setHeight(4);
assert 12 == rect.calculateArea(); // 傳入 Square 將失敗
}
爲什麼正方形 IS-NOT-A 長方形呢?
IS-A 關係是關於行爲的。
- 從行爲的方式來看,正方形和長方形是不同的。
從對象的屬性來證明這一論點,對於同一個類,所創建的不同對象,它們的:
- 標識 - 是不同的。
- 狀態 - 是不同的。
- 行爲 - 是不同的。
因此,設計和界定一個類,應該以其行爲作爲區分。
從 “契約” 的角度來看 LSP
LSP 要求,凡是使用基類的地方,一定也適用於其子類。
從 Java 語法角度看,意味着:
- 子類一定擁有基類的整個接口。
- 子類的訪問控制不能比基類更嚴格。
☞ 例如,Object
類中又一個方法:
☞protected Object clone();
☞ 子類中可以覆蓋(override)之並放鬆其訪問控制:
☞public Object clone();
☞ 但反過來是不行的,例如:
☞ 覆蓋public String toString()
方法,並將其訪問權限縮小成private
, 編譯器不可能允許這樣的代碼通過編譯。
從更廣泛的意義來看,子類的 “契約” 不能比基類更 “嚴格”。
- 例如, 正方形長寬相等,這個契約比長方形要嚴格,因此正方形不是長方形的子類。
- 例如,
Properties
的契約比Hashtable
更嚴格。
如何重構代碼,以解決 LSP 問題?
方法1:最簡單的辦法是,提取共性到基類
方法2:改成組合
繼承 vs. 組合
繼承和組合 OOP 的兩種擴展手段
繼承的優點:
- 比較容易,因爲基類的大部分功能可以通過繼承直接進入子類。
繼承的缺點:
- 繼承破壞了封裝,因爲繼承將基類更多的細節暴露給子類。因而繼承被稱爲 “白盒複用”.
- 當基類發生改變時,可能會層層影響其下的子類。
- 繼承是靜態的,無法在運行時改變組合。
- 類數量的爆炸。
應該優先使用組合。
合適檢測 LSP?
一個模型,如果孤立地看,並不具有真正意義上的有效性。
- 孤立地看,Rectangle 和 Square 並沒有什麼問題。
通過它的客戶程序才能體現出來
- 從對基類做出合理假設的客戶程序的角度來看,Rectangle 和 Square 這個模型就是有問題的。
有誰知道設計的使用者會做出什麼合理的假設呢?
- 大多數這樣的假設都很難預測。
- 避免 “過於複雜” 或 “過度設計”.
- 只預測明顯的違反 LSP 的情況,而推遲其它的預測。
可能違反 LSP 的徵兆
派生類中的退化函數
public class Base {
public void func() { /* do something. */ }
}
public class Derived extends Base {
public void func() { }
}
派生類中拋出基類不會產生的異常。
public class Derived extends Base {
public void func() {
throw new UnsupportedOperationException();
}
}
OOD 原則四:第一職責原則(SRP)
SRP - Single Responsibility Principle
- 又被稱爲 “內聚性原則(Cohesion)”, 意爲:
☞ 一個模塊的組成元素之間的功能相關性。 - 將它與引起一個模塊改變的作用力相關聯,就形成了如下描述:
☞ 一個類,只能有一個引起它的變化的原因。
什麼是職責?
- 單純談論職責,每個人都會得出不同的結論
- 因此我們下一個定義:
☞ 一個職責是一個變化的原因。
違反 SRP 原則的後果
舉例說明:
- Rectangle 類包含了兩個職責:
☞draw()
在GUI上畫出自己;
☞area()
用來計算自身的面積。 - 有兩個應用分別依賴Rectangle:
☞ 計算幾何應用,利用Rectangle計算面積
☞ 圖形應用,利用Rectangle繪製長方形,也需要計算面積。
後果 - 脆弱性 - 把繪圖和計算功能耦合在一起,當修改其中一個時,另一個功能可能會意外受損。
- 不可移植性 - 計算幾何應用只需要使用 “計算面積” 的功能,卻不得不包含 GUI 的依賴。
改進
區分類的方法: 分清職責
職責 - 變化的原因
- 有時區分一個類包含了幾個職責並不明顯,例如:
interface Modem {
void dial(String pno);
void hangup();
void send(char c);
void recv();
}
- 加入應用程序連接 Modem 的方式會發生變化,例如: dial 的參數會因此而變化,那麼這個設計會導致 “僵化性” 的問題。此時,應該把連接和收發這兩個職責分離:
何時分離職責?當變化發生時。
一種常見的違反 SRP 情景
Employee 包含了兩個職責:
- 業務邏輯
- 持久化邏輯
這兩個職責通常不應該混合在一起:
- 業務變化快,持久化邏輯變化慢。
- 變化的原因也不同
OOD原則五:接口分離原則(ISP)
ISP – Interface Segregation Principle
- 不應該強迫客戶程序依賴它們不需要的方法。
ISP 和 SRP 的關係
- ISP 和 SRP 是相關的,都和 “內聚性” 有關。
- SRP 指出應該如何設計一個類 – 只能有一種原因才能促使該類發生變化。
- ISP 指出改如何設計一個接口 – 從客戶的需要出發,強調不要讓客戶看到他們不需要的方法。
以前面 Modem 爲例
事實上,要完全做到 SRP 是困難的,例如在 Modem 例子中,”連接“ 環節和 ”收發數據“ 環節有內在的關係,可能必須寫在一個類中。
但是我們仍然可以把接口分開,這樣當 ”連接“ 的方法改變時,那些只關係 ”收發數據“ 的程序不會受到影響。
胖接口 – 另一個例子
這是一個可定時關閉的門。
Interface Door extends TimerClient {
void lock();
void unlock();
boolean isDoorOpen();
}
class Timer {
void register(int timeout, TimeClient client);
}
interface TimerClient {
void timeout();
}
在這個例子中, Door 類的接口中包含了 timeout 方法,然而這個方法對不需要 timeout 機制的門是沒有用的。
客戶對接口的反作用
Timer 是 Door 的客戶;另外還有一些不需要定時功能的 Door 客戶。
當 Timer 發生改變時:
class Timer {
public void register(int timeout, int timeoutID, TimerClient client);
}
TimerClient 也被迫改變:
interface TimerClient {
void timeout(int timeoutID);
}
從而所有不需要定時功能的 Door 的客戶程序都收到影響。
改進:分離 Door 接口和 TimerClient 接口
方法1: 適配器模式
方法2: 多繼承
總結
優秀的程序員:歡迎需求變更。你的設計就是爲了需求變更而設計。開閉原則,對修改關閉,對擴展開發。
推薦閱讀:《敏捷軟件開發原則、模式與實踐》,作者Robert C. Martim
注意:以上信息如有侵權,請聯繫作者刪除,謝謝。