第 1 章 軟件架構設計原則

1.1 開閉原則

開閉原則(Open-Closed Principle,COP)是指一個軟件實體(如類、模塊和函數)應該對擴展開放,對修改關閉。所謂的關閉,也正是對擴張和修改兩個行爲的一個原則。它強調的是用抽象構建框架,用實現擴展細節,可以提高軟件系統的客服用心及可維護性。開閉原則是對面向對象設計最基礎的設計原則,它知道我們如何建立穩定、靈活的系統。例如版本更新,我們儘可能不修改源代碼,但是可以增加新功能。

在現實生活中開閉原則也有體現。比如,很多互聯網公司都實行彈性作息時間,只規定每天工作 8 小時。意思就是說,對於每天工作 8 小時這個規定是關閉的,但是你什麼時候來、什麼時候走時開放的。早來早走,晚來晚走。

開閉原則的核心思想就是面向抽象編程,接下來我們來看一段代碼。

以咕泡學院的課程體系爲例,首先創建一個課程接口 ICourse:

public interface ICourse {
    Integer getId();

    String getName();

    Double getPrice();
}

整個課程生態有 Java 架構、大數據、人工智能、前端、軟件測試等,我們來創建一個 Java 架構課程的類 JavaCourse:

public class JavaCourse implements ICourse {
    private final Integer id;
    private final String name;
    private final Double price;

    public JavaCourse(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Double getPrice() {
        return price;
    }
}

現在我們要給 Java 架構課程做活動,價格優惠。如果修改 JavaCourse 中的 getPrice( ) 方法,則存在一定的風險,可能影響其他方法的調用結果。我們如何在不修改原有代碼的前提下,實現價格優惠這個功能呢?現在,我們再寫一個處理優惠邏輯的類 JavaDiscountCourse(思考一下爲什麼要叫 JavaDiscountCourse,而不叫 DiscountCourse):

public class JavaDiscountCourse extends JavaCourse {

    public JavaDiscountCourse(Integer id, String name, Double price) {
        super(id, name, price);
    }

    public Double getOriginPrice() {
        return super.getPrice();
    }

    public Double getPrice() {
        return super.getPrice() * 0.61;
    }

}

回顧一下,簡單看一下類結構圖,如下圖所示。

image

1.2 依賴倒置原則

依賴倒置原則(Dependence Inversion Principle,DIP)是指設計代碼結構時,高層模塊不應該依賴低層模塊,二者都應該依賴其抽象。抽象不應該依賴細節,細節應該依賴抽象。通過依賴倒置,可以減少類與類之間的耦合性,提高系統的穩定性,提高代碼的可讀性和可維護性,並且能夠降低修改程序說造成的風險。接下來看一個案例,還是以 Course(課程)爲例,先來創建一個類 Tom:

public class Tom {
    public void studyJavaCourse() {
        System.out.println("Tom在學習Java課程");
    }

    public void studyPythonCourse() {
        System.out.println("Tom在學習Python課程");
    }
}

來調用一下:

    public static void main(String[] args) {
        Tom tom = new Tom();
        tom.studyJavaCourse();
        tom.studyPythonCourse();
    }

Tom 熱愛學習,目前正在學習 Java 課程和 Python 課程。大家都知道,學習也是會上癮的。隨着學習興趣的“暴漲”,現在 Tom 還想學習 AI(人工智能)的課程。這時候,因爲業務擴展,要從低層到高層(調用層)依次修改代碼。在 Tom 類中增加 studyAiCourse()方法,在高層也要追加調用。如此一來,系統發佈以後,實際上是非常不穩定的,在修改代碼的同時也會帶來意想不到的風險。接下來我們優化代碼,創建一個課程抽象類 ICourse 接口:

public interface ICourse {
    void study();
}

然後編寫 JavaCourse 類:

public class JavaCourse implements ICourse {
    @Override
    public void study() {
        System.out.println("Tom在學習Java課程");
    }
}

再實現 PythonCourse 類:

public class PythonCourse {
    public void study() {
        System.out.println("Tom在學習Python課程");
    }
}

修改 Tom 類:

public class Tom {
    public void study(ICourse course) {
        course.study();
    }
}

來看調用代碼:

    public static void main(String[] args) {
        Tom tom = new Tom();
        tom.study(new JavaCourse());
        tom.study(new PythonCourse());
    }

這時候再來看代碼,Tom 的興趣無論怎麼暴漲,對於新的課程,只需要新建一個類,通過傳參的方式告訴 Tom,而不需要修改底層代碼。實際上這是一種大家非常熟悉的方式,叫依賴注入。注入的方式還有構造器方式和 Setter 方式。我們來看構造器注入方式:

public class Tom {
    private final ICourse course;

    public Tom(ICourse course) {
        this.course = course;
    }

    public void study() {
        course.study();
    }
}

看調用代碼:

    public static void main(String[] args) {
        Tom tom = new Tom(new JavaCourse());
        tom.study();
    }

根據構造器方式注入,在調用時,每次都要創建實例。如果 Tom 是全局單例,則我們就只能選擇用 Setter 方式來注入,繼續修改 Tom 類的代碼:

public class Tom {
    private ICourse course;

    public void setCourse(ICourse course) {
        this.course = course;
    }

    public void study() {
        course.study();
    }
}

看調用代碼:

    public static void main(String[] args) {
        Tom tom = new Tom();
        tom.setCourse(new JavaCourse());
        tom.study();
        tom.setCourse(new PythonCourse());
        tom.study();
    }

現在我們再來看最終的類圖,如下圖所示。

image

大家要切記:以抽象爲基準比以細節爲基準搭建起來的架構要穩定得多,因此在拿到需求之後,要面向接口編程,先頂層再細節地設計代碼結構。

1.3 單一職責原則

單一職責(Simple Responsibility Pinciple,SRP)是指不要存在多於一個導致類變更的原因。假設我們有一個類負責兩個職責,一旦發生需求變更,修改其中一個職責的邏輯代碼,有可能導致另一個職責的功能發生故障。這樣一來,這個類就存在兩個導致類變更的原因。如何解決這個問題呢?將兩個職責用兩個類實現,進行解耦。後期需求變更維護互不影響。這樣的設計,可以降低類的複雜度,提高類的可讀性,提高系統的可維護性,降低變更引起的風險。總體來說,就是一個類、接口或方法只負責一項職責。

接下來,我們來看代碼實例,我們的課程有直播課和錄播課。直播課不能快進和快退,錄播課程可以任意地反覆觀看,功能職責不一樣。還是先創建一個 Course 類:

public class Course {
    public void study(String courseName) {
        if ("直播課".equals(courseName)) {
            System.out.println(courseName + "不能快進");
        } else {
            System.out.println(courseName + "可以反覆回看");
        }
    }
}

看調用代碼:

    public static void main(String[] args) {
        Course course = new Course();
        course.study("直播課");
        course.study("錄播課");
    }

從上面的代碼來看,Course 類承擔了兩種處理邏輯。假如現在要對課程進行加密,直播課程和錄播課程的加密邏輯不一樣,必須修改代碼。而修改代碼的邏輯勢必會相互影響,容易帶來不可控的風險。我們對職責進行解耦,來看代碼,分別創建兩個類:LiveCourse 和 ReplayCourse。

LiveCourse 類的代碼如下:

public class LiveCourse {
    public void study(String courseName) {
        System.out.println(courseName + "不能快進看");
    }
}

ReplayCourse 類的代碼如下:

public class ReplayCourse {
    public void study(String courseName) {
        System.out.println(courseName + "可以反覆回看");
    }
}

調用代碼如下:

    public static void main(String[] args) {
        LiveCourse liveCourse = new LiveCourse();
        liveCourse.study("直播課");

        ReplayCourse replayCourse = new ReplayCourse();
        replayCourse.study("錄播課");
    }

業務繼續發展,課程要做權限。沒有付費的學員可以獲取課程基本信息,已經付費的學員可以獲得視頻流,即學習全限。那麼在控制課程層面上至少有兩個職責。我們可以把展示職責和管理職責分離開來,都實現同一個抽象依賴。設計一個頂層接口,創建 ICourse 接口:

public interface ICourse {

    // 獲得基本信息
    String getCourseName();

    // 獲得視頻流
    byte[] getCourseVideo();

    // 學習課程
    void studyCourse();

    // 退款
    void refundCourse();
}

我們可以把這個接口拆成兩個接口:ICourseInfo 和 ICourseManager。

ICourseInfo 接口的代碼如下:

public interface ICourseInfo {
    // 獲得基本信息
    String getCourseName();

    // 獲得視頻流
    byte[] getCourseVideo();
}

ICourseManager 接口的代碼如下:

public interface ICourseManager {
    // 學習課程
    void studyCourse();

    // 退款
    void refundCourse();
}

來看一下類圖,如下圖所示。

CourseImpl

下面我們來看一下方法層面的單一職責設計。有時候我們會偷懶,把一個方法寫成下面這樣:

private void modifyUserInfo(String userName,String address){
	userName = "Tom";
	address = "Changsha";
}

還可能寫成這樣:

private void modiryUserInfo(String userName,String... fileds){
	userName = "Tom";
// address = "Changsha";
}
private void modifyUserInfo(String userName,String address,boolean bool){
	if(bool){

	}else{
	
	}

	userName = "Tom";
	address = "Changsha";
}

顯然,上面的 modifyUserInfo ( ) 方法承擔了多個職責,既可以修改 userName,也可以修改 address,甚至更多,明顯不符合單一職責。我們做如下修改,把這個方法拆分成兩個方法:

private void modifyUserName(String userName){
	userName = "Tom";
}
private void modifyAddress(String address){
	address = "Changsha";
}

修改之後,開發起來簡單,維護起來也容易。我們在實際開發中會有項目依賴、組合、聚合這些關係,還有項目的規模、週期、技術人員的水平、對進度的把控,很多類都不符合單一職責。但是,我們在編寫代碼的過程,儘可能地讓接口和方法保持單一職責,對項目後期的維護是有很大幫助的。

1.4 接口隔離原則

接口隔離原則(Interface Segregation Principle,ISP)是指用多個專門的接口,而不使用單一的總接口,客戶端不應該依賴它不需要的接口。這個原則指導我們在設計時應當注意以下幾點:

(1)一個類對另一個類的依賴應該建立在最小的接口之上。

(2)建立單一接口,不要建立龐大臃腫的接口。

(3)儘量細化接口,接口中的方法儘量少(不是越少越好,一定要適度)。

接口隔離原則符合我們常說的高內聚、低耦合的設計思想,可以使類具有很好的可讀性、擴展性和可維護性。我們在設計接口的時候,要多花時間去思考,要考慮業務模型,包括對以後有可能發生變化的地方還有做一些預判。所以,對於抽象、對於業務模型的理解是非常重要的。下面我們來看一段代碼,對一個動物行爲進行抽象描述。

IAminal 接口的代碼如下:

public interface IAnimal {
    void eat();

    void walk();

    void sleep();
}

Bird 類的代碼如下:

public class Bird implements IAnimal {
    @Override
    public void eat() {
    
    }

    @Override
    public void walk() {

    }

    @Override
    public void sleep() {

    }
}

Dog 類的代碼如下:

public class Dog implements IAnimal{
    @Override
    public void eat() {
    
    }

    @Override
    public void walk() {

    }

    @Override
    public void sleep() {

    }
}

可以看出,Bird 的 swim()方法可能只能空着,但 Dog 的 fly()方法顯然是不可能的。這時候,我們針對不同動物行爲來設計不同的接口,分別設計 IEatAnimal、IFlyAnimal 和 ISwimAninal 接口,來看代碼。

IEatAnimal 接口的代碼如下:

public interface IEatAnimal {
    void eat();
}

IFlyAnimal 接口的代碼如下:

public interface IFlyAnimal {
    void fly();
}

ISwimAnimal 接口的代碼如下:

public interface ISwimAnimal {
    void swim();
}

Dog 只實現 IEatAnimal 和 ISwimAnimal 接口,代碼如下:

public class Dog implements ISwimAnimal, IEatAnimal {
    @Override
    public void eat() {

    }

    @Override
    public void swim() {

    }
}

來看一下兩種類圖的對比,如下圖所示,還是非常清晰明瞭的。

image

1.5 迪米特原則

迪米特原則(Lay of Demeter,LoD)是指一個對象應該對其他對象保持最少得了解,又叫最少知道原則(Least Knowledge Principle,LKP),儘量降低類與類之間的耦合度。迪米特原則主要強調:只和朋友交流,不和陌生人說話。出現在成員變量、方法的輸入、輸出參數中的類都可以稱爲成員朋友類,而出現在方法體內部的類不屬於朋友類。

現在來設計一個權限系統,Boss 需要查看目前發佈在線上的課程數量。這時候,Boss 要找到 TeamLeader 去進行統計,TeamLeader 再把統計結果告訴 Boss。接下來我們還是來看代碼。

Course 類的代碼如下:

public class Course {
}

TeamLeader 類的代碼如下:

public class TeamLeader {
    public void checkNumberOfCourses(List<Course> courseList) {
        System.out.println("目前已發佈的課程數量爲:" + courseList.size());
    }
}

Boss 類的代碼如下:

public class Boss {
    public void commandCheckNumber(TeamLeader teamLeader) {
        // 模擬Boss一頁一頁往下翻頁,TeamLeader實時統計
        List<Course> courseList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            courseList.add(new Course());
        }
        teamLeader.checkNumberOfCourses(courseList);
    }
}

測試代碼如下:

    public static void main(String[] args) {
        Boss boss = new Boss();
        TeamLeader teamLeader = new TeamLeader();
        boss.commandCheckNumber(teamLeader);
    }

寫到這裏,其實功能都已經實現,代碼看上去也沒什麼問題。根據迪米特原則,Boss 只想要結果,不需要跟 Course 直接交流。而 TeamLeader 統計需要引用 Course 對象。Boss 和 Course 並不是朋友,從下面的類圖就可以看出來。

image

下面對代碼進行改造。

TeamLeader 類的代碼如下:

public class TeamLeader {
    public void checkNumberOfCourses() {
        List<Course> courseList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            courseList.add(new Course());
        }
        System.out.println("目前已發佈的課程數量是:" + courseList.size());
    }
}

Boss 類的代碼如下:

public class Boss {
    public void commandCheckNumber(TeamLeader teamLeader) {
        teamLeader.checkNumberOfCourses();
    }
}

再來看下面的類圖,Course 和 Boss 已經沒有關聯了。

image

學習軟件設計原則,千萬不能形成強迫症。碰到業務複雜的場景,我們需要隨機應變。

1.6 里氏替換原則

里氏替換原則(Liskov Substitution Principle,LSP)是指如果對每一個類型爲 T1 的對象 O1,都有類型爲 T2 的對象O2,使得以 T1 定義的所有程序 P 在所有的對象 O1 都替換成 O2 時,程序 P 的行爲沒有發生變化,那麼類型 T2 是類型 T1 的子類型。

這個定義看上去還是比較抽象的,我們重新理解一下。可以理解爲一個軟件實體如果適用於一個父類,那麼一定適用於其子類,所用引用父類的地方必須能透明地使用其子類的對象,子類對象能夠替換父類對象,而程序邏輯不變。根據這個理解,引申含義爲:子類可以擴展父類的功能能,但不能改變父類原有的功能。

(1)子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法。

(2)子類可以增加自己特有的方法。

(3)當子類的方法重載父類的方法時,方法的前置條件(即方法的輸入/入參)要比父類方法的輸入參數更寬鬆。

(4)當子類的方法實現父類的方法時(重寫/重載或實現抽象方法),方法的後置條件(即方法的輸出/返回值)要比父類更嚴格或與父類一樣。

在講開閉原則的時候我埋下了一個伏筆,在獲取折扣時重寫覆蓋了父類的 getPrice()方法,增加了一個獲取原價格的方法 getOriginPrice(),顯然就違背了里氏替換原則。我們修改一下代碼,不應該覆蓋 getPrice()方法,增加 getDiscountPrice()方法:

public class JavaDiscountCourse extends JavaCourse {
    public JavaDiscountCourse(Integer id, String name, Double price) {
        super(id, name, price);
    }

    public Double getDiscountPrice() {
        return super.getPrice() * 0.61;
    }
}

使用里氏替換原則有以下優點:

(1)約束繼承氾濫,是開閉原則的一種體現。

(2)加強程序的健壯性,同時變更時也可以做到非常好的兼容性,提高程序的可維護性和擴展性,降低需求變更時引入的風險。

現在來描述一個經典的業務場景,用正方形、矩形和四邊形的關係說明裏氏替換原則,我們都知道正方形是一個特殊的長方形,所以就可以創建一個父類 Rectangle:

public class Rectangle {
    private long height;
    private long width;

    public long getWidth() {
        return width;
    }

    public void setWidth(long width) {
        this.width = width;
    }

    public long getHeight() {
        return height;
    }

    public void setHeight(long height) {
        this.height = height;
    }
}

創建正方形類 Square 繼承 Rectangle 類:

public class Square extends Rectangle {
    private long length;

    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    @Override
    public long getWidth() {
        return length;
    }

    @Override
    public void setWidth(long width) {
        setLength(width);
    }

    @Override
    public long getHeight() {
        return getLength();
    }

    @Override
    public void setHeight(long height) {
        setLength(height);
    }
}

在測試類中創建 resize()方法,長方形的寬應該大於等於高,我們讓高一直自增,直到高等於寬,變成正方形:

    public static void resize(Rectangle rectangle) {
        while (rectangle.getWidth() >= rectangle.getHeight()) {
            rectangle.setHeight(rectangle.getHeight() + 1);
            System.out.println("width: " + rectangle.getWidth() + ", height: " + rectangle.getHeight());
        }
        System.out.println("resize方法結束\n" +
                "width: " + rectangle.getWidth() + ", height: " + rectangle.getHeight());
    }

測試代碼如下:

    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(20);
        rectangle.setHeight(10);
        resize(rectangle);
    }

運行結果如下所示:

width: 20, height: 11
width: 20, height: 12
width: 20, height: 13
width: 20, height: 14
width: 20, height: 15
width: 20, height: 16
width: 20, height: 17
width: 20, height: 18
width: 20, height: 19
width: 20, height: 20
width: 20, height: 21
resize方法結束
width: 20, height: 21

我們發現高比寬還大了,這在長方形中是一種非常正常的情況。現在我們把 Rectangle 類替換成它的子類 Square,修改測試代碼:

    public static void main(String[] args) {
        Square square = new Square();
        square.setLength(10);
        resize(square);
    }

上述代碼運行時出現了死循環,違背了里氏替換原則,將父類替換爲子類後,程序運行結果沒有達到預期。因此,我們的代碼設計是存在一定風險的。里氏替換原則只存在於父類於子類之間,約束繼承氾濫。我們再來創建一個基於長方形與正方形共同的抽象四邊形接口 Quadrangle:

public interface Quadrangle {
    long getWidth();

    long getHeight();
}

修改長方形類 Rectangle:

public class Rectangle implements Quadrangle {
    private long height;
    private long width;

    @Override
    public long getWidth() {
        return width;
    }

    public void setWidth(long width) {
        this.width = width;
    }

    @Override
    public long getHeight() {
        return height;
    }

    public void setHeight(long height) {
        this.height = height;
    }
}

修改正方形類 Square:

public class Square implements Quadrangle {
    private long length;

    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    @Override
    public long getWidth() {
        return length;
    }

    @Override
    public long getHeight() {
        return length;
    }
}

此時,如果我們把 resize()方法的參數換成四邊形接口 Quadrangle,方法內部就會報錯。因爲正方形類 Square 已經沒有了 setWidth()和 setHeight()方法。因此,爲了約束繼承氾濫,resize()方法的參數只能用 Rectangle 類。當然,我們在後面的設計模式的內容中還會繼續深入講解。

1.7 合成複用原則

合成複用原則(Composite/Aggregate Reuse Principle,CARP)是指儘量使用對象組合(has-a)/聚合(contains-a)而不是繼承關係達到軟件複用的目的。可以使系統更加靈活,降低類與類之間的耦合度,一個類的變化對其他類造成的影響相對較少。

繼承叫作白箱複用,相當於把所有的實現細節暴露給子類。組合/聚合稱爲黑箱複用,我們是無法獲取到類以外的對象的實現細節的。雖然我們要根據具體的業務場景來做代碼設計,但也需要遵循 OOP 模型。以數據庫操作爲例,先來創建 DBConnection 類:

public class DBConnection {
    public String getConnection() {
        return "MySQL數據庫連接";
    }
}

創建 ProductDao 類:

public class ProductDao {
    private DBConnection dbConnection;

    public void setDbConnection(DBConnection dbConnection) {
        this.dbConnection = dbConnection;
    }

    public void addProduct() {
        String conn = dbConnection.getConnection();
        System.out.println("使用" + conn + "增加產品");
    }
}

這是一種非常典型的合成複用原則的應用場景。但是,就目前的設計來說,DBConnection 還不是一種抽象,不便於系統擴展。目前的系統支持 MySQL 數據庫連接,假設業務發生變化,數據庫操作層要支持 Oracle 數據庫。當然,我們可以在 DBConnection 中增加對 Oracle 數據庫的支持,但是這違背了開閉原則。其實,我們可以不修改 Dao 的代碼,而將 DBConnection 修改爲“abstract”的,來看代碼:

public abstract class DBConnection {
    public abstract String getConnection();
}

然後將 MySQL 的邏輯抽離:

public class MySQLConnection extends DBConnection {
    @Override
    public String getConnection() {
        return "MySQL數據庫連接";
    }
}

再創建 Oracle 支持:

public class OracleConnection extends DBConnection {
    @Override
    public String getConnection() {
        return "Oracle數據庫連接";
    }
}

具體選擇交給應用層,來看一下類圖,如下所示。

image

1.8 設計原則總結

學習設計原則是學習設計模式的基礎。在實際開發過程中,並不要求所有代碼都遵循設計原則,我們要考慮人力、時間、成本、質量,不能可以追求完美,但要在適當的場景遵循設計原則,這體現的是一種平衡取捨,可以幫助我們設計出更加優雅的代碼結構。

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