本文主要講述了應對複雜性的一些原則和經驗,通過實際案例解構設計思想,個人認為好的設計是體現在「職責分離」、「抽象分層」和「變化擴展」上,在類的結構設計上尤其要花心思去想,如「變與不變分離」、「配置域與執行域分離」、「查詢與命令分離」。 ...
本文主要講述了應對複雜性的一些原則和經驗,通過實際案例解構設計思想,個人認為好的設計是體現在「職責分離」、「抽象分層」和「變化擴展」上,在類的結構設計上尤其要花心思去想,如「變與不變分離」、「配置域與執行域分離」、「查詢與命令分離」。
正如Brooks所言,軟體複雜性是軟體固有的屬性,這種固有的複雜性主要由4個方面的原因造成的:
-
問題域的複雜性
-
管理開發過程的複雜性
-
隨處可變的靈活性
-
描繪離散系統行為的問題
上面每一個方面都有極大的挑戰,以「問題域的複雜性」為例,現在我們的大型系統中,動不動就幾十個應用,組合在一起就是一個複雜的系統,而每個人只負責其中一小部分,想要瞭解系統全部的運行狀況是很難的,哪怕一個子系統,它包含的業務規則就巨多,因此說軟體複雜是它的本質屬性。
影響軟體複雜度的因素有很多,其中「認知複雜度」占據著很重要的困素。一提到複雜性,我們腦海裡會浮出各種各樣的印象:應用數多、代碼行數超過百萬級、業務規則複雜等,這些複雜度從本質上來看是認知複雜度超過了正常人的認知範圍,比如看百萬行級的代碼與看100行代碼相比,維護10個應用與維護1個應用相比,兩個複雜度不是在同一個數量級上。
認知複雜度是軟體的本質複雜度,從根本上規蔽不了,只能去理解、消化吸收,我們能做的是在理解的基礎上去發現共性的「規律」,將這些「規律」抽象出來,讓應用層開發變得簡單。舉一個印象最深的例子,當時做財務核算時,最開始面對的業務認知複雜度非常高,它關聯電商交易、支付、營銷、結算、資金等領域,依賴業務將近100張離線表,除了要理解電商業務鏈路外,還要站在財務視角把這些數據有序地組織起來,複雜度一下子就上升上來了,新人至少要花3個月的時間去消化這些業務知識。當進來做了一些需求開發後,慢慢發現了一些規律,利用發現的這些規律有助於提升需求溝通、開發的效率。
「規律」是日常開發中發現有共性的地方,往後再遇到可以同樣的問題可加速解決的效率。軟體複雜度伴隨著軟體研發開始就產生的問題,「設計原則」就是應對複雜性過程中總結出來的規律。常見的設計原則有SOLID、GRASP、KISS、分層等,這些設計原則指導我們在面對複雜系統時應該如何去設計。原則的東西,我們總覺得有些虛無飄緲,感覺理解了,又好像落不了地,個人經驗是建立自己的認知體系,在實踐中修正認知。
在經典的設計原則之上,加上自己的一些理解,最終將設計原則歸類成三個方面:「職責分解」、「層次抽象」和「變化擴展」。
2.2.1 職責分解
對職責分離有兩點體會:一個是「你擁有什麼信息就應該承擔怎樣的職責」;另一個是「一個類只做一件事」。其中第一點出自GRASP的「信息專家原則」,當我們在討論是否是貧血模型時,你可以用這個原則去檢驗,如果一類中的成員屬性操作放在另外一類中,大概率是不符合信息專家原則,舉一個簡單的例子,比如要計算訂單的金額,那麼這個計算方法應該是在訂單類中,而不是放在另外一個類中,因為訂單類中有訂單的單價和數量。
另一點是出自於SOLID的單一職責,它的原意是一個類只有一個變化的原因,一個類專註於做一件事的好處是可提升復用性和減少依賴,反之一個類耦合了不同的操作,修改的頻次就會變多,儘量少改動穩定的部分,在系統穩定性中有一個共性認知:故障的發生大概率與最近的發佈有關。
職責分解最大的挑戰是一個職責到底要劃分到多細或多粗,很遺憾沒有量化的標準,只能說只做一件事或者只有一個變化這樣大的指導原則,更多地是我們在實踐中總結出來的經驗,比如「變與不變分離」、「讀寫分離」、「配置域與執行域分離」。
2.2.2 層次抽象
層次抽象是利用已發現的規律,讓往後的開發變得簡單,當我們在一線開發中,你會發現有一些規律,比如在日常開發中,發現開發主要涉及到與前端交互、業務邏輯處理和數據存儲,這樣就可以分成三層:「視圖層」、「業務邏輯層」和「數據訪問層」。
高層次依賴低層次,最高層次越具象,也會越簡單,舉一個例子,在傳統Servlet開發中,一般的步驟是獲取參數信息並轉成業務層的對象,再進行業務處理,雖然不同的業務處理邏輯是不一樣的,但參數獲取是具有共性的操作,在SpringMVC中,我們可以直接定義POJO去映射參數,可以不用使用HttpServlet底層的操作去獲取參數,這就是一種典型的層次抽象。
「層次特性」是複雜系統的固有屬性,需要我們不斷去探索,分層的確能極大地降低認知複雜度,相當是站在巨人的肩膀上看問題,利用已發現的規律辦事效率會高很多,如上文提到的財務核算,做多了就會發現就那幾種模式,當你沒有摸清裡面的規律時,覺會顯得很零散。
2.2.3 變化擴展
軟體如果沒有變化,也就不需要所謂的設計原則,一次性工程怎麼快就怎麼來,而現實中遇到最多的現象是需求不斷變化。變化擴展的挑戰不在於技術,而是在於「怎麼認知到哪裡有變化」。常見變化擴展的技術有:配置項、介面、抽象類、攔截器、SPI、插件等,這些都是具體的解決手段,它們並不複雜,複雜在於哪裡會有變化,這個是最難的。
認識到多少變化,它取決於認識的寬度,看到多少內容會影響到系統設計,比如在SpringMVC中,我們最高常操作的是定義一個Controller,再在方法上寫一個RequestMapping註解,但在實際中,它還有另外的寫法,如實現Controller介面,正是有不同的場景和類型,處理上還有差別,此時就會有變化擴展的訴求。
在經典的設計原則之上,結合實踐過程中的得與失,總結了以下6條設計經驗,為了更容易理解,下麵的案例選用常用的開源框架剖析設計思想,方便與大家產生共鳴。
2.3.1 在多變中找不變,模板方法治之
當一個業務有多個場景,並且不同的場景處理既有共性的地方,也有差異性的地方時,此時最容易想到的方法是用「模板方法」固定共性的邏輯,差異性的邏輯放到子類中實現。
在開源框架中,我們經常見到這樣的設計思想,比如在SpringMVC中查找Handler的過程,不同的場景查找邏輯不一樣,最常見的是RequestMapping方式查找,它是在HandMapping介面類中定義getHandler方法。
public interface HandlerMapping {
HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception;
}
然後在抽象類AbstractHandlerMapping中定義模板方法,抽象方法又交由子類去實現。
public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
// 抽象方法,交由具體的子類實現
Object handler = getHandlerInternal(request);
if (handler == null) {
handler = getDefaultHandler();
}
if (handler == null) {
return null;
}
// 省略部分代碼
HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);
// 省略部分代碼
return executionChain;
}
在MyBatis框架中,Executor定義了增刪改查等方法,具體實現有如單條命令執行、批量命令執行等,模板方法定義在Ba
在MyBatis框架中,Executor定義了增刪改查等方法,具體實現有如單條命令執行、批量命令執行等,模板方法定義在BaseExecutor類中,類結構繼承關係如下所示,這也是一種最簡單的三層設計結構:介面類、抽象類、子類。
2.3.2 涉及業務鏈路查詢和複雜組裝的,查詢與命令職責分離治之
有一類業務,它涉及「查詢」與「組裝」兩個操作,比如Spring中有Bean查詢操作,與之對應的有Bean創建操作,這兩個職責是不一樣的,也有的稱之為「讀寫分離」或者「查詢與命令分離」,從本質上講,它也遵循了介面單一職責。
再比如SpringMVC中,Handler有查找的操作,對應也有Handler構建的操作,它也是分在兩個不同的類中實現的,一般信息構建操作是在初始化過程中完成的,因此組裝Handler的邏輯是實現了InitializingBean的afterPropertiesSet()方法。
上面的兩個案例,是命令複雜、查詢簡單的例子,還有一類場景是命令簡單、查詢複雜的例子,比如在CQRS模式中,命令執行之後,結果會通過某種機制從一個數據源同步到另外一個數據源做聚合分析,查詢從分析結果中獲取數據,典型的例子是數據從資料庫同步到搜索引擎中,查詢從搜索引擎中獲取數據。
至此,在模板方法的基礎之上,又增加了「查詢與命令分離」的設計原則,類結構繼承關係也隨之發生了變化。
2.3.3 有面向用戶配置的,配置域與執行域分離治之
有些業務前臺用戶能夠直接配置操作的,比如在SpringMVC中,我們配置一個Controller的請求可以配置不同的屬性,其中RequestMapping是直接面向用戶視角的配置操作,在配置域的內容,是與現實操作一一映射的,RequestMapping對應有一個類叫RequestMappingInfo,然而在執行域,此時它就不需要配置域中的那麼多信息,執行過程只要對象和方法的信息即可,對應有一個類中HandlerMethod,由此可見,配置域和執行域兩個抽象的視角是不一樣的,一個是現實世界的直接映射,一個是偏底層執行。
@RestController
public class UserController {
@RequestMapping(value = "/acquire", method = RequestMethod.GET)
public User getUser(@RequestParam("name") String name, @RequestParam("age") Integer age) {
return null;
}
}
RequestMappingHandlerMapping類結構繼承關係如下圖所示。
再比如在Spring中,允許用戶配置自定義的編輯器、BeanPostProcessor處理器,也是由一個單獨的介面類ConfigurableBeanFactory表達的。
這樣的例子還有很多,比如BeanDefinition是面向配置域的,Bean是執行域的,我們在定義Bean是有很多的屬性,這些屬性信息在BeanDefinition類中定義,而在執行過程中會生成一個對象,本質上是一個Object。
2.3.4 業務有多樣變化的,封裝變化治之
應對變化的方法有很多,難的是要感知到變化並且封裝好變化,比如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;
}
}
設計擴展點時一定要把握好度,粒度過細則擴展點數量非常多,在Spring中設計就比較好,對於開發而言,有兩個時機有明顯的擴展訴求,一個是在Bean掃描時,可以允許用戶自定義Bean,此時有BeanFactoryPostProcessor擴展介面;另一個是在Bean初始化時的擴展,對應有BeanPostProcessor擴展介面。不管是Spring內部使用,還是外部開發,都是使用同樣的擴展。
2.3.5 業務流程型操作,責任鏈治之
業務型操作,有明顯的流程痕跡,比如前置檢查、協議組裝、介面調用等,節點與節點之間就構成了一條鏈條,只不過平時寫代碼時我們是放在一個大的流程中實現的。在HttpClient中,對於請求,我們有不同的操作流程,比如重試、緩存、重定向、調用socket等操作,HttpClient使用責任鏈的模式。
鏈條上的每個節點都是獨立操作的,方便擴展,責任鏈核心是鏈的構建和節點設計,這給平時寫流程型業務代碼提供了一種新的思路,大型系統中,有流程引擎,本質來講它也是一條鏈,一個節點做完之後下一個節點繼續做,思想上大同小異。
2.3.6 複雜系統場景,抽象治之
抽象是應對複雜場景的重要方法,這一點我們並不懷疑,最難的是要抽象什麼去刻畫業務,比如AOP切麵編程,站在用戶視角,就是告訴他哪些類、哪些方法需要被增強什麼共性業務邏輯,比如日誌切麵類、許可權切麵類等,AOP對它的抽象是「對指定的類和方法以某種方式織入特定的共性邏輯」。其中指定的類和方法抽象成切點,以某種方式抽象成通知,此時,你會發現它抽象出了一些概念出來,如切麵、切點、通知。因此,對複雜業務場景,一定要有一套抽象的元數據去表徵它,也即是領域模型,最高明的建模方法是下定義的方法,用一句簡明的話講清楚業務的結構和功能。
系統是元素和元素間以某種關聯關係構成的一種結構,複雜系統是構成元素更多、關聯關係更複雜,核心還是要找到「結構」,這種結構也即是領域模型,好的領域模型可遇而不可求,是要花大量的時間去探尋它,突然有一天在你腦海裡靈光一現就出來了,這種感覺很奇妙,因此,領域建模是非常依賴經驗而非方法。
有了上面的分析基礎,再以SpringMVC DispatcherServlet為例,分析它的設計思想,它的類圖結構如下圖所示。
SpringMVC核心是對HttpServlet的封裝,在HttpServlet中有兩個重要的方法,一個是init()方法,一個是service()方法,init()方法是Servlet初始化時回調的方法,service()是處理請求時回調的方法。
在HttpServletBean類中,它重寫了HttpServlet init()方法,主要完成SpringMVC子容器初始化的過程。FrameworkServlet類主要重寫了service()方法,處理實際的如GET、POST請求,但它只是定義了一個抽象的doService()方法,實際處理過程是在DispatcherServlet類中,分發的Servlet是攔截所有的請求,然後匹配到目標Handler執行。
在DispatcherServlet類的設計中,體現出了「職責分離」和「變化擴展」的設計思想,init初始化與service執行分離,攔截器支持變化擴展。
上面列舉的幾個框架,它們都是解決了一些平常的問題,但不影響它們優秀的設計,如MyBatis、Spring、SpringMVC、HttpClient,它們並沒有在一個大類中實現各種各樣的功能,而是切分放在不同的類中,並且通過多層繼承關係組合在一起,不管是可讀性上,還是可擴展性上都非常不錯。
在認知面前,所有的方法和工具都是蒼白的,就像一個人想不勞而獲一樣,總想找一種萬能的方法解決所有的問題,而事實並沒有,還得靠在實踐中解決問題。複雜性也是同樣的問題,沒有萬能的方法解決它,只有原則作為指導,而具體要怎麼去做,還是得身體力行。當我們不理解框架為什麼要設計得這麼複雜時,大概率是我們對應用的場景瞭解還不夠全面。
當大家第一次去看Spring Bean掃描的邏輯時,它的邏輯是很複雜的,如果讓我們自己去實現一個,你可能會很簡單的設計出來,根據指定的路徑掃描所有的類,如果有@Component的註解時就存放到BeanDefinnitionMap中,那為什麼Spring要設計得這麼複雜呢,原因是現實場景中Bean定義有多種方法,比如嵌套定義Bean,再比如先掃描出一部分Bean,此時這些Bean中有定義@CompentScan,又可以載入其它的Bean,所以你看這麼多你不曾考慮的場景疊加在一起,實現起來的複雜度自然就高了。
還比如SpringMVC在查找Handler時,它的邏輯也挺複雜的,與我們日常通過一個URL映射到一個Handler不一樣,在現實中完全有一種可能是相同的URL對應不同的請求方法,此時就不是一個簡單映射的就能完成,還有一大堆的匹配邏輯,所以你會看到,當我們的業務認知瞭解得越來越多時,在設計中就會考慮更多的因素。
提升業務認知,除了溝通交流外,還得踏踏實實去工作一段時間,真正地瞭解裡面的問題是什麼,即使是踩坑,也是修正自己的認知,在做財務核算過程中,有些在今天看來是很低級的錯誤在當時就犯過,本質講還是對業務的認知不夠,不在自己的認知範圍內就會犯錯。
除了業務認知外,技術也是在不斷發展的,如果你不瞭解某個技術或技術點,此時你也不會想到好的設計方法。比如讓你設計一個事件通知框架,本來這個功能倒不是那麼複雜,它最難的點是在於如何找到事件對應的事件處理器,此時就有不同的解決方案,一種最簡單的方法是在定義事件處理器時讓用戶指定事件類型,這似乎是一種解決方案,但站在用戶使用的角度看,它並不是一種好的解決方案,把複雜留給用戶而不是自己。為了提升用戶使用體驗,這裡就要使用到泛型類型解析的方面的知識了,核心代碼如下:
/**
* 事件分發器
*
* @author fulai.gfl
*/
public class EventDispatcher {
/**
* 事件列表
*/
private static List<Event> events = new ArrayList<>();
/**
* 事件處理器列表
*/
private static List<Handler> handlers = new ArrayList<>();
/**
* 添加事件
*
* @param event
*/
public static void addEvent(Event event) {
events.add(event);
}
/**
* 添加事件處理器
*
* @param handler
*/
public static void addHandler(Handler handler) {
handlers.add(handler);
}
/**
* 觸發事件
*
* @param event
* @return
* @throws Exception
*/
public Object fire(Event event) throws Exception {
Handler handler = getHandler(event);
if(Objects.isNull(handler)){
throw new Exception("沒有找到指定的handler, event_name =" + event.getEventName());
}
return handler.handle(event);
}
/**
* 根據事件找到對應的Handler
*
* @param event
* @return
* @throws Exception
*/
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;
}
}
本文主要講述了應對複雜性的一些原則和經驗,通過實際案例解構設計思想,個人認為好的設計是體現在「職責分離」、「抽象分層」和「變化擴展」上,在類的結構設計上尤其要花心思去想,如「變與不變分離」、「配置域與執行域分離」、「查詢與命令分離」。歸根到底,認知是解決複雜性的基石,需要我們去瞭解它,不管是業務還是技術上。
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Principles-and-Cases-of-Complex-System-Design.html