Dubbo官方文檔筆記—設計原則

魔鬼在細節

http://javatar.iteye.com/blog/1056664

最近一直擔心如果 Dubbo 分佈式服務框架維護人員增多或變更,會出現質量的下降的問題, 我在想,有沒有什麼規則是需要大家共同遵守的。根據平時寫代碼時的一習慣,總結了以下在寫代碼過程中,尤其是框架代碼,要時刻牢記的細節。可能下面要講的這些,大家都會覺得很簡單,很基礎,但要做到時刻牢記。在每一行代碼中都考慮這些因素,是需要很大耐心的, 大家經常說,魔鬼在細節中,確實如此。

防止空指針和下標越界

這是我最不喜歡看到的異常,尤其在覈心框架中,我更願看到信息詳細的參數不合法異常。這也是一個編寫健壯程序的開發人員,在寫每一行代碼都應在潛意識中防止的異常。基本上要能確保每一次寫完的代碼,在不測試的情況下,都不會出現這兩個異常纔算合格。

保證線程安全性和可見性

對於框架的開發人員,對線程安全性和可見性的深入理解是最基本的要求。需要開發人員,在寫每一行代碼時都應在潛意識中確保其正確性。因爲這種代碼,在小併發下做功能測試時,會顯得很正常。但在高併發下就會出現莫明其妙的問題,而且場景很難重現,極難排查。

儘早失敗和前置斷言

儘早失敗也應該成爲潛意識,在有傳入參數和狀態變化時,均在入口處全部斷言。一個不合法的值和狀態,在第一時間就應報錯,而不是等到要用時才報錯。因爲等到要用時,可能前面已經修改其它相關狀態,而在程序中很少有人去處理回滾邏輯。這樣報錯後,其實內部狀態可能已經混亂,極易在一個隱蔽分支上引發程序不可恢復。

分離可靠操作和不可靠操作

這裏的可靠是狹義的指是否會拋出異常或引起狀態不一致,比如,寫入一個線程安全的 Map,可以認爲是可靠的,而寫入數據庫等,可以認爲是不可靠的。開發人員必須在寫每一行代碼時,都注意它的可靠性與否,在代碼中儘量劃分開,並對失敗做異常處理,併爲容錯,自我保護,自動恢復或切換等補償邏輯提供清晰的切入點,保證後續增加的代碼不至於放錯位置,而導致原先的容錯處理陷入混亂。

異常防禦,但不忽略異常

這裏講的異常防禦,指的是對非必須途徑上的代碼進行最大限度的容忍,包括程序上的 BUG,比如:獲取程序的版本號,會通過掃描 Manifest 和 jar 包名稱抓取版本號,這個邏輯是輔助性的,但代碼卻不少,初步測試也沒啥問題,但應該在整個 getVersion() 中加上一個全函數的 try-catch 打印錯誤日誌,並返回基本版本,因爲 getVersion() 可能存在未知特定場景異常,或被其他的開發人員誤修改邏輯(但一般人員不會去掉 try-catch),而如果它拋出異常會導致主流程異常,這是我們不希望看到的。但這裏要控制個度,不要隨意 try-catch,更不要無聲無息的喫掉異常。

縮小可變域和儘量 final

如果一個類可以成爲不變類(Immutable Class),就優先將它設計成不變類。不變類有天然的併發共享優勢,減少同步或複製,而且可以有效幫忙分析線程安全的範圍。就算是可變類,對於從構造函數傳入的引用,在類中持有時,最好將字段 final,以免被中途誤修改引用。不要以爲這個字段是私有的,這個類的代碼都是我自己寫的,不會出現對這個字段的重新賦值。要考慮的一個因素是,這個代碼可能被其他人修改,他不知道你的這個弱約定,final 就是一個不變契約。

降低修改時的誤解性,不埋雷

前面不停的提到代碼被其他人修改,這也開發人員要隨時緊記的。這個其他人包括未來的自己,你要總想着這個代碼可能會有人去改它。我應該給修改的人一點什麼提示,讓他知道我現在的設計意圖,而不要在程序裏面加潛規則,或埋一些容易忽視的雷,比如:你用 null 表示不可用,size 等於 0 表示黑名單,這就是一個雷,下一個修改者,包括你自己,都不會記得有這樣的約定,可能後面爲了改某個其它 BUG,不小心改到了這裏,直接引爆故障。對於這個例子,一個原則就是永遠不要區分 null 引用和 empty 值。

提高代碼的可測性

這裏的可測性主要指 Mock 的容易程度,和測試的隔離性。至於測試的自動性,可重複性,非偶然性,無序性,完備性(全覆蓋),輕量性(可快速執行),一般開發人員,加上 JUnit 等工具的輔助基本都能做到,也能理解它的好處,只是工作量問題。這裏要特別強調的是測試用例的單一性(只測目標類本身)和隔離性(不傳染失敗)。現在的測試代碼,過於強調完備性,大量重複交叉測試,看起來沒啥壞處,但測試代碼越多,維護代價越高。經常出現的問題是,修改一行代碼或加一個判斷條件,引起 100 多個測試用例不通過。時間一緊,誰有這個閒功夫去改這麼多形態各異的測試用例?久而久之,這個測試代碼就已經不能真實反應代碼現在的狀況,很多時候會被迫繞過。最好的情況是,修改一行代碼,有且只有一行測試代碼不通過。如果修改了代碼而測試用例還能通過,那也不行,表示測試沒有覆蓋到。另外,可 Mock 性是隔離的基礎,把間接依賴的邏輯屏蔽掉。可 Mock 性的一個最大的殺手就是靜態方法,儘量少用。

一些設計上的基本常識

最近給團隊新人講了一些設計上的常識,可能會對其它的新人也有些幫助,把暫時想到的幾條,先記在這裏。

API 與 SPI 分離

框架或組件通常有兩類客戶,一個是使用者,一個是擴展者。API (Application Programming Interface) 是給使用者用的,而 SPI (Service Provide Interface) 是給擴展者用的。在設計時,儘量把它們隔離開,而不要混在一起。也就是說,使用者是看不到擴展者寫的實現的。

比如:一個 Web 框架,它有一個 API 接口叫 Action,裏面有個 execute() 方法,是給使用者用來寫業務邏輯的。然後,Web 框架有一個 SPI 接口給擴展者控制輸出方式,比如用 velocity 模板輸出還是用 json 輸出等。如果這個 Web 框架使用一個都繼承 Action 的 VelocityAction 和一個 JsonAction 做爲擴展方式,要用 velocity 模板輸出的就繼承 VelocityAction,要用 json 輸出的就繼承 JsonAction,這就是 API 和 SPI 沒有分離的反面例子,SPI 接口混在了 API 接口中。

mix-api-spi

合理的方式是,有一個單獨的 Renderer 接口,有 VelocityRenderer 和 JsonRenderer 實現,Web 框架將 Action 的輸出轉交給 Renderer 接口做渲染輸出。

seperate-api-spi

服務域/實體域/會話域分離

任何框架或組件,總會有核心領域模型,比如:Spring 的 Bean,Struts 的 Action,Dubbo 的 Service,Napoli 的 Queue 等等。這個核心領域模型及其組成部分稱爲實體域,它代表着我們要操作的目標本身。實體域通常是線程安全的,不管是通過不變類,同步狀態,或複製的方式。

服務域也就是行爲域,它是組件的功能集,同時也負責實體域和會話域的生命週期管理, 比如 Spring 的 ApplicationContext,Dubbo 的 ServiceManager 等。服務域的對象通常會比較重,而且是線程安全的,並以單一實例服務於所有調用。

什麼是會話?就是一次交互過程。會話中重要的概念是上下文,什麼是上下文?比如我們說:“老地方見”,這裏的“老地方”就是上下文信息。爲什麼說“老地方”對方會知道,因爲我們前面定義了“老地方”的具體內容。所以說,上下文通常持有交互過程中的狀態變量等。會話對象通常較輕,每次請求都重新創建實例,請求結束後銷燬。簡而言之:把元信息交由實體域持有,把一次請求中的臨時狀態由會話域持有,由服務域貫穿整個過程。

ddd

 

在重要的過程上設置攔截接口

如果你要寫個遠程調用框架,那遠程調用的過程應該有一個統一的攔截接口。如果你要寫一個 ORM 框架,那至少 SQL 的執行過程,Mapping 過程要有攔截接口;如果你要寫一個 Web 框架,那請求的執行過程應該要有攔截接口,等等。沒有哪個公用的框架可以 Cover 住所有需求,允許外置行爲,是框架的基本擴展方式。這樣,如果有人想在遠程調用前,驗證下令牌,驗證下黑白名單,統計下日誌;如果有人想在 SQL 執行前加下分頁包裝,做下數據權限控制,統計下 SQL 執行時間;如果有人想在請求執行前檢查下角色,包裝下輸入輸出流,統計下請求量,等等,就可以自行完成,而不用侵入框架內部。攔截接口,通常是把過程本身用一個對象封裝起來,傳給攔截器鏈,比如:遠程調用主過程爲 invoke(),那攔截器接口通常爲 invoke(Invocation),Invocation 對象封裝了本來要執行過程的上下文,並且 Invocation 裏有一個 invoke() 方法,由攔截器決定什麼時候執行,同時,Invocation 也代表攔截器行爲本身,這樣上一攔截器的 Invocation 其實是包裝的下一攔截器的過程,直到最後一個攔截器的 Invocation 是包裝的最終的 invoke() 過程;同理,SQL 主過程爲 execute(),那攔截器接口通常爲 execute(Execution),原理一樣。當然,實現方式可以任意,上面只是舉例。

filter-chain

 

重要的狀態的變更發送事件並留出監聽接口

這裏先要講一個事件和上面攔截器的區別,攔截器是干預過程的,它是過程的一部分,是基於過程行爲的,而事件是基於狀態數據的,任何行爲改變的相同狀態,對事件應該是一致的。事件通常是事後通知,是一個 Callback 接口,方法名通常是過去式的,比如 onChanged()。比如遠程調用框架,當網絡斷開或連上應該發出一個事件,當出現錯誤也可以考慮發出一個事件,這樣外圍應用就有可能觀察到框架內部的變化,做相應適應。

event-listener

擴展接口職責儘可能單一,具有可組合性

比如,遠程調用框架它的協議是可以替換的。如果只提供一個總的擴展接口,當然可以做到切換協議,但協議支持是可以細分爲底層通訊,序列化,動態代理方式等等。如果將接口拆細,正交分解,會更便於擴展者複用已有邏輯,而只是替換某部分實現策略。當然這個分解的粒度需要把握好。

微核插件式,平等對待第三方

大凡發展的比較好的框架,都遵守微核的理念。Eclipse 的微核是 OSGi, Spring 的微核是 BeanFactory,Maven 的微核是 Plexus。通常核心是不應該帶有功能性的,而是一個生命週期和集成容器,這樣各功能可以通過相同的方式交互及擴展,並且任何功能都可以被替換。如果做不到微核,至少要平等對待第三方,即原作者能實現的功能,擴展者應該可以通過擴展的方式全部做到。原作者要把自己也當作擴展者,這樣才能保證框架的可持續性及由內向外的穩定性。

不要控制外部對象的生命週期

比如上面說的 Action 使用接口和 Renderer 擴展接口。框架如果讓使用者或擴展者把 Action 或 Renderer 實現類的類名或類元信息報上來,然後在內部通過反射 newInstance() 創建一個實例,這樣框架就控制了 Action 或 Renderer 實現類的生命週期,Action 或 Renderer 的生老病死,框架都自己做了,外部擴展或集成都無能爲力。好的辦法是讓使用者或擴展者把 Action 或 Renderer 實現類的實例報上來,框架只是使用這些實例,這些對象是怎麼創建的,怎麼銷燬的,都和框架無關,框架最多提供工具類輔助管理,而不是絕對控制。

可配置一定可編程,並保持友好的 CoC 約定

因爲使用環境的不確定因素很多,框架總會有一些配置,一般都會到 classpath 直掃某個指定名稱的配置,或者啓動時允許指定配置路徑。做爲一個通用框架,應該做到凡是能配置文件做的一定要能通過編程方式進行,否則當使用者需要將你的框架與另一個框架集成時就會帶來很多不必要的麻煩。

另外,儘可能做一個標準約定,如果用戶按某種約定做事時,就不需要該配置項。比如:配置模板位置,你可以約定,如果放在 templates 目錄下就不用配了,如果你想換個目錄,就配置下。

區分命令與查詢,明確前置條件與後置條件

這個是契約式設計的一部分,儘量遵守有返回值的方法是查詢方法,void 返回的方法是命令。查詢方法通常是冪等性的,無副作用的,也就是不改變任何狀態,調 n 次結果都是一樣的,比如 get 某個屬性值,或查詢一條數據庫記錄。命令是指有副作用的,也就是會修改狀態,比如 set 某個值,或 update 某條數據庫記錄。如果你的方法即做了修改狀態的操作,又做了查詢返回,如果可能,將其拆成寫讀分離的兩個方法,比如:User deleteUser(id),刪除用戶並返回被刪除的用戶,考慮改爲 getUser() 和 void 的 deleteUser()。 另外,每個方法都儘量前置斷言傳入參數的合法性,後置斷言返回結果的合法性,並文檔化。

增量式擴展,而不要擴充原始核心概念

參見:談談擴充式擴展與增量式擴展

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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