01.06 Dubbo作者聊 設計原則

以下內容均來自 梁飛 的個人博客 http://javatar.iteye.com/blog/1056664

  • 魔鬼在細節

  • 一些設計上的基本常識

  • 談談擴充式擴展與增量式擴展

  • 配置設計

  • 設計實現的健壯性

  • 防痴呆設計

  • 擴展點重構


魔鬼在細節中

轉於自己在公司的Blog:

http://pt.alibaba-inc.com/wp/experience_1301/code-detail.html

最近一直擔心Dubbo分佈式服務框架後續如果維護人員增多或變更,會出現質量的下降,

我在想,有沒有什麼是需要大家共同遵守的,

根據平時寫代碼時的一習慣,總結了一下在寫代碼過程中,尤其是框架代碼,要時刻牢記的細節,

可能下面要講的這些,大家都會覺得很簡單,很基礎,但要做到時刻牢記,

在每一行代碼中都考慮這些因素,是需要很大耐心的,

大家經常說,魔鬼在細節中,確實如此。

1. 防止空指針和下標越界

這是我最不喜歡看到的異常,尤其在核心框架中,我更願看到信息詳細的參數不合法異常,

這也是一個健狀的程序開發人員,在寫每一行代碼都應在潛意識中防止的異常,

基本上要能確保一次寫完的代碼,在不測試的情況,都不會出現這兩個異常才算合格。

2. 保證線程安全性和可見性

對於框架的開發人員,對線程安全性和可見性的深入理解是最基本的要求,

需要開發人員,在寫每一行代碼時都應在潛意識中確保其正確性,

因為這種代碼,在小併發下做功能測試時,會顯得很正常,

但在高併發下就會出現莫明其妙的問題,而且場景很難重現,極難排查。

3. 儘早失敗和前置斷言

儘早失敗也應該成為潛意識,在有傳入參數和狀態變化時,均在入口處全部斷言,

一個不合法的值和狀態,在第一時間就應報錯,而不是等到要用時才報錯,

因為等到要用時,可能前面已經修改其它相關狀態,而在程序中很少有人去處理回滾邏輯,

這樣報錯後,其實內部狀態可能已經混亂,極易在一個隱蔽分支上引發程序不可恢復。

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

這裡的可靠是狹義的指是否會拋出異常或引起狀態不一致,

比如,寫入一個線程安全的Map,可以認為是可靠的,

而寫入數據庫等,可以認為是不可靠的,

開發人員必須在寫每一行代碼時,都注意它的可靠性與否,

在代碼中儘量劃分開,並對失敗做異常處理,

併為容錯,自我保護,自動恢復或切換等補償邏輯提供清晰的切入點,

保證後續增加的代碼不至於放錯位置,而導致原先的容錯處理陷入混亂。

5. 異常防禦,但不忽略異常

這裡講的異常防禦,指的是對非必須途徑上的代碼進行最大限度的容忍,

包括程序上的BUG,比如:獲取程序的版本號,會通過掃描Manifest和jar包名稱抓取版本號,

這個邏輯是輔助性的,但代碼卻不少,初步測試也沒啥問題,

但應該在整個getVersion()中加上一個全函數的try-catch打印錯誤日誌,並返回基本版本,

因為getVersion()可能存在未知特定場景異常,或被其他的開發人員誤修改邏輯(但一般人員不會去掉try-catch),

而如果它拋出異常會導致主流程異常,這是我們不希望看到的,

但這裡要控制個度,不要隨意try-catch,更不要無聲無息的吃掉異常。

6. 縮小可變域和儘量final

如果一個類可以成為不變類(Immutable Class),就優先將它設計成不變類,

不變類有天然的併發共享優勢,減少同步或複製,而且可以有效幫忙分析線程安全的範圍,

就算是可變類,對於從構造函數傳入的引用,在類中持有時,最好將字段final,以免被中途誤修改引用,

不要以為這個字段是私有的,這個類的代碼都是我自己寫的,不會出現對這個字段的重新賦值,

要考慮的一個因素是,這個代碼可能被其他人修改,他不知道你的這個弱約定,final就是一個不變契約。

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

前面不停的提到代碼被其他人修改,這也開發人員要隨時緊記的,

這個其他人包括未來的自己,你要總想著這個代碼可能會有人去改它,

我應該給修改的人一點什麼提示,讓他知道我現在的設計意圖,

而不要在程序裡面加潛規則,或埋一些容易忽視的雷,

比如:你用null表示不可用,size等於0表示黑名單,

這就是一個雷,下一個修改者,包括你自己,都不會記得有這樣的約定,

可能後面為了改某個其它BUG,不小心改到了這裡,直接引爆故障。

對於這個例子,一個原則就是永遠不要區分null引用和empty值。

8. 提高代碼的可測性

這裡的可測性主要指Mock的容易程度,和測試的隔離性,

至於測試的自動性,可重複性,非偶然性,無序性,完備性(全覆蓋),輕量性(可快速執行),

一般開發人員,加上JUnit等工具的輔助基本都能做到,也能理解它的好處,只是工作量問題,

這裡要特別強調的是測試用例的單一性(只測目標類本身)和隔離性(不傳染失敗),

現在的測試代碼,過於強調完備性,大量重複交叉測試,

看起來沒啥壞處,但測試代碼越多,維護代價越高,

經常出現的問題是,修改一行代碼或加一個判斷條件,引起100多個測試用例不通過,

時間一緊,誰有這個閒功夫去改這麼多形態各異的測試用例?

久而久之,這個測試代碼就已經不能真實反應代碼現在的狀況,很多時候會被迫繞過,

最好的情況是,修改一行代碼,有且只有一行測試代碼不通過,

如果修改了代碼而測試用例還能通過,那也不行,表示測試沒有覆蓋到,

另外,可Mock性是隔離的基礎,把間接依賴的邏輯屏蔽掉,

可Mock性的一個最大的殺手就是靜態方法,儘量少用。


一些設計上的基本常識

轉於自己在公司的Blog:

http://pt.alibaba-inc.com/wp/experience_886/software_design_general_knowledge.html

最近給團隊新人講了一些設計上的常識,可能會對其它的新人也有些幫助,

把暫時想到的幾條,先記在這裡。

1. 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接口中,

合理的方式是,有一個單獨的Renderer接口,有VelocityRenderer和JsonRenderer實現,

Web框架將Action的輸出轉交給Renderer接口做渲染輸出。

Dubbo作者聊 設計原則

image.png

Dubbo作者聊 設計原則

image.png

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

任何框架或組件,總會有核心領域模型,比如:

Spring的Bean,Struts的Action,Dubbo的Service,Napoli的Queue等等

這個核心領域模型及其組成部分稱為實體域,它代表著我們要操作的目標本身,

實體域通常是線程安全的,不管是通過不變類,同步狀態,或複製的方式,

服務域也就是行為域,它是組件的功能集,同時也負責實體域和會話域的生命週期管理,

比如Spring的ApplicationContext,Dubbo的ServiceManager等,

服務域的對象通常會比較重,而且是線程安全的,並以單一實例服務於所有調用,

什麼是會話?就是一次交互過程,

會話中重要的概念是上下文,什麼是上下文?

比如我們說:“老地方見”,這裡的“老地方”就是上下文信息,

為什麼說“老地方”對方會知道,因為我們前面定義了“老地方”的具體內容,

所以說,上下文通常持有交互過程中的狀態變量等,

會話對象通常較輕,每次請求都重新創建實例,請求結束後銷燬。

簡而言之:

把元信息交由實體域持有,

把一次請求中的臨時狀態由會話域持有,

由服務域貫穿整個過程。

Dubbo作者聊 設計原則

image.png

Dubbo作者聊 設計原則

image.png

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

如果你要寫個遠程調用框架,那遠程調用的過程應該有一個統一的攔截接口,

如果你要寫一個ORM框架,那至少SQL的執行過程,Mapping過程要有攔截接口,

如果你要寫一個Web框架,那請求的執行過程應該要有攔截接口,

等等,沒有哪個公用的框架可以Cover住所有需求,允許外置行為,是框架的基本擴展方式,

這樣,如果有人想在遠程調用前,驗證下令牌,驗證下黑白名單,統計下日誌,

如果有人想在SQL執行前加下分頁包裝,做下數據權限控制,統計下SQL執行時間,

如果有人想在請求執行前檢查下角色,包裝下輸入輸出流,統計下請求量,

等等,就可以自行完成,而不用侵入框架內部,

攔截接口,通常是把過程本身用一個對象封裝起來,傳給攔截器鏈,

比如:遠程調用主過程為invoke(),那攔截器接口通常為invoke(Invocation),

Invocation對象封裝了本來要執行過程的上下文,並且Invocation裡有一個invoke()方法,

由攔截器決定什麼時候執行,同時,Invocation也代表攔截器行為本身,

這樣上一攔截器的Invocation其實是包裝的下一攔截器的過程,

直到最後一個攔截器的Invocation是包裝的最終的invoke()過程,

同理,SQL主過程為execute(),那攔截器接口通常為execute(Execution),原理一樣,

當然,實現方式可以任意,上面只是舉例。

Dubbo作者聊 設計原則

image.png

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

這裡先要講一個事件和上面攔截器的區別,攔截器是干預過程的,它是過程的一部分,是基於過程行為的,

而事件是基於狀態數據的,任何行為改變的相同狀態,對事件應該是一致的,

事件通常是事後通知,是一個Callback接口,方法名通常是過去式的,比如onChanged(),

比如遠程調用框架,當網絡斷開或連上應該發出一個事件,當出現錯誤也可以考慮發出一個事件,

這樣外圍應用就有可能觀察到框架內部的變化,做相應適應。

Dubbo作者聊 設計原則

image.png

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

比如,遠程調用框架它的協議是可以替換的,

如果只提供一個總的擴展接口,當然可以做到切換協議,

但協議支持是可以細分為底層通訊,序列化,動態代理方式等等,

如果將接口拆細,正交分解,會更便於擴展者複用已有邏輯,而只是替換某部分實現策略,

當然這個分解的粒度需要把握好。

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

大凡發展的比較好的框架,都遵守微核的理念,

Eclipse的微核是OSGi, Spring的微核是BeanFactory,Maven的微核是Plexus,

通常核心是不應該帶有功能性的,而是一個生命週期和集成容器,

這樣各功能可以通過相同的方式交互及擴展,並且任何功能都可以被替換,

如果做不到微核,至少要平等對待第三方,

即原作者能實現的功能,擴展者應該可以通過擴展的方式全部做到,

原作者要把自己也當作擴展者,這樣才能保證框架的可持續性及由內向外的穩定性。

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

比如上面說的Action使用接口和Renderer擴展接口,

框架如果讓使用者或擴展者把Action或Renderer實現類的類名或類元信息報上來,

然後在內部通過反射newInstance()創建一個實例,

這樣框架就控制了Action或Renderer實現類的生命週期,

Action或Renderer的生老病死,框架都自己做了,外部擴展或集成都無能為力,

好的辦法是讓使用者或擴展者把Action或Renderer實現類的實例報上來,

框架只是使用這些實例,這些對象是怎麼創建的,怎麼銷燬的,都和框架無關,

框架最多提供工具類輔助管理,而不是絕對控制。

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

因為使用環境的不確定因素很多,框架總會有一些配置,

一般都會到classpath直掃某個指定名稱的配置,或者啟動時允許指定配置路徑,

做為一個通用框架,應該做到凡是能配置文件做的一定要能通過編程方式進行,

否則當使用者需要將你的框架與另一個框架集成時就會帶來很多不必要的麻煩,

另外,儘可能做一個標準約定,如果用戶按某種約定做事時,就不需要該配置項。

比如:配置模板位置,你可以約定,如果放在templates目錄下就不用配了,

如果你想換個目錄,就配置下。

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

這個是契約式設計的一部分,儘量遵守有返回值的方法是查詢方法,void返回的方法是命令,

查詢方法通常是冪等性的,無副作用的,也就是不改變任何狀態,調n次結果都是一樣的,

比如get某個屬性值,或查詢一條數據庫記錄,

命令是指有副作用的,也就是會修改狀態,比如set某個值,或update某條數據庫記錄,

如果你的方法即做了修改狀態的操作,又做了查詢返回,如果可能,將其拆成寫讀分離的兩個方法,

比如:User deleteUser(id),刪除用戶並返回被刪除的用戶,考慮改為getUser()和void的deleteUser()。

另外,每個方法都儘量前置斷言傳入參數的合法性,後置斷言返回結果的合法性,並文檔化。

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

參見:http://javatar.iteye.com/blog/690845


談談擴充式擴展與增量式擴展

轉於自己在公司的Blog:

http://pt.alibaba-inc.com/wp/experience_760/generic_vs_composite_expansibility.html

我們平臺的產品越來越多,產品的功能也越來越多,

平臺的產品為了適應各BU和部門以及產品線的需求,

勢必會將很多不相干的功能湊在一起,客戶可以選擇性的使用,

為了兼容更多的需求,每個產品,每個框架,都在不停的擴展,

而我們經常會選擇一些擴展的擴展方式,也就是將新舊功能擴展成一個通用實現,

我想討論是,有些情況下也可以考慮增量式的擴展方式,也就是保留原功能的簡單性,新功能獨立實現,

我最近一直做分佈式服務框架的開發,就拿我們項目中的問題開涮吧。

比如:遠程調用框架,肯定少不了序列化功能,功能很簡單,就是把流轉成對象,對象轉成流,

但因有些地方可能會使用osgi,這樣序列化時,IO所在的ClassLoader可能和業務方的ClassLoader是隔離的,

需要將流轉換成byte[]數組,然後傳給業務方的ClassLoader進行序列化,

為了適應osgi需求,把原來非osgi與osgi的場景擴展了一下,

這樣,不管是不是osgi環境,都先將流轉成byte[]數組,拷貝一次,

然而,大部分場景都用不上osgi,卻為osgi付出了代價,

而如果採用增量式擴展方式,非osgi的代碼原封不動,

再加一個osgi的實現,要用osgi的時候,直接依賴osgi實現即可。

再比如:最開始,遠程服務都是基於接口方法,進行透明化調用的,

這樣,擴展接口就是,invoke(Method method, Object[] args),

後來,有了無接口調用的需求,就是沒有接口方法也能調用,並將POJO對象都轉換成Map表示,

因為Method對象是不能直接new出來的,我們不自覺選了一個擴展式擴展,

把擴展接口改成了invoke(String methodName, String[] parameterTypes, String returnTypes, Object[] args),

導致不管是不是無接口調用,都得把parameterTypes從Class[]轉成String[],

如果選用增量式擴展,應該是保持原有接口不變,

增加一個GeneralService接口,裡面有一個通用的invoke()方法,

和其它正常業務上的接口一樣的調用方式,擴展接口也不用變,

只是GeneralServiceImpl的invoke()實現會將收到的調用轉給目標接口,

這樣就能將新功能增量到舊功能上,並保持原來結構的簡單性。

再再比如:無狀態消息發送,很簡單,序列化一個對象發過去就行,

後來有了同步消息發送需求,需要一個Request/Response進行配對,

採用擴展式擴展,自然想到,無狀態消息其實是一個沒有Response的Request,

所以在Request里加一個boolean狀態,表示要不要返回Response,

如果再來一個會話消息發送需求,那就再加一個Session交互,

然後發現,原來同步消息發送是會話消息的一種特殊情況,

所有場景都傳Session,不需要Session的地方無視即可。

如果採用增量式擴展,無狀態消息發送原封不動,

同步消息發送,在無狀態消息基礎上加一個Request/Response處理,

會話消息發送,再加一個SessionRequest/SessionResponse處理。

Dubbo作者聊 設計原則

image.png

Dubbo作者聊 設計原則

image.png


配置設計

轉於自己在公司的Blog:

http://pt.alibaba-inc.com/wp/experience_1182/sofeware-configuration-design.html

Dubbo現在的設計是完全無侵入,也就是使用者只依賴於配置契約,

經過多個版本的發展,為了滿足各種需求場景,配置越來越多,

為了保持兼容,配置只增不減,裡面潛伏著各種風格,約定,規則,

新版本也將配置做了一次調整,去掉了dubbo.properties,改為全spring配置,

將想到的一些記在這,備忘。

1. 配置分類

首先,配置的用途是有多種的,大致可以分為:

(1) 環境配置,比如:連接數,超時等配置。

(2) 描述配置,比如:服務接口描述,服務版本等。

(3) 擴展配置,比如:協議擴展,策略擴展等。

2. 配置格式

(1) 通常環境配置,用properties配置會比較方便,

因為都是一些離散的簡單值,用key-value配置可以減少配置的學習成本。

(2) 而描述配置,通常信息比較多,甚至有層次關係,

用xml配置會比較方便,因為樹結構的配置表現力更強,

如果非常複雜,也可以考自定義DSL做為配置,

有時候這類配置也可以用Annotation代替,

因為這些配置和業務邏輯相關,放在代碼裡也是合理的。

(3) 另外擴展配置,可能不盡相同,

如果只是策略接口實現類替換,可以考慮properties等結構,

如果有複雜的生命週期管理,可能需要XML等配置,

有時候擴展會通過註冊接口的方式提供。

3. 配置加載

(1) 對於環境配置,

在java世界裡,比較常規的做法,

是在classpath下約定一個以項目為名稱的properties配置,

比如:log4j.properties,velocity.properties等,

產品在初始化時,自動從classpath下加載該配置,

我們平臺的很多項目也使用類似策略,

如:dubbo.properties,comsat.xml等,

這樣有它的優勢,就是基於約定,簡化了用戶對配置加載過程的干預,

但同樣有它的缺點,當classpath存在同樣的配置時,可能誤加載,

以及在ClassLoader隔離時,可能找不到配置,

並且,當用戶希望將配置放到統一的目錄時,不太方便。

Dubbo新版本去掉了dubbo.properties,因為該約定經常造成配置衝突。

(2) 而對於描述配置,

因為要參與業務邏輯,通常會嵌到應用的生命週期管理中,

現在使用spring的項目越來越多,直接使用spring配置的比較普遍,

而且spring允許自定義schema,配置簡化後很方便,

當然,也有它的缺點,就是強依賴spring,

可以提編程接口做了配套方案。

在Dubbo即存在描述配置,也有環境配置,

一部分用spring的schame配置加載,一部分從classpath掃描properties配置加載,

用戶感覺非常不便,所以在新版本中進行了合併,

統一放到spring的schame配置加載,也增加了配置的靈活性。

(3) 擴展配置,通常對配置的聚合要求比較高,

因為產品需要發現第三方實現,將其加入產品內部,

在java世裡,通常是約定在每個jar包下放一個指定文件加載,

比如:eclipse的plugin.xml,struts2的struts-plugin.xml等,

這類配置可以考慮java標準的服務發現機制,

即在jar包的META-INF/services下放置接口類全名文件,內容為每行一個實現類類名,

就像jdk中的加密算法擴展,腳本引擎擴展,新的JDBC驅動等,都是採用這種方式,

參見:ServiceProvider規範

Dubbo舊版本通過約定在每個jar包下,

放置名為dubbo-context.xml的spring配置進行擴展與集成,

新版本改成用jdk自帶的META-INF/services方式,

去掉過多的spring依賴。

4. 可編程配置

配置的可編程性是非常必要的,不管你以何種方式加載配置文件,

都應該提供一個編程的配置方式,允許用戶不使用配置文件,直接用代碼完成配置過程,

因為一個產品,尤其是組件類產品,通常需要和其它產品協作使用,

當用戶集成你的產品時,可能需要適配配置方式。

Dubbo新版本提供了與xml配置一對一的配置類,

如:ServiceConfig對應<service>,並且屬性也一對一,

這樣有利於文件配置與編程配置的一致性理解,減少學習成本。

5. 配置缺省值

配置的缺省值,通常是設置一個常規環境的合理值,這樣可以減少用戶的配置量,

通常建議以線上環境為參考值,開發環境可以通過修改配置適應,

缺省值的設置,最好在最外層的配置加載就做處理,

程序底層如果發現配置不正確,就應該直接報錯,容錯在最外層做,

如果在程序底層使用時,發現配置值不合理,就填一個缺省值,

很容易掩蓋表面問題,而引發更深層次的問題,

並且配置的中間傳遞層,很可能並不知道底層使用了一個缺省值,

一些中間的檢測條件就可能失效,

Dubbo就出現過這樣的問題,中間層用“地址”做為緩存Key,

而底層,給“地址”加了一個缺省端口號,

導致不加端口號的“地址”和加了缺省端口的“地址”並沒有使用相同的緩存。

6. 配置一致性

配置總會隱含一些風格或潛規則,應儘可能保持其一致性,

比如:很多功能都有開關,然後有一個配置值:

(1) 是否使用註冊中心,註冊中心地址。

(2) 是否允許重試,重試次數。

你可以約定:

(1) 每個都是先配置一個boolean類型的開關,再配置一個值。

(2) 用一個無效值代表關閉,N/A地址,0重試次數等。

不管選哪種方式,所有配置項,都應保持同一風格,Dubbo選的是第二種,

相似的還有,超時時間,重試時間,定時器間隔時間,

如果一個單位是秒,另一個單位是毫秒(C3P0的配置項就是這樣),配置人員會瘋掉。

7. 配置覆蓋

提供配置時,要同時考慮開發人員,測試人員,配管人員,系統管理員,

測試人員是不能修改代碼的,而測試的環境很可能較為複雜,

需要為測試人員留一些“後門”,可以在外圍修改配置項,

就像spring的PropertyPlaceholderConfigurer配置,支持SYSTEM_PROPERTIES_MODE_OVERRIDE,

可以通過JVM的-D參數,或者像hosts一樣約定一個覆蓋配置文件,

在程序外部,修改部分配置,便於測試。

Dubbo支持通過JVM參數-Dcom.xxx.XxxService=dubbo://10.1.1.1:1234

直接使遠程服務調用繞過註冊中心,進行點對點測試。

還有一種情況,開發人員增加配置時,都會按線上的部署情況做配置,如:

<registry>

因為線上只有一個註冊中心,這樣的配置是沒有問題的,

而測試環境可能有兩個註冊中心,測試人員不可能去修改配置,改為:

<registry>

<registry>

所以這個地方,Dubbo支持在${dubbo.registry.address}的值中,

通過豎號分隔多個註冊中心地址,用於表示多註冊中心地址。

8. 配置繼承

配置也存在“重複代碼”,也存在“泛化與精化”的問題,

比如:Dubbo的超時時間設置,每個服務,每個方法,都應該可以設置超時時間,

但很多服務不關心超時,如果要求每個方法都配置,是不現實的,

所以Dubbo採用了,方法超時繼承服務超時,服務超時再繼承缺省超時,沒配置時,一層層向上查找。

另外,Dubbo舊版本所有的超時時間,重試次數,負載均衡策略等都只能在服務消費方配置,

但實際使用過程中發現,服務提供方比消費方更清楚,但這些配置項是在消費方執行時才用到的,

新版本,就加入了在服務提供方也能配這些參數,通過註冊中心傳遞到消費方,

做為參考值,如果消費方沒有配置,就以提供方的配置為準,相當於消費方繼承了提供方的建議配置值,

而註冊中心在傳遞配置時,也可以在中途修改配置,這樣就達到了治理的目的,繼承關係相當於:

服務消費者 --> 註冊中心 --> 服務提供者

Dubbo作者聊 設計原則

image.png

9. 配置向後兼容

向前兼容很好辦,你只要保證配置只增不減,就基本上能保證向前兼容,

但向後兼容,也是要注意的,要為後續加入新的配置項做好準備,

如果配置出現一個特殊配置,就應該為這個“特殊”情況約定一個兼容規則,

因為這個特殊情況,很有可能在以後還會發生,

比如:有一個配置文件是保存“服務=地址”映射關係的,

其中有一行特殊,保存的是“註冊中心=地址”,

現在程序加載時,約定“註冊中心”這個Key是特殊的,

做特別處理,其它的都是“服務”,

然而,新版本發現,要加一項“監控中心=地址”,

這時,舊版本的程序會把“監控中心”做為“服務”處理,

因為舊代碼是不能改的,兼容性就很會很麻煩,

如果先前約定“特殊標識+XXX”為特殊處理,後續就會方便很多。

向後兼容性,可以多向HTML5學習,參見:HTML5設計原理


實現的健壯性

轉於自己在公司的Blog:http://pt.alibaba-inc.com/wp/experience_1224/robustness-of-implement.html

Dubbo作為遠程服務暴露、調用和治理的解決方案,是應用運轉的經絡,其本身實現健壯性的重要程度是不言而喻的。

這裡列出一些Dubbo用到的原則和方法。

一、日誌

日誌是發現問題、查看問題一個最常用的手段。

日誌質量往往被忽視,沒有日誌使用上的明確約定。

重視Log的使用,提高Log的信息濃度。

日誌過多、過於混亂,會導致有用的信息被淹沒。

要有效利用這個工具要注意:

嚴格約定WARN、ERROR級別記錄的內容

  • WARN表示可以恢復的問題,無需人工介入。

  • ERROR表示需要人工介入問題。

有了這樣的約定,監管系統發現日誌文件的中出現ERROR字串就報警,又儘量減少了發生。

過多的報警會讓人疲倦,使人對報警失去警惕性,使ERROR日誌失去意義。

再輔以人工定期查看WARN級別信息,以評估系統的“亞健康”程度。

日誌中,儘量多的收集關鍵信息

哪些是關鍵信息呢?

  • 出問題時的現場信息,即排查問題要用到的信息。如服務調用失敗時,要給出 使用Dubbo的版本、服務提供者的IP、使用的是哪個註冊中心;調用的是哪個服務、哪個方法等等。這些信息如果不給出,那麼事後人工收集的,問題過後現場可能已經不能復原,加大排查問題的難度。

  • 如果可能,給出問題的原因和解決方法。這讓維護和問題解決變得簡單,而不是尋求精通者(往往是實現者)的幫助。

同一個或是一類問題不要重複記錄多次

同一個或是一類異常日誌連續出現幾十遍的情況,還是常常能看到的。人眼很容易漏掉淹沒在其中不一樣的重要日誌信息。要儘量避免這種情況。在可以預見會出現的情況,有必要加一些邏輯來避免。

如為一個問題準備一個標誌,出問題後打日誌後設置標誌,避免重複打日誌。問題恢復後清除標誌。

雖然有點麻煩,但是這樣做保證日誌信息濃度,讓監控更有效。

二、界限設置

資源是有限的,CPU、內存、IO等等。不要因為外部的請求、數據不受限的而崩潰。

線程池(ExectorService)的大小和飽和策略

Server端用於處理請求的ExectorService設置上限。

ExecutorService的任務等待隊列使用有限隊列,避免資源耗盡。

當任務等待隊列飽和時,選擇一個合適的飽和策略。這樣保證平滑劣化。

在Dubbo中,飽和策略是丟棄數據,等待結果也只是請求的超時。

達到飽和時,說明已經達到服務提供方的負荷上限,要在飽和策略的操作中日誌記錄這個問題,以發出監控警報。

記得注意不要重複多次記錄哦。

(注意,缺省的飽和策略不會有這些附加的操作。)

根據警報的頻率,已經決定擴容調整等等,避免系統問題被忽略。

集合容量

如果確保進入集合的元素是可控的且是足夠少,則可以放心使用。這是大部分的情況。

如果不能保證,則使用有有界的集合。當到達界限時,選擇一個合適的丟棄策略。

三、容錯-重試-恢復

高可用組件要容忍其依賴組件的失敗。

Dubbo的服務註冊中心

目前服務註冊中心使用了數據庫來保存服務提供者和消費者的信息;

註冊中心集群不同註冊中心也通過數據庫來之間同步數據,以感知其它註冊中心上提供者。

註冊中心會內存中保證一份提供者和消費者數據,數據庫不可用時,註冊中心獨立對外正常運轉,只是拿不到其它註冊中心的數據。

當數據庫恢復時,重試邏輯會內存中修改的數據寫回數據庫,並拿到數據庫中新數據。

服務的消費者

服務消息者從註冊中心拿到提供者列表後,會保存提供者列表到內存和磁盤文件中。

這樣註冊中心宕後消費者可以正常運轉,甚至可以在註冊中心宕機過程中重啟消費者。

消費者啟動時,發現註冊中心不可用,會讀取保存在磁盤文件中提供者列表。

重試邏輯保證註冊中心恢復後,更新信息。

四、重試延遲策略

上一點的子問題。Dubbo中碰到有兩個相關的場景。

數據庫上的活鎖

註冊中心會定時更新數據庫一條記錄的時間戳,這樣集群中其它的註冊中心感知它是存活。

過期註冊中心和它的相關數據 會被清除。數據庫正常時,這個機制運行良好。

但是數據庫負荷高時,其上的每個操作都會很慢。這就出現:

A註冊中心認為B過期,刪除B的數據。 B發現自己的數據沒有了,重新寫入自己的數據。 的反覆操作。這些反覆的操作又加重了數據庫的負荷,惡化問題。

可以使用下面邏輯

當B發現自己數據被刪除時(寫入失敗),選擇等待這段時間再重試。

重試時間可以選擇指數級增長,如第一次等1分鐘,第二次10分鐘、第三次100分鐘。

這樣操作減少後,保證數據庫可以冷卻(Cool Down)下來。

Client重連註冊中心

當一個註冊中心停機時,其它的Client會同時接收事件,而去重連另一個註冊中心。

Client數量相對比較多,會對註冊中心造成衝擊。

避免方法可以是Client重連時隨機延時3分鐘,把重連分散開。


防痴呆設計

轉於自己在公司的Blog:

http://pt.alibaba-inc.com/wp/experience_1014/design-for-dummy.html

最近有點痴呆,因為解決了太多的痴呆問題,

服務框架實施面超來超廣,已有50多個項目在使用,

每天都要去幫應用查問題,來來回回,

發現大部分都是配置錯誤,或者重複的文件或類,或者網絡不通等,

所以準備在新版本中加入防痴呆設計,估且這麼叫吧,

可能很簡單,但對排錯速度還是有點幫助,

希望能拋磚引玉,也希望大家多給力,想出更多的防範措施共享出來。

(1) 檢查重複的jar包

最痴呆的問題,就是有多個版本的相同jar包,

會出現新版本的A類,調用了舊版本的B類,

而且和JVM加載順序有關,問題帶有偶然性,誤導性,

遇到這種莫名其妙的問題,最頭疼,

所以,第一條,先把它防住,

在每個jar包中挑一個一定會加載的類,加上重複類檢查,

給個示例:

static { 
Duplicate.checkDuplicate(Xxx.class);
}

檢查重複工具類:

public final class Duplicate { private Duplicate() {} public static void checkDuplicate(Class cls) {
checkDuplicate(cls.getName().replace('.', '/') + ".class");
} public static void checkDuplicate(String path) { try { // 在ClassPath搜文件
Enumeration urls = Thread.currentThread().getContextClassLoader().getResources(path);
Set files = new HashSet(); while (urls.hasMoreElements()) {
URL url = urls.nextElement(); if (url != null) {
String file = url.getFile(); if (file != null && file.length() > 0) {
files.add(file);
}
}
} // 如果有多個,就表示重複
if (files.size() > 1) {
logger.error("Duplicate class " + path + " in " + files.size() + " jar " + files);
}
} catch (Throwable e) { // 防禦性容錯
logger.error(e.getMessage(), e);

}
}
}

(2) 檢查重複的配置文件

配置文件加載錯,也是經常碰到的問題,

用戶通常會和你說:“我配置的很正確啊,不信我發給你看下,但就是報錯”,

然後查一圈下來,原來他發過來的配置根本沒加載,

平臺很多產品都會在classpath下放一個約定的配置,

如果項目中有多個,通常會取JVM加載的第一個,

為了不被這麼低級的問題折騰,

和上面的重複jar包一樣,在配置加載的地方,加上:

Duplicate.checkDuplicate("xxx.properties");

(3) 檢查所有可選配置

必填配置估計大家都會檢查,因為沒有的話,根本沒法運行,

但對一些可選參數,也應該做一些檢查,

比如:服務框架允許通過註冊中心關聯服務消費者和服務提供者,

也允許直接配置服務提供者地址點對點直連,

這時候,註冊中心地址是可選的,

但如果沒有配點對點直連配置,註冊中心地址就一定要配,

這時候也要做相應檢查。

(4) 異常信息給出解決方案

在給應用排錯時,最怕的就是那種只有簡單的一句錯誤描述,啥信息都沒有的異常信息,

比如上次碰到一個Failed to get session異常,

就這幾個單詞,啥都沒有,哪個session出錯? 什麼原因Failed?

看了都快瘋掉,因是線上環境不好調試,而且有些場景不是每次都能重現,

異常最基本要帶有上下文信息,包括操作者,操作目標,原因等,

最好的異常信息,應給出解決方案,比如上面可以給出:

"從10.20.16.3到10.20.130.20:20880之間的網絡不通,

請在10.20.16.3使用telnet 10.20.130.20 20880測試一下網絡,

如果是跨機房調用,可能是防火牆阻擋,請聯繫SA開通訪問權限"

等等,上面甚至可以根據IP段判斷是不是跨機房。

另外一個例子,是spring-web的context加載,

如果在getBean時spring沒有被啟動,

spring會報一個錯,錯誤信息寫著:

請在web.xml中加入:<listener>...<init-param>.../<init-param>/<listener>

多好的同學,看到錯誤的人複製一下就完事了,我們該學學,

可以把常見的錯誤故意犯一遍,看看錯誤信息能否自我搞定問題,

或者把平時支持應用時遇到的問題及解決辦法都寫到異常信息裡。

(5) 日誌信息包含環境信息

每次應用一出錯,應用的開發或測試就會把出錯信息發過來,詢問原因,

這時候我都會問一大堆套話,

用的哪個版本呀?

是生產環境還是開發測試環境?

哪個註冊中心呀?

哪個項目中的?

哪臺機器呀?

哪個服務?

。。。

累啊,最主要的是,有些開發或測試人員根本分不清,

沒辦法,只好提供上門服務,浪費的時間可不是浮雲,

所以,日誌中最好把需要的環境信息一併打進去,

最好給日誌輸出做個包裝,統一處理掉,免得忘了。

包裝Logger接口如:

public void error(String msg, Throwable e) { 
delegate.error(msg + " on server " + InetAddress.getLocalHost() + " using version " + Version.getVersion(), e);
}

獲取版本號工具類:

public final class Version { private Version() {} private static final Logger logger = LoggerFactory.getLogger(Version.class); private static final Pattern VERSION_PATTERN = Pattern.compile("([0-9][0-9\\\\.\\\\-]*)\\\\.jar"); private static final String VERSION = getVersion(Version.class, "2.0.0"); public static String getVersion(){ return VERSION;
} public static String getVersion(Class cls, String defaultVersion) { try { // 首先查找MANIFEST.MF規範中的版本號
String version = cls.getPackage().getImplementationVersion(); if (version == null || version.length() == 0) {
version = cls.getPackage().getSpecificationVersion();
} if (version == null || version.length() == 0) { // 如果MANIFEST.MF規範中沒有版本號,基於jar包名獲取版本號

String file = cls.getProtectionDomain().getCodeSource().getLocation().getFile(); if (file != null && file.length() > 0 && file.endsWith(".jar")) {
Matcher matcher = VERSION_PATTERN.matcher(file); while (matcher.find() && matcher.groupCount() > 0) {
version = matcher.group(1);
}
}
} // 返回版本號,如果為空返回缺省版本號
return version == null || version.length() == 0 ? defaultVersion : version;
} catch (Throwable e) { // 防禦性容錯
// 忽略異常,返回缺省版本號
logger.error(e.getMessage(), e); return defaultVersion;
}
}
}

(6) kill之前先dump

每次線上環境一出問題,大家就慌了,

通常最直接的辦法回滾重啟,以減少故障時間,

這樣現場就被破壞了,要想事後查問題就麻煩了,

有些問題必須在線上的大壓力下才會發生,

線下測試環境很難重現,

不太可能讓開發或Appops在重啟前,

先手工將出錯現場所有數據備份一下,

所以最好在kill腳本之前調用dump,

進行自動備份,這樣就不會有人為疏忽。

dump腳本示例:

JAVA_HOME=/usr/java
OUTPUT_HOME=~/output
DEPLOY_HOME=`dirname $0`
HOST_NAME=`hostname`
DUMP_PIDS=`ps --no-heading -C java -f --width 1000 | grep "$DEPLOY_HOME" |awk '{print $2}'`if [ -z "$DUMP_PIDS" ]; then
echo "The server $HOST_NAME is not started!"
exit 1;fiDUMP_ROOT=$OUTPUT_HOME/dumpif [ ! -d $DUMP_ROOT ]; then
mkdir $DUMP_ROOTfiDUMP_DATE=`date +%Y%m%d%H%M%S`
DUMP_DIR=$DUMP_ROOT/dump-$DUMP_DATEif [ ! -d $DUMP_DIR ]; then
mkdir $DUMP_DIRfiecho -e "Dumping the server $HOST_NAME ...\\c"for PID in $DUMP_PIDS ; do
$JAVA_HOME/bin/jstack $PID > $DUMP_DIR/jstack-$PID.dump 2>&1 echo -e ".\\c"
$JAVA_HOME/bin/jinfo $PID > $DUMP_DIR/jinfo-$PID.dump 2>&1 echo -e ".\\c"
$JAVA_HOME/bin/jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil-$PID.dump 2>&1 echo -e ".\\c"
$JAVA_HOME/bin/jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity-$PID.dump 2>&1 echo -e ".\\c"
$JAVA_HOME/bin/jmap $PID > $DUMP_DIR/jmap-$PID.dump 2>&1 echo -e ".\\c"
$JAVA_HOME/bin/jmap -heap $PID > $DUMP_DIR/jmap-heap-$PID.dump 2>&1 echo -e ".\\c"
$JAVA_HOME/bin/jmap -histo $PID > $DUMP_DIR/jmap-histo-$PID.dump 2>&1 echo -e ".\\c"
if [ -r /usr/sbin/lsof ]; then
/usr/sbin/lsof -p $PID > $DUMP_DIR/lsof-$PID.dump echo -e ".\\c"
fidoneif [ -r /usr/bin/sar ]; then/usr/bin/sar > $DUMP_DIR/sar.dumpecho -e ".\\c"fiif [ -r /usr/bin/uptime ]; then/usr/bin/uptime > $DUMP_DIR/uptime.dumpecho -e ".\\c"fiif [ -r /usr/bin/free ]; then/usr/bin/free -t > $DUMP_DIR/free.dumpecho -e ".\\c"fiif [ -r /usr/bin/vmstat ]; then/usr/bin/vmstat > $DUMP_DIR/vmstat.dumpecho -e ".\\c"fiif [ -r /usr/bin/mpstat ]; then/usr/bin/mpstat > $DUMP_DIR/mpstat.dumpecho -e ".\\c"fiif [ -r /usr/bin/iostat ]; then/usr/bin/iostat > $DUMP_DIR/iostat.dumpecho -e ".\\c"fiif [ -r /bin/netstat ]; then/bin/netstat > $DUMP_DIR/netstat.dumpecho -e ".\\c"fiecho "OK!"

Dubbo擴展點重構

轉於自己在公司的Blog:

http://pt.alibaba-inc.com/wp/dev_related_1283/dubbo-extension.html

隨著服務化的推廣,網站對Dubbo服務框架的需求逐漸增多,

Dubbo的現有開發人員能實現的需求有限,很多需求都被delay,

而網站的同學也希望參與進來,加上領域的推動,

所以平臺計劃將部分項目對公司內部開放,讓大家一起來實現,

Dubbo為試點項目之一。

既然要開放,那Dubbo就要留一些擴展點,

讓參與者儘量黑盒擴展,而不是白盒的修改代碼,

否則分支,質量,合併,衝突都會很難管理。

先看一下Dubbo現有的設計:

Dubbo作者聊 設計原則

image.png

這裡面雖然有部分擴展接口,但並不能很好的協作,

而且擴展點的加載和配置都沒有統一處理,所以下面對它進行重構。

第一步,微核心,插件式,平等對待第三方。

即然要擴展,擴展點的加載方式,首先要統一,

微核心+插件式,是比較能達到OCP原則的思路,

由一個插件生命週期管理容器,構成微核心,

核心不包括任何功能,這樣可以確保所有功能都能被替換,

並且,框架作者能做到的功能,擴展者也一定要能做到,以保證平等對待第三方,

所以,框架自身的功能也要用插件的方式實現,不能有任何硬編碼。

通常微核心都會採用Factory,IoC,OSGi等方式管理插件生命週期,

考慮Dubbo的適用面,不想強依賴Spring等IoC容器,

自已造一個小的IoC容器,也覺得有點過度設計,

所以打算採用最簡單的Factory方式管理插件,

最終決定採用的是JDK標準的SPI擴展機制,參見:java.util.ServiceLoader

也就是擴展者在jar包的META-INF/services/目錄下放置與接口同名的文本文件,

內容為接口實現類名,多個實現類名用換行符分隔,

比如,需要擴展Dubbo的協議,只需在xxx.jar中放置:

文件:META-INF/services/com.alibaba.dubbo.rpc.Protocol

內容為:com.alibaba.xxx.XxxProtocol

Dubbo通過ServiceLoader掃描到所有Protocol實現。

並約定所有插件,都必須標註:@Extension("name"),

作為加載後的標識性名稱,用於配置選擇。

第二步,每個擴展點只封裝一個變化因子,最大化複用。

每個擴展點的實現者,往往都只是關心一件事,

現在的擴展點,並沒有完全分離,

比如:Failover, Route, LoadBalance, Directory沒有完全分開,全由RoutingInvokerGroup寫死了。

再比如,協議擴展,擴展者可能只是想替換序列化方式,或者只替換傳輸方式,

並且Remoting和Http也能複用序列化等實現,

這樣,需為傳輸方式,客戶端實現,服務器端實現,協議頭解析,數據序列化,都留出不同擴展點。

拆分後,設計如下:

Dubbo作者聊 設計原則

image.png

第三步,全管道式設計,框架自身邏輯,均使用截面攔截實現。

現在很多的邏輯,都是放在基類中實現,然後通過模板方法回調子類的實現,

包括:local, mock, generic, echo, token, accesslog, monitor, count, limit等等,

可以全部拆分使用Filter實現,每個功能都是調用鏈上的一環。

比如:(基類模板方法)

public abstract AbstractInvoker implements Invoker { public Result invoke(Invocation inv) throws RpcException { // 偽代碼
active ++; if (active > max)
wait();
doInvoke(inv);
active --;
notify();
}
protected abstract Result doInvoke(Invocation inv) throws RpcException
}

改成:(鏈式過濾器)

public abstract LimitFilter implements Filter { public Result invoke(Invoker chain, Invocation inv) throws RpcException { // 偽代碼
active ++; if (active > max)
wait();
chain.invoke(inv);
active --;
notify();
}
}

第四步,最少概念,一致性概念模型。

保持儘可能少的概念,有助於理解,對於開放的系統尤其重要,

另外,各接口都使用一致的概念模型,能相互指引,並減少模型轉換,

比如,Invoker的方法簽名為:

Result invoke(Invocation invocation) throws RpcException; 

而Exporter的方法簽名為:

Object invoke(Method method, Object[] args) throws Throwable;

但它們的作用是一樣的,只是一個在客戶端,一個在服務器端,卻採用了不一樣的模型類。

再比如,URL以字符串傳遞,不停的解析和拼裝,沒有一個URL模型類, 而URL的參數,卻時而Map, 時而Parameters類包裝,

export(String url)
createExporter(String host, int port, Parameters params);

使用一致模型:

export(URL url)
createExporter(URL url);

再比如,現有的:Invoker, Exporter, InvocationHandler, FilterChain

其實都是invoke行為的不同階段,完全可以抽象掉,統一為Invoker,減少概念。

第五步,分層,組合式擴展,而不是泛化式擴展。

原因參見:http://javatar.iteye.com/blog/690845

泛化式擴展指:將擴展點逐漸抽象,取所有功能並集,新加功能總是套入並擴充舊功能的概念。

組合式擴展指:將擴展點正交分解,取所有功能交集,新加功能總是基於舊功能之上實現。

上面的設計,不自覺的就將Dubbo現有功能都當成了核心功能,

上面的概念包含了Dubbo現有RPC的所有功能,包括:Proxy, Router, Failover, LoadBalance, Subscriber, Publisher, Invoker, Exporter, Filter等,

但這些都是核心嗎?踢掉哪些,RPC一樣可以Run?而哪些又是不能踢掉的?

基於這樣考慮,可以將RPC分解成兩個層次,只是Protocol和Invoker才是RPC的核心,

其它,包括Router, Failover, Loadbalance, Subscriber, Publisher都不核心,而是Routing,

所以,將Routing作為Rpc核心的一個擴展,設計如下:

Dubbo作者聊 設計原則

image.png

第六步,整理,梳理關係。

整理後,設計如下:

Dubbo作者聊 設計原則

image.png


個人介紹:

高廣超:多年一線互聯網研發與架構設計經驗,擅長設計與落地高可用、高性能、可擴展的互聯網架構。

本文首發在 高廣超的簡書博客 轉載請註明!


分享到:


相關文章: