軟體設計中最關鍵的「開閉原則」,究竟指什麼呢?

2022-12-23 12:01:29

前言

軟體設計原則中有一條很關鍵的原則是開閉原則,就是所謂的對擴充套件開放,對修改關閉。個人覺得這條原則是非常重要的,直接關係到你的設計是否具備良好的擴充套件性,但也是相對比較難以理解和掌握的,究竟怎樣的程式碼改動才被定義為「擴充套件」?怎樣的程式碼改動才被定義為「修改」?怎麼才算滿足或違反「開閉原則」?別急,本文將展開詳細闡述。

歡迎關注微信公眾號「JAVA旭陽」交流和學習

舉個例子好理解

為了更好的解釋清楚,直接上例子,這是監控告警的類,Alert是監控告警類,AlertRule儲存告警規則資訊,Notification是告警通知類。

public class Alert { 
    // 儲存告警規則
    private AlertRule rule; 
    // 告警通知類, 支援郵件、簡訊、微信、手機等多種通知渠道。
    private Notification notification;

	public Alert(AlertRule rule, Notification notification) { 
        this.rule = rule; 
        this.notification = notification; 
    }

    // 校驗是否進行告警
    public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
        // 計算請求的tps
        long tps = requestCount / durationOfSeconds; 
        // 如果tps大於閾值進行告警
        if (tps > rule.getMatchedRule(api).getMaxTps()) { 
             notification.notify(NotificationEmergencyLevel.URGENCY, "..."); 
        } 
        // 如果錯誤次數大於規則閾值進行告警
        if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) { 
            notification.notify(NotificationEmergencyLevel.SEVERE, "..."); 
        } 
    }

}

這個告警Alert的核心業務邏輯主要集中在check()函數中

  • 當介面的 TPS 超過某個預先設定的最大值時,觸發告警,傳送通知。
  • 當介面請求出錯數大於某個最大允許值時,就會觸發告警,通知介面的相關負責人或者團隊。

現在來了個新的需求,當每秒鐘介面超時請求個數,超過某個預先設定的最大閾值時,我們也要觸發告警傳送通知。這個時候,我們該如何改動程式碼呢?

做法一

這簡單,你可能直接開工就寫出下面的程式碼了。

public class Alert { 
    // ... 省略 AlertRule/Notification 屬性和建構函式... 
    // 改動一:新增引數 timeoutCount 
    public void check(String api, long requestCount, long errorCount, long timeoutCount) {
    long tps = requestCount / durationOfSeconds; 
    if (tps > rule.getMatchedRule(api).getMaxTps()) { 
        notification.notify(NotificationEmergencyLevel.URGENCY, "..."); 
    } 
    if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) { 
        notification.notify(NotificationEmergencyLevel.SEVERE, "..."); 
    }
    // 改動二:新增介面超時處理邏輯
    long timeoutTps = timeoutCount / durationOfSeconds;
    if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
        notification.notify(NotificationEmergencyLevel.URGENCY, "..."); 
    } 
}

修改點如下:

  • check()方法新增了timeoutCount引數。
  • check()方法邏輯中新增了介面超時處理邏輯。

這個做法有啥問題呢?

  1. 你竟然調整了check()方法的引數,所有原來呼叫的地方都要修改,如果很多,這不得恨死你呀。
  2. 修改了 check()函數,相應的單元測試都需要修改。

像這種情況,我們就是完全對原來的程式碼進行修改,不符合開閉原則。

做法二

這時候,你開動腦瓜,大刀闊斧的進行了重構。

  1. 引入了ApiStatInfo類,封裝了check的入參資訊。
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
	private String api; 
    private long requestCount; 
    private long errorCount;
    private long durationOfSeconds;  
} 
  1. 引入 handler 的概念,將 if 判斷邏輯分散在各個 handler
public abstract class AlertHandler { 
    protected AlertRule rule;
    protected Notification notification; 
    
    public AlertHandler(AlertRule rule, Notification notification) {
		this.rule = rule; 
        this.notification = notification; 
    } 
    public abstract void check(ApiStatInfo apiStatInfo); 
}

// TPS的告警處理器
public class TpsAlertHandler extends AlertHandler {
    public TpsAlertHandler(AlertRule rule, Notification notification) { 
        super(rule, notification); 
    } 
    
    @Override
    public void check(ApiStatInfo apiStatInfo) { 
        long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds;
        if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) { 
            notification.notify(NotificationEmergencyLevel.URGENCY, "..."); 
        }
    } 
}

// 錯誤次數告警處理器
public class ErrorAlertHandler extends AlertHandler { 
    public ErrorAlertHandler(AlertRule rule, Notification notification){ 
        super(rule, notification); 
    } 
    @Override
    public void check(ApiStatInfo apiStatInfo) { 
        if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()) 
            notification.notify(NotificationEmergencyLevel.SEVERE, "..."); 
    } 
} 
  1. 修改Alert類,新增各種告警處理器。
public class Alert { 
    private List<AlertHandler> alertHandlers = new ArrayList<>(); 
    public void addAlertHandler(AlertHandler alertHandler) { 
        this.alertHandlers.add(alertHandler); 
    } 
    
    public void check(ApiStatInfo apiStatInfo) { 
        // 遍歷各種告警處理器
        for (AlertHandler handler : alertHandlers) { 
            handler.check(apiStatInfo); 
        } 
    } 
}
  1. 上層單例類ApplicationContext建立、組裝、使用Alert
public class ApplicationContext { 
    private AlertRule alertRule; 
    private Notification notification; 
    private Alert alert; 
    
    public void initializeBeans() {
        alertRule = new AlertRule(/*. 省略引數.*/); // 省略一些初始化程式碼
		notification = new Notification(/*. 省略引數.*/); // 省略一些初始化程式碼
		alert = new Alert();
        // 新增告警處理器
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); 
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); 
    } 

    // 返回告警器Alert
    public Alert getAlert() { return alert; }

	// 餓漢式單例
    private static final ApplicationContext instance = new ApplicationContext(); 
   
    private ApplicationContext() { 
        instance.initializeBeans(); 
    } 
    
    public static ApplicationContext getInstance() { 
        return instance; 
    }
}

public class Demo { 
    public static void main(String[] args) { 
        ApiStatInfo apiStatInfo = new ApiStatInfo();  // ... 省略設定 apiStatInfo 資料值的程式碼
		// 進行告警操作
        ApplicationContext.getInstance().getAlert().check(apiStatInfo); 
    } 
}

終於你重構完一開始的邏輯了, 在這個基礎上,針對每秒鐘介面超時請求個數超過某個最大閾值就告警這個需求,我們又該如何改動程式碼呢?

  1. ApiStatInfo類新增新欄位
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
	private String api; 
    private long requestCount; 
    private long errorCount; 
    private long durationOfSeconds; 
    private long timeoutCount; // 改動一:新增新欄位
 } 
  1. 新增新的處理器類TimeoutAlertHandler
public class TimeoutAlertHandler extends AlertHandler {// 省略程式碼...}
  1. 修改ApplicationContext類新增註冊TimeoutAlertHandler
public class ApplicationContext {
    ....
	public void initializeBeans() { 
        alertRule = new AlertRule(/*. 省略引數.*/); // 省略一些初始化程式碼
		notification = new Notification(/*. 省略引數.*/); // 省略一些初始化程式碼
		alert = new Alert(); 
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification)); 
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification)); 
        // 改動三:註冊 handler 
        alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification)); 
    } 
    //... 省略其他未改動程式碼
}
  1. 呼叫告警處理的地方設定引數
public class Demo { 
    public static void main(String[] args) { 
        ApiStatInfo apiStatInfo = new ApiStatInfo(); 
        // ... 省略 apiStatInfo 的 set 欄位程式碼
        apiStatInfo.setTimeoutCount(289); // 改動四:設定 tiemoutCount 值 
        ApplicationContext.getInstance().getAlert().check(apiStatInfo); 
    }
}

有沒有發現,重構完成以後程式碼的擴充套件性特別好。如果又有新的告警處理,我只需要新加一個handler類, 並且註冊進去,而不用修改原來的check邏輯,也只需要為新增的類寫單元測試。這種情況就是很符合開閉原則的。

可能你會糾結我也明明修改程式碼了,怎麼就是對修改關閉了呢?

  • 第一個修改的地方是向 ApiStatInfo 類中新增新的屬性 timeoutCount。實際上,開閉原則可以應用在不同粒度的程式碼中,可以是模組,也可以類,還可以是方法(及其屬性)。同樣一個程式碼改動,在粗程式碼粒度下,被認定為「修改」,在細程式碼粒度下,又可以被認定為「擴充套件」。比如這裡的新增屬性和方法相當於修改類,在類這個層面,這個程式碼改動可以被認定為「修改」;但這個程式碼改動並沒有修改已有的屬性和方法,在方法(及其屬性)這一層面,它又可以被認定為「擴充套件」。
  • 另外一個修改的地方是在 ApplicationContext 類的 initializeBeans() 方法中,往 alert 物件中註冊新的 timeoutAlertHandler;在使用 Alert 類的時候,需要給check() 函數的入參 apiStatInfo 物件設定 timeoutCount 的值。首先說明新增一個新功能,不可能任何模組、類、方法的程式碼都不「修改」,這個是不可能的。主要看修改的是什麼內容,這裡的修改是上層的程式碼,而非核心下層的程式碼,所以是可以接受的。

如何理解開閉原則?

前面通過一個例子詳細闡述了開閉原則的核心思想,對修改關閉,對擴張開放,這裡再次做一個總結,讓大家進一步理解開閉原則。

新增一個新的功能,應該是通過在已有程式碼基礎上擴充套件程式碼(新增模組、類、方法、屬性等),而非修改已有程式碼(修改模組、類、方法、屬性等)的方式來完成。關於定義,我們有兩點要注意。第一點是,開閉原則並不是說完全杜絕修改,而是以最小的修改程式碼的代價來完成新功能的開發,而且儘量修改的是上層的程式碼,而非底層或者和核心邏輯的程式碼。第二點是,同樣的程式碼改動,在粗程式碼粒度下,可能被認定為「修改」;在細程式碼粒度下,可能又被認定為「擴充套件」,比如對於一個類新增一個欄位或者方法,在某些情況下我們也可以認為是擴充套件。

開閉原則一定是好的嗎?

開閉原則並不是沒有條件的。有些情況下,程式碼的擴充套件性會跟可讀性相沖突。比如,我們之前舉的 Alert 告警的例子。為了更好地支援擴充套件性,我們對程式碼進行了重構,重構之後的程式碼要比之前的程式碼複雜很多,理解起來也更加有難度。很多時候,我們都需要在擴充套件性和可讀性之間做權衡。在某些場景下,程式碼的擴充套件性很重要,我們就可以適當地犧牲一些程式碼的可讀性;在另一些場景下,程式碼的可讀性更加重要,那我們就適當地犧牲一些程式碼的可延伸性。

在我們之前舉的 Alert 告警的例子中,如果告警規則並不是很多、也不復雜,那 check() 函數中的 if 語句就不會很多,程式碼邏輯也不復雜,程式碼行數也不多,那最初的第一種程式碼實現思路簡單易讀,就是比較合理的選擇。相反,如果告警規則很多、很複雜,check()函數的 if 語句、程式碼邏輯就會很多、很複雜,相應的程式碼行數也會很多,可讀性、可維護性就會變差,那重構之後的第二種程式碼實現思路就是更加合理的選擇了。總之,這裡沒有一個放之四海而皆準的參考標準,全憑實際的應用場景來決定。

怎麼做到「對擴充套件開放、修改關閉」?

開閉原則,本質上就是讓你寫的程式擴充套件性好,這需要你平時慢慢的積累和學習,需要時刻具備擴充套件意識、抽象意識、封裝意識。這些「潛意識」可能比任何開發技巧都重要。平時需要多多思考,這段程式碼未來可能有哪些需求變更、如何設計程式碼結構,事先留好擴充套件點,以便在未來需求變更的時候,不需要改動程式碼整體結構、做到最小程式碼改動的情況下,新的程式碼能夠很靈活地插入到擴充套件點上,做到「對擴充套件開放、對修改關閉」。但是切記不要過度設計,不然維護十分困難,也會造成災難性後果。

至於具體的方法論層面,我十分推薦大家要面向介面程式設計,怎麼理解呢?

比如現在有個業務需求是將訊息傳送到kafka,你可能直接在業務程式碼中呼叫kafka的API傳送訊息,這就是面向實現程式設計,這樣非常不好,萬一以後不用kafka,該用rocketMQ了怎麼辦?這時候,我們是不是定義一個發訊息的介面,讓上層直接呼叫介面即可。

總結

本文講解了軟體設計中個人認為最重要的一個設計原則,開閉原則,即對擴充套件開放,對修改關閉,這會指導我們寫出擴充套件性良好的程式碼,設計出擴充套件性更好的架構。

歡迎關注微信公眾號「JAVA旭陽」交流和學習
更多學習資料請移步:程式設計師成神之路