極客大學架構師訓練營 框架設計、設計原則、設計模式 第四課 聽課總結

說明

框架設計、設計原則、設計模式

講師:李智慧

對象對象編程與面向對象分析

面向對象編程不是使用面向對象的編程語言進行編程,而是利用多態特性進行編程。

面向對象分析是將客觀世界,即編程的業務領域進行對象分析。

  • 充血模型和貧血模型
  • 領域驅動設計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();
	}

}

有什麼臭味嗎?

在這裏插入圖片描述

  1. 僵化 - 不易增加、修改:
    ☞ 增加一種 Button 類型,就需要對 Button 類進行修改;
    ☞ 修改 Dialer,可能會影響 Button。
  2. 脆弱 - switch case / if else 語句是相當脆弱的。
    ☞ 當我想修改 Send 按鈕的功能時,有可能不小心破壞數字按鈕;
    ☞ 當這種函數很多時,我很有可能會漏掉某個函數,或其中的某個條件分支。
  3. 不可移植 - 設想我們要設計密碼鎖的按鈕,它只需要數字按鍵,但 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
在這裏插入圖片描述

注意:以上信息如有侵權,請聯繫作者刪除,謝謝。

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