前言
軟體設計原則中有一條很關鍵的原則是開閉原則,就是所謂的對擴充套件開放,對修改關閉。個人覺得這條原則是非常重要的,直接關係到你的設計是否具備良好的擴充套件性,但也是相對比較難以理解和掌握的,究竟怎樣的程式碼改動才被定義為“擴充套件”?怎樣的程式碼改動才被定義為“修改”?怎麼才算滿足或違反“開閉原則”?別急,本文將展開詳細闡述。
歡迎關注微信公眾號「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()
方法邏輯中新增了介面超時處理邏輯。
這個做法有啥問題呢?
- 你竟然調整了
check()
方法的引數,所有原來呼叫的地方都要修改,如果很多,這不得恨死你呀。 - 修改了
check()
函式,相應的單元測試都需要修改。
像這種情況,我們就是完全對原來的程式碼進行修改,不符合開閉原則。
做法二
這時候,你開動腦瓜,大刀闊斧的進行了重構。
- 引入了
ApiStatInfo
類,封裝了check
的入參資訊。
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}
- 引入
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, "...");
}
}
- 修改
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);
}
}
}
- 上層單例類
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);
}
}
終於你重構完一開始的邏輯了, 在這個基礎上,針對每秒鐘介面超時請求個數超過某個最大閾值就告警這個需求,我們又該如何改動程式碼呢?
ApiStatInfo
類新增新欄位
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
private long timeoutCount; // 改動一:新增新欄位
}
- 新增新的處理器類
TimeoutAlertHandler
public class TimeoutAlertHandler extends AlertHandler {// 省略程式碼...}
- 修改
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));
}
//... 省略其他未改動程式碼
}
- 呼叫告警處理的地方設定引數
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旭陽」交流和學習
更多學習資料請移步:程式設計師成神之路