復(fù)雜系統(tǒng)設(shè)計原則與案例
一、復(fù)雜是軟件的本質(zhì)屬性
1.1 復(fù)雜是軟件的本質(zhì)屬性
互聯(lián)網(wǎng)經(jīng)歷了十多年的高速發(fā)展,各個領(lǐng)域方向的系統(tǒng)都已經(jīng)歷了多次升級迭代,大家在經(jīng)手這些軟件系統(tǒng)時,不免感嘆現(xiàn)在軟件系統(tǒng)的復(fù)雜度,其實軟件復(fù)雜性是軟件固有的屬性,這種固有的復(fù)雜性主要由4個方面的原因造成的:
-
問題域的復(fù)雜性
-
管理開發(fā)過程的復(fù)雜性
-
隨處可變的靈活性
-
描繪離散系統(tǒng)行為的問題
上面每一個方面都有極大的挑戰(zhàn),以「問題域的復(fù)雜性」為例,現(xiàn)在以微服務(wù)架構(gòu)設(shè)計思路下的大型系統(tǒng)中,動不動就幾十個應(yīng)用,組合在一起就是一個復(fù)雜的系統(tǒng),而每個人只負責其中一小部分,想要了解系統(tǒng)全部的運行狀況是很難的,哪怕一個子系統(tǒng),它包含的業(yè)務(wù)規(guī)則就巨多,因此說軟件復(fù)雜是它的本質(zhì)屬性。
1.2 對業(yè)務(wù)認知復(fù)雜度是影響軟件復(fù)雜性的重要因素
影響軟件復(fù)雜度的因素有很多,其中「認知復(fù)雜度」占據(jù)著很重要的因素。一提到復(fù)雜性,我們腦海里會浮出各種各樣的印象:應(yīng)用數(shù)多、代碼行數(shù)超過百萬級、業(yè)務(wù)規(guī)則復(fù)雜等,這些復(fù)雜度從本質(zhì)上來看是認知復(fù)雜度超過了正常人的認知范圍,比如看百萬行級的代碼與看100行代碼相比,維護10個應(yīng)用與維護1個應(yīng)用相比,兩個復(fù)雜度不是在同一個數(shù)量級上,有可能是指數(shù)級提升。認知復(fù)雜度是軟件的本質(zhì)復(fù)雜度,從根本上規(guī)避不了,只能去理解、消化吸收,我們能做的是在理解的基礎(chǔ)上去發(fā)現(xiàn)共性的「規(guī)律」,將這些「規(guī)律」抽象出來,讓應(yīng)用層開發(fā)變得簡單。
舉當前的例子,目前負責的是電商板塊的物流資金結(jié)算業(yè)務(wù)系統(tǒng),最開始面對的業(yè)務(wù)認知復(fù)雜度非常高,它關(guān)聯(lián)電商交易、支付、營銷、結(jié)算、資金等領(lǐng)域,依賴業(yè)務(wù)將近100張離線表,除了要理解電商業(yè)務(wù)鏈路外,還要站在物流,財務(wù),風控等視角把這些數(shù)據(jù)有序地組織起來,復(fù)雜度一下子就上升上來了,新人至少要花3個月的時間去消化這些業(yè)務(wù)知識。 當進來做了一些需求開發(fā)后,慢慢發(fā)現(xiàn)了一些規(guī)律,利用發(fā)現(xiàn)的這些規(guī)律有助于提升需求溝通、開發(fā)的效率。
二、應(yīng)對復(fù)雜性的設(shè)計方法
2.1 把握套路是應(yīng)對復(fù)雜性的根本方法
「規(guī)律」是日常開發(fā)中發(fā)現(xiàn)有共性的地方,往后再遇到可以同樣的問題可加速解決的效率。軟件復(fù)雜度伴隨著軟件研發(fā)開始就產(chǎn)生的問題,「設(shè)計原則」就是應(yīng)對復(fù)雜性過程中總結(jié)出來的規(guī)律。常見的設(shè)計原則有SOLID、GRASP、KISS、分層等,這些設(shè)計原則指導(dǎo)我們在面對復(fù)雜系統(tǒng)時應(yīng)該如何去設(shè)計。原則的東西,個人經(jīng)驗是建立自己的認知體系,需要有實事求是,學以致用的實踐態(tài)度。

2.2 通識規(guī)律
在經(jīng)典的設(shè)計原則之上,最終將設(shè)計原則歸類成三個方面:「職責分解」、「層次抽象」和「變化擴展」。
2.2.1 職責分離
對職責分離有兩點體會:一個是「你擁有什么信息就應(yīng)該承擔怎樣的職責」;另一個是「一個類只做一件事」。當我們在討論是否是貧血模型時,你可以用這個原則去檢驗,如果一類中的成員屬性操作放在另外一類中,大概率是不符合信息專家原則,舉一個簡單的例子,比如要計算物流訂單的運費金額,那么這個計算方法應(yīng)該是在訂單類中,而不是放在另外一個類中,因為訂單類中有訂單的單價和數(shù)量。
另一點是出自于SOLID的單一職責,它的原意是一個類只有一個變化的原因,一個類專注于做一件事的好處是可提升復(fù)用性和減少依賴,反之一個類耦合了不同的操作,修改的頻次就會變多,盡量少改動穩(wěn)定的部分,在系統(tǒng)穩(wěn)定性中有一個共性認知:故障的發(fā)生大概率與最近的發(fā)布有關(guān)。
職責分解最大的挑戰(zhàn)是一個職責到底要劃分到多細或多粗,只能說只做一件事或者只有一個變化這樣大的指導(dǎo)原則,更多的是我們在實踐中總結(jié)出來的經(jīng)驗,比如「變與不變分離」、「讀寫分離」、「配置域與執(zhí)行域分離」。
2.2.2 層次抽象
層次抽象是利用已發(fā)現(xiàn)的規(guī)律,讓往后的開發(fā)變得簡單,當我們在一線開發(fā)中,你會發(fā)現(xiàn)有一些規(guī)律,比如在日常開發(fā)中,發(fā)現(xiàn)開發(fā)主要涉及到與前端交互、業(yè)務(wù)邏輯處理和數(shù)據(jù)存儲,這樣就可以分成三層:「視圖層」、「業(yè)務(wù)邏輯層」和「數(shù)據(jù)訪問層」。
高層次依賴低層次,最高層次越具象,也會越簡單,舉一個例子,在傳統(tǒng)Servlet開發(fā)中,一般的步驟是獲取參數(shù)信息并轉(zhuǎn)成業(yè)務(wù)層的對象,再進行業(yè)務(wù)處理,雖然不同的業(yè)務(wù)處理邏輯是不一樣的,但參數(shù)獲取是具有共性的操作,在SpringMVC中,我們可以直接定義POJO去映射參數(shù),可以不用使用HttpServlet底層的操作去獲取參數(shù),這就是一種典型的層次抽象。
「層次特性」是復(fù)雜系統(tǒng)的固有屬性,需要我們不斷去探索,分層的確能極大地降低認知復(fù)雜度,相當是站在巨人的肩膀上看問題,利用已發(fā)現(xiàn)的規(guī)律辦事效率會高很多,如上文提到的財務(wù)核算,做多了就會發(fā)現(xiàn)就那幾種模式,當你沒有摸清里面的規(guī)律時,會覺得顯得很零散。
2.2.3 變化擴展
軟件如果沒有變化,也就不需要所謂的設(shè)計原則,一次性工程怎么快就怎么來,而現(xiàn)實中遇到最多的現(xiàn)象是需求不斷變化。變化擴展的挑戰(zhàn)不在于技術(shù),而是在于「怎么認知到哪里有變化」。常見變化擴展的技術(shù)有:配置項、接口、抽象類、攔截器、SPI、插件等,這些都是具體的解決手段,它們并不復(fù)雜,復(fù)雜在于哪里會有變化,這個是最難的。
認識到多少變化,它取決于認識的寬度,看到多少內(nèi)容會影響到系統(tǒng)設(shè)計,比如在SpringMVC中,我們最高常操作的是定義一個Controller,再在方法上寫一個RequestMapping注解,但在實際中,它還有另外的寫法,如實現(xiàn)Controller接口,正是有不同的場景和類型,處理上還有差別,此時就會有變化擴展的訴求。
2.3 軟件設(shè)計的6條經(jīng)驗
在經(jīng)典的設(shè)計原則之上,結(jié)合實踐過程中的得與失,總結(jié)了以下6條設(shè)計經(jīng)驗,為了更容易理解,下面的案例選用常用的開源框架剖析設(shè)計思想,方便與大家產(chǎn)生共鳴。
2.3.1 模板方法-在多變中找不變
當一個業(yè)務(wù)有多個場景,并且不同的場景處理既有共性的地方,也有差異性的地方時,此時最容易想到的方法是用「模板方法」固定共性的邏輯,差異性的邏輯放到子類中實現(xiàn)。在開源框架中,我們經(jīng)常見到這樣的設(shè)計思想,比如在SpringMVC中查找Handler的過程,不同的場景查找邏輯不一樣,最常見的是RequestMapping方式查找,它是在HandMapping接口類中定義getHandler方法。
public interface HandlerMapping {
HandlerExecutionChain getHandler(HttpServletRequest request)
throws Exception;
}
然后在抽象類AbstractHandlerMapping中定義模板方法,抽象方法又交由子類去實現(xiàn)。
public final HandlerExecutionChain getHandler(HttpServletRequest request)
throws Exception {
// 抽象方法,交由具體的子類實現(xiàn)
Object handler = getHandlerInternal(request);
if (handler == null) {
handler = getDefaultHandler();
}
if (handler == null) {
return null;
}
// 省略部分代碼
HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);
// 省略部分代碼
return executionChain;
}
在MyBatis框架中,Executor定義了增刪改查等方法,具體實現(xiàn)有如單條命令執(zhí)行、批量命令執(zhí)行等,模板方法定義在BaseExecutor類中,類結(jié)構(gòu)繼承關(guān)系如下所示,這也是一種最簡單的三層設(shè)計結(jié)構(gòu):接口類、抽象類、子類。

2.3.2 命令職責-業(yè)務(wù)鏈路查詢和復(fù)雜組裝
有一類業(yè)務(wù),它涉及「查詢」與「組裝」兩個操作,比如Spring中有Bean查詢操作,與之對應(yīng)的有Bean創(chuàng)建操作,這兩個職責是不一樣的,也有的稱之為「讀寫分離」或者「查詢與命令分離」,從本質(zhì)上講,它也遵循了接口單一職責。

2.3.3 配置域與執(zhí)行域分離-有面向用戶配置
有些業(yè)務(wù)前臺用戶能夠直接配置操作的,比如在SpringMVC中,我們配置一個Controller的請求可以配置不同的屬性,其中RequestMapping是直接面向用戶視角的配置操作,在配置域的內(nèi)容,是與現(xiàn)實操作一一映射的,RequestMapping對應(yīng)有一個類叫RequestMappingInfo,然而在執(zhí)行域,此時它就不需要配置域中的那么多信息,執(zhí)行過程只要對象和方法的信息即可,對應(yīng)有一個類中HandlerMethod,由此可見,配置域和執(zhí)行域兩個抽象的視角是不一樣的,一個是現(xiàn)實世界的直接映射,一個是偏底層執(zhí)行。
@RestController
public class UserController {
@RequestMapping(value = "/acquire", method = RequestMethod.GET)
public User getUser(@RequestParam("name") String name, @RequestParam("age") Integer age) {
return null;
}
}
RequestMappingHandlerMapping類結(jié)構(gòu)繼承關(guān)系如下圖所示。

再比如在Spring中,允許用戶配置自定義的編輯器、BeanPostProcessor處理器,也是由一個單獨的接口類ConfigurableBeanFactory表達的。
這樣的例子還有很多,比如BeanDefinition是面向配置域的,Bean是執(zhí)行域的,我們在定義Bean有很多的屬性,這些屬性信息在BeanDefinition類中定義,而在執(zhí)行過程中會生成一個對象,本質(zhì)上是一個Object。
2.3.4 封裝變化-業(yè)務(wù)有多樣變化
應(yīng)對變化的方法有很多,難的是要感知到變化并且封裝好變化,比如Spring Bean實例化后進行初始化,在此期間就有很多操作,如常見的Bean依賴注入、AOP代理等,Spring抽象出BeanPostProcessor擴展類,在Bean初始化前后做一些額外的擴展工作。
public interface BeanPostProcessor {
// 初始化前的操作
Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
// 初始化后的操作
Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
}
設(shè)計擴展點時一定要把握好度,粒度過細則擴展點數(shù)量非常多,在Spring中設(shè)計就比較好,對于開發(fā)而言,有兩個時機有明顯的擴展訴求,一個是在Bean掃描時,可以允許用戶自定義Bean,此時有BeanFactoryPostProcessor擴展接口;另一個是在Bean初始化時的擴展,對應(yīng)有BeanPostProcessor擴展接口。不管是Spring內(nèi)部使用,還是外部開發(fā),都是使用同樣的擴展。
2.3.5 責任鏈-業(yè)務(wù)流程型操作
業(yè)務(wù)型操作,有明顯的流程痕跡,比如前置檢查、協(xié)議組裝、接口調(diào)用等,節(jié)點與節(jié)點之間就構(gòu)成了一條鏈條,只不過平時寫代碼時我們是放在一個大的流程中實現(xiàn)的。在HttpClient中,對于請求,我們有不同的操作流程,比如重試、緩存、重定向、調(diào)用socket等操作,HttpClient使用責任鏈的模式。

鏈條上的每個節(jié)點都是獨立操作的,方便擴展,責任鏈核心是鏈的構(gòu)建和節(jié)點設(shè)計,這給平時寫流程型業(yè)務(wù)代碼提供了一種新的思路,大型系統(tǒng)中,有流程引擎,本質(zhì)來講它也是一條鏈,一個節(jié)點做完之后下一個節(jié)點繼續(xù)做,思想上大同小異。
2.3.6 合理抽象-復(fù)雜系統(tǒng)場景
抽象是應(yīng)對復(fù)雜場景的重要方法,這一點我們并不懷疑,最難的是要抽象什么去刻畫業(yè)務(wù),比如AOP切面編程,站在用戶視角,就是告訴他哪些類、哪些方法需要被增強什么共性業(yè)務(wù)邏輯,比如日志切面類、權(quán)限切面類等,AOP對它的抽象是「對指定的類和方法以某種方式織入特定的共性邏輯」。其中指定的類和方法抽象成切點,以某種方式抽象成通知。此時,你會發(fā)現(xiàn)它抽象出了一些概念出來,如切面、切點、通知。因此,對復(fù)雜業(yè)務(wù)場景,一定要有一套抽象的元數(shù)據(jù)去表征它,也即是領(lǐng)域模型,最高明的建模方法是下定義的方法,用一句簡明的話講清楚業(yè)務(wù)的結(jié)構(gòu)和功能。

系統(tǒng)是元素和元素間以某種關(guān)聯(lián)關(guān)系構(gòu)成的一種結(jié)構(gòu),復(fù)雜系統(tǒng)是構(gòu)成元素更多、關(guān)聯(lián)關(guān)系更復(fù)雜,核心還是要找到「結(jié)構(gòu)」,這種結(jié)構(gòu)也即是領(lǐng)域模型,好的領(lǐng)域模型可遇而不可求,是要花大量的時間去探尋它,突然有一天在你腦海里靈光一現(xiàn)就出來了,這種感覺很奇妙,因此,領(lǐng)域建模是非常依賴經(jīng)驗而非方法。
三、框架設(shè)計案例分析
有了上面的分析基礎(chǔ),再以SpringMVC DispatcherServlet為例,分析它的設(shè)計思想,它的結(jié)構(gòu)如下圖所示。

SpringMVC核心是對HttpServlet的封裝,在HttpServlet中有兩個重要的方法,一個是init()方法,一個是service()方法,init()方法是Servlet初始化時回調(diào)的方法,service()是處理請求時回調(diào)的方法。
在HttpServletBean類中,它重寫了HttpServlet init()方法,主要完成SpringMVC子容器初始化的過程。FrameworkServlet類主要重寫了service()方法,處理實際的如GET、POST請求,但它只是定義了一個抽象的doService()方法,實際處理過程是在DispatcherServlet類中,分發(fā)的Servlet是攔截所有的請求,然后匹配到目標Handler執(zhí)行。
在DispatcherServlet類的設(shè)計中,體現(xiàn)出了「職責分離」和「變化擴展」的設(shè)計思想,init初始化與service執(zhí)行分離,攔截器支持變化擴展。上面列舉的幾個框架,它們都是解決了一些平常的問題,但不影響它們優(yōu)秀的設(shè)計,如MyBatis、Spring、SpringMVC、HttpClient,它們并沒有在一個大類中實現(xiàn)各種各樣的功能,而是切分放在不同的類中,并且通過多層繼承關(guān)系組合在一起,不管是可讀性上,還是可擴展性上都非常不錯。
四、認知是解決復(fù)雜性的基石
在認知面前,所有的方法和工具都是蒼白的,就像一個人想不勞而獲一樣,總想找一種萬能的方法解決所有的問題,而事實并沒有,還得靠在實踐中解決問題。復(fù)雜性也是同樣的問題,沒有萬能的方法解決它,只有原則作為指導(dǎo),而具體要怎么去做,還是得身體力行。當我們不理解框架為什么要設(shè)計得這么復(fù)雜時,大概率是我們對應(yīng)用的場景了解還不夠全面。
4.1 業(yè)務(wù)認知
當大家第一次去看Spring Bean掃描的邏輯時,它的邏輯是很復(fù)雜的,如果讓我們自己去實現(xiàn)一個,你可能會很簡單的設(shè)計出來,根據(jù)指定的路徑掃描所有的類,如果有@Component的注解時就存放到BeanDefinnitionMap中,那為什么Spring要設(shè)計得這么復(fù)雜呢,原因是現(xiàn)實場景中Bean定義有多種方法,比如嵌套定義Bean,再比如先掃描出一部分Bean,此時這些Bean中有定義@CompentScan,又可以加載其它的Bean,所以你看這么多你不曾考慮的場景疊加在一起,實現(xiàn)起來的復(fù)雜度自然就高了。
還比如SpringMVC在查找Handler時,它的邏輯也挺復(fù)雜的,與我們?nèi)粘Mㄟ^一個URL映射到一個Handler不一樣,在現(xiàn)實中完全有一種可能是相同的URL對應(yīng)不同的請求方法,此時就不是一個簡單映射的就能完成,還有一大堆的匹配邏輯,所以你會看到,當我們的業(yè)務(wù)認知了解得越來越多時,在設(shè)計中就會考慮更多的因素。
提升業(yè)務(wù)認知,除了溝通交流外,還得踏踏實實去工作一段時間,真正地了解里面的問題是什么,即使是踩坑,也是修正自己的認知。
4.2 技術(shù)認知
除了業(yè)務(wù)認知外,技術(shù)也是在不斷發(fā)展的,如果你不了解某個技術(shù)或技術(shù)點,此時你也不會想到好的設(shè)計方法。比如讓你設(shè)計一個事件通知框架,本來這個功能倒不是那么復(fù)雜,它最難的點是在于如何找到事件對應(yīng)的事件處理器,此時就有不同的解決方案,一種最簡單的方法是在定義事件處理器時讓用戶指定事件類型,這似乎是一種解決方案,但站在用戶使用的角度看,它并不是一種好的解決方案,把復(fù)雜留給用戶而不是自己。為了提升用戶使用體驗,這里就要使用到泛型類型解析的方面的知識了,核心代碼如下:
/**
* 事件分發(fā)器
*
* @author fulai.gfl
*/
public class EventDispatcher {
/**
* 事件列表
*/
private static List<Event> events = new ArrayList<>();
/**
* 事件處理器列表
*/
private static List<Handler> handlers = new ArrayList<>();
/**
* 添加事件
*/
public static void addEvent(Event event) {
events.add(event);
}
/**
* 添加事件處理器
*/
public static void addHandler(Handler handler) {
handlers.add(handler);
}
/**
* 觸發(fā)事件
*/
public Object fire(Event event) throws Exception {
Handler handler = getHandler(event);
if(Objects.isNull(handler)){
throw new Exception("event_name =" + event.getEventName());
}
return handler.handle(event);
}
/**
* 根據(jù)事件找到對應(yīng)的Handler
*/
private Handler getHandler(Event event) throws Exception {
Handler handler = null;
for (Handler h : handlers) {
Type[] argumentsTypes =
((ParameterizedTypeImpl)h.getClass()
.getGenericInterfaces()[0])
.getActualTypeArguments();
if (Class.forName(((Class)argumentsTypes[0])
.getName()).equals(event.getClass())) {
handler = h;
}
}
return handler;
}
}
五、小結(jié)
本文主要講述了應(yīng)對復(fù)雜性的一些原則和經(jīng)驗,通過實際案例解構(gòu)設(shè)計思想,個人認為好的設(shè)計是體現(xiàn)在「職責分離」、「抽象分層」和「變化擴展」上,在類的結(jié)構(gòu)設(shè)計上尤其要花心思去想,如「變與不變分離」、「配置域與執(zhí)行域分離」、「查詢與命令分離」。歸根到底,認知是解決復(fù)雜性的基石,如果要更好地發(fā)揮技術(shù)的作用,對業(yè)務(wù)的理解需要更好的認識。
