編碼約定
代碼風格
Dubbo 的源代碼和 JavaDoc 遵循以下的規范:
異常和日志
- 盡可能攜帶完整的上下文信息,比如出錯原因,出錯的機器地址,調用對方的地址,連的注冊中心地址,使用 Dubbo 的版本等。
- 盡量將直接原因寫在最前面,所有上下文信息,在原因后用鍵值對顯示。
- 拋出異常的地方不用打印日志,由最終處理異常者決定打印日志的級別,吃掉異常必需打印日志。
- 打印 ERROR 日志表示需要報警,打印 WARN 日志表示可以自動恢復,打印 INFO 表示正常信息或完全不影響運行。
- 建議應用方在監控中心配置 ERROR 日志實時報警,WARN 日志每周匯總發送通知。
- RpcException 是 Dubbo 對外的唯一異常類型,所有內部異常,如果要拋出給用戶,必須轉為 RpcException。
- RpcException 不能有子類型,所有類型信息用 ErrorCode 標識,以便保持兼容。
配置和 URL
- 配置對象屬性首字母小寫,多個單詞用駝峰命名 。
- 配置屬性全部用小寫,多個單詞用"-"號分隔 。
- URL參數全部用小寫,多個單詞用"."號分隔 。
- 盡可能用 URL 傳參,不要自定義 Map 或其它上下文格式,配置信息也轉成 URL 格式使用。
- 盡量減少 URL 嵌套,保持 URL 的簡潔性。
單元和集成測試
- 單元測試統一用 JUnit 和 EasyMock,集成測試用 TestNG,數據庫測試用 DBUnit。
- 保持單元測試用例的運行速度,不要將性能和大的集成用例放在單元測試中。
- 保持單元測試的每個用例都用 try...finally 或 tearDown 釋放資源。
- 減少 while 循環等待結果的測試用例,對定時器和網絡的測試,用以將定時器中的邏輯抽為方法測試。
- 對於容錯行為的測試,比如 failsafe 的測試,統一用 LogUtil 斷言日志輸出。
擴展點基類與 AOP
- AOP 類都命名為 XxxWrapper,基類都命名為 AbstractXxx。
- 擴展點之間的組合將關系由 AOP 完成,ExtensionLoader 只負載加載擴展點,包括 AOP 擴展。
- 盡量采用 IoC 注入擴展點之間的依賴,不要直接依賴 ExtensionLoader 的工廠方法。
- 盡量采用 AOP 實現擴展點的通用行為,而不要用基類,比如負載均衡之前的 isAvailable 檢查,它是獨立於負載均衡之外的,不需要檢查的是URL參數關閉。
- 對多種相似類型的抽象,用基類實現,比如 RMI, Hessian 等第三方協議都已生成了接口代理,只需將將接口代理轉成 Invoker 即可完成橋接,它們可以用公共基類實現此邏輯。
- 基類也是 SPI 的一部分,每個擴展點都應該有方便使用的基類支持。
模塊與分包
- 基於復用度分包,總是一起使用的放在同一包下,將接口和基類分成獨立模塊,大的實現也使用獨立模塊。
- 所有接口都放在模塊的根包下,基類放在 support 子包下,不同實現用放在以擴展點名字命名的子包下。
- 盡量保持子包依賴父包,而不要反向。
設計原則
一、魔鬼在細節
最近一直擔心 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 接口中。
合理的方式是,有一個單獨的 Renderer 接口,有 VelocityRenderer 和 JsonRenderer 實現,Web 框架將 Action 的輸出轉交給 Renderer 接口做渲染輸出。
服務域/實體域/會話域分離
任何框架或組件,總會有核心領域模型,比如:Spring 的 Bean,Struts 的 Action,Dubbo 的 Service,Napoli 的 Queue 等等。這個核心領域模型及其組成部分稱為實體域,它代表着我們要操作的目標本身。實體域通常是線程安全的,不管是通過不變類,同步狀態,或復制的方式。
服務域也就是行為域,它是組件的功能集,同時也負責實體域和會話域的生命周期管理, 比如 Spring 的 ApplicationContext,Dubbo 的 ServiceManager 等。服務域的對象通常會比較重,而且是線程安全的,並以單一實例服務於所有調用。
什么是會話?就是一次交互過程。會話中重要的概念是上下文,什么是上下文?比如我們說:“老地方見”,這里的“老地方”就是上下文信息。為什么說“老地方”對方會知道,因為我們前面定義了“老地方”的具體內容。所以說,上下文通常持有交互過程中的狀態變量等。會話對象通常較輕,每次請求都重新創建實例,請求結束后銷毀。簡而言之:把元信息交由實體域持有,把一次請求中的臨時狀態由會話域持有,由服務域貫穿整個過程。
在重要的過程上設置攔截接口
如果你要寫個遠程調用框架,那遠程調用的過程應該有一個統一的攔截接口。如果你要寫一個 ORM 框架,那至少 SQL 的執行過程,Mapping 過程要有攔截接口;如果你要寫一個 Web 框架,那請求的執行過程應該要有攔截接口,等等。沒有哪個公用的框架可以 Cover 住所有需求,允許外置行為,是框架的基本擴展方式。這樣,如果有人想在遠程調用前,驗證下令牌,驗證下黑白名單,統計下日志;如果有人想在 SQL 執行前加下分頁包裝,做下數據權限控制,統計下 SQL 執行時間;如果有人想在請求執行前檢查下角色,包裝下輸入輸出流,統計下請求量,等等,就可以自行完成,而不用侵入框架內部。攔截接口,通常是把過程本身用一個對象封裝起來,傳給攔截器鏈,比如:遠程調用主過程為 invoke(),那攔截器接口通常為 invoke(Invocation),Invocation 對象封裝了本來要執行過程的上下文,並且 Invocation 里有一個 invoke() 方法,由攔截器決定什么時候執行,同時,Invocation 也代表攔截器行為本身,這樣上一攔截器的 Invocation 其實是包裝的下一攔截器的過程,直到最后一個攔截器的 Invocation 是包裝的最終的 invoke() 過程;同理,SQL 主過程為 execute(),那攔截器接口通常為 execute(Execution),原理一樣。當然,實現方式可以任意,上面只是舉例。
重要的狀態的變更發送事件並留出監聽接口
這里先要講一個事件和上面攔截器的區別,攔截器是干預過程的,它是過程的一部分,是基於過程行為的,而事件是基於狀態數據的,任何行為改變的相同狀態,對事件應該是一致的。事件通常是事后通知,是一個 Callback 接口,方法名通常是過去式的,比如 onChanged()。比如遠程調用框架,當網絡斷開或連上應該發出一個事件,當出現錯誤也可以考慮發出一個事件,這樣外圍應用就有可能觀察到框架內部的變化,做相應適應。
擴展接口職責盡可能單一,具有可組合性
比如,遠程調用框架它的協議是可以替換的。如果只提供一個總的擴展接口,當然可以做到切換協議,但協議支持是可以細分為底層通訊,序列化,動態代理方式等等。如果將接口拆細,正交分解,會更便於擴展者復用已有邏輯,而只是替換某部分實現策略。當然這個分解的粒度需要把握好。
微核插件式,平等對待第三方
大凡發展的比較好的框架,都遵守微核的理念。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()。 另外,每個方法都盡量前置斷言傳入參數的合法性,后置斷言返回結果的合法性,並文檔化。
增量式擴展,而不要擴充原始核心概念
三、談談擴充式擴展與增量式擴展
我們平台的產品越來越多,產品的功能也越來越多。平台的產品為了適應各 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 現在的設計是完全無侵入,也就是使用者只依賴於配置契約。經過多個版本的發展,為了滿足各種需求場景,配置越來越多。為了保持兼容,配置只增不減,里面潛伏着各種風格,約定,規則。新版本也將配置做了一次調整,去掉了 dubbo.properties,改為全 spring 配置。將想到的一些記在這,備忘。
配置分類
首先,配置的用途是有多種的,大致可以分為:
- 環境配置,比如:連接數,超時等配置。
- 描述配置,比如:服務接口描述,服務版本等。
- 擴展配置,比如:協議擴展,策略擴展等。
配置格式
通常環境配置,用 properties 配置會比較方便,因為都是一些離散的簡單值,用 key-value 配置可以減少配置的學習成本。
而描述配置,通常信息比較多,甚至有層次關系,用 xml 配置會比較方便,因為樹結構的配置表現力更強。如果非常復雜,也可以考自定義 DSL 做為配置。有時候這類配置也可以用 Annotation 代替, 因為這些配置和業務邏輯相關,放在代碼里也是合理的。
另外擴展配置,可能不盡相同。如果只是策略接口實現類替換,可以考慮 properties 等結構。如果有復雜的生命周期管理,可能需要 XML 等配置。有時候擴展會通過注冊接口的方式提供。
配置加載
對於環境配置,在 java 世界里,比較常規的做法,是在 classpath 下約定一個以項目為名稱的 properties 配置,比如:log4j.properties,velocity.properties等。產品在初始化時,自動從 classpath 下加載該配置。我們平台的很多項目也使用類似策略,如:dubbo.properties,comsat.xml 等。這樣有它的優勢,就是基於約定,簡化了用戶對配置加載過程的干預。但同樣有它的缺點,當 classpath 存在同樣的配置時,可能誤加載,以及在 ClassLoader 隔離時,可能找不到配置,並且,當用戶希望將配置放到統一的目錄時,不太方便。
Dubbo 新版本去掉了 dubbo.properties,因為該約定經常造成配置沖突。
而對於描述配置,因為要參與業務邏輯,通常會嵌到應用的生命周期管理中。現在使用 spring 的項目越來越多,直接使用 spring 配置的比較普遍,而且 spring 允許自定義 schema,配置簡化后很方便。當然,也有它的缺點,就是強依賴 spring,可以提編程接口做了配套方案。
在 Dubbo 即存在描述配置,也有環境配置。一部分用 spring 的 schame 配置加載,一部分從 classpath 掃描 properties 配置加載。用戶感覺非常不便,所以在新版本中進行了合並,統一放到 spring 的 schame 配置加載,也增加了配置的靈活性。
擴展配置,通常對配置的聚合要求比較高。因為產品需要發現第三方實現,將其加入產品內部。在 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 依賴。
可編程配置
配置的可編程性是非常必要的,不管你以何種方式加載配置文件,都應該提供一個編程的配置方式,允許用戶不使用配置文件,直接用代碼完成配置過程。因為一個產品,尤其是組件類產品,通常需要和其它產品協作使用,當用戶集成你的產品時,可能需要適配配置方式。
Dubbo 新版本提供了與 xml 配置一對一的配置類,如:ServiceConfig 對應 <dubbo:service />
,並且屬性也一對一,這樣有利於文件配置與編程配置的一致性理解,減少學習成本。
配置缺省值
配置的缺省值,通常是設置一個常規環境的合理值,這樣可以減少用戶的配置量。通常建議以線上環境為參考值,開發環境可以通過修改配置適應。缺省值的設置,最好在最外層的配置加載就做處理。程序底層如果發現配置不正確,就應該直接報錯,容錯在最外層做。如果在程序底層使用時,發現配置值不合理,就填一個缺省值,很容易掩蓋表面問題,而引發更深層次的問題。並且配置的中間傳遞層,很可能並不知道底層使用了一個缺省值,一些中間的檢測條件就可能失效。Dubbo 就出現過這樣的問題,中間層用“地址”做為緩存 Key, 而底層,給“地址”加了一個缺省端口號,導致不加端口號的“地址”和加了缺省端口的“地址”並沒有使用相同的緩存。
配置一致性
配置總會隱含一些風格或潛規則,應盡可能保持其一致性。比如:很多功能都有開關,然后有一個配置值:
- 是否使用注冊中心,注冊中心地址。
- 是否允許重試,重試次數。
你可以約定:
- 每個都是先配置一個 boolean 類型的開關,再配置一個值。
- 用一個無效值代表關閉,N/A地址,0重試次數等。
不管選哪種方式,所有配置項,都應保持同一風格,Dubbo 選的是第二種。相似的還有,超時時間,重試時間,定時器間隔時間。如果一個單位是秒,另一個單位是毫秒(C3P0的配置項就是這樣),配置人員會瘋掉。
配置覆蓋
提供配置時,要同時考慮開發人員,測試人員,配管人員,系統管理員。測試人員是不能修改代碼的,而測試的環境很可能較為復雜,需要為測試人員留一些“后門”,可以在外圍修改配置項。就像 spring 的 PropertyPlaceholderConfigurer 配置,支持 SYSTEM_PROPERTIES_MODE_OVERRIDE
,可以通過 JVM 的 -D 參數,或者像 hosts 一樣約定一個覆蓋配置文件,在程序外部,修改部分配置,便於測試。
Dubbo 支持通過 JVM 參數 -Dcom.xxx.XxxService=dubbo://10.1.1.1:1234 直接使遠程服務調用繞過注冊中心,進行點對點測試。還有一種情況,開發人員增加配置時,都會按線上的部署情況做配置,如:<dubbo:registry address="${dubbo.registry.address}" /> 因為線上只有一個注冊中心,這樣的配置是沒有問題的,而測試環境可能有兩個注冊中心,測試人員不可能去修改配置,改為: <dubbo:registry address="${dubbo.registry.address1}" />, <dubbo:registry address="${dubbo.registry.address2}" />,所以這個地方,Dubbo 支持在 ${dubbo.registry.address} 的值中,通過豎號分隔多個注冊中心地址,用於表示多注冊中心地址。
配置繼承
配置也存在“重復代碼”,也存在“泛化與精化”的問題。比如:Dubbo 的超時時間設置,每個服務,每個方法,都應該可以設置超時時間。但很多服務不關心超時,如果要求每個方法都配置,是不現實的。所以 Dubbo 采用了方法超時繼承服務超時,服務超時再繼承缺省超時,沒配置時,一層層向上查找。
另外,Dubbo 舊版本所有的超時時間,重試次數,負載均衡策略等都只能在服務消費方配置。但實際使用過程中發現,服務提供方比消費方更清楚,但這些配置項是在消費方執行時才用到的。新版本,就加入了在服務提供方也能配這些參數,通過注冊中心傳遞到消費方, 做為參考值,如果消費方沒有配置,就以提供方的配置為准,相當於消費方繼承了提供方的建議配置值。而注冊中心在傳遞配置時,也可以在中途修改配置,這樣就達到了治理的目的,繼承關系相當於:服務消費者 --> 注冊中心 --> 服務提供者
配置向后兼容
向前兼容很好辦,你只要保證配置只增不減,就基本上能保證向前兼容。但向后兼容,也是要注意的,要為后續加入新的配置項做好准備。如果配置出現一個特殊配置,就應該為這個“特殊”情況約定一個兼容規則,因為這個特殊情況,很有可能在以后還會發生。比如:有一個配置文件是保存“服務=地址”映射關系的,其中有一行特殊,保存的是“注冊中心=地址”。現在程序加載時,約定“注冊中心”這個Key是特殊的,做特別處理,其它的都是“服務”。然而,新版本發現,要加一項“監控中心=地址”,這時,舊版本的程序會把“監控中心”做為“服務”處理,因為舊代碼是不能改的,兼容性就很會很麻煩。如果先前約定“特殊標識+XXX”為特殊處理,后續就會方便很多。
向后兼容性,可以多向HTML5學習,參見:HTML5設計原理
五、設計實現的健壯性
http://oldratlee.com/380/tech/java/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 分鍾,把重連分散開。
六、防痴呆設計
最近有點痴呆,因為解決了太多的痴呆問題。服務框架實施面超來超廣,已有 50 多個項目在使用,每天都要去幫應用查問題,來來回回,發現大部分都是配置錯誤,或者重復的文件或類,或者網絡不通等,所以准備在新版本中加入防痴呆設計。估且這么叫吧,可能很簡單,但對排錯速度還是有點幫助,希望能拋磚引玉,也希望大家多給力,想出更多的防范措施共享出來。
檢查重復的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); } } }
檢查重復的配置文件
配置文件加載錯,也是經常碰到的問題。用戶通常會和你說:“我配置的很正確啊,不信我發給你看下,但就是報錯”。然后查一圈下來,原來他發過來的配置根本沒加載,平台很多產品都會在 classpath 下放一個約定的配置,如果項目中有多個,通常會取JVM加載的第一個,為了不被這么低級的問題折騰,和上面的重復jar包一樣,在配置加載的地方,加上:
Duplicate.checkDuplicate("xxx.properties");
檢查所有可選配置
必填配置估計大家都會檢查,因為沒有的話,根本沒法運行。但對一些可選參數,也應該做一些檢查,比如:服務框架允許通過注冊中心關聯服務消費者和服務提供者,也允許直接配置服務提供者地址點對點直連,這時候,注冊中心地址是可選的,但如果沒有配點對點直連配置,注冊中心地址就一定要配,這時候也要做相應檢查。
異常信息給出解決方案
在給應用排錯時,最怕的就是那種只有簡單的一句錯誤描述,啥信息都沒有的異常信息。比如上次碰到一個 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>...
,多好的同學,看到錯誤的人復制一下就完事了,我們該學學。可以把常見的錯誤故意犯一遍,看看錯誤信息能否自我搞定問題, 或者把平時支持應用時遇到的問題及解決辦法都寫到異常信息里。
日志信息包含環境信息
每次應用一出錯,應用的開發或測試就會把出錯信息發過來,詢問原因,這時候我都會問一大堆套話,用的哪個版本呀?是生產環境還是開發測試環境?哪個注冊中心呀?哪個項目中的?哪台機器呀?哪個服務? 累啊,最主要的是,有些開發或測試人員根本分不清,沒辦法,只好提供上門服務,浪費的時間可不是浮雲,所以,日志中最好把需要的環境信息一並打進去,最好給日志輸出做個包裝,統一處理掉,免得忘了。包裝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; } } }
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; fi DUMP_ROOT=$OUTPUT_HOME/dump if [ ! -d $DUMP_ROOT ]; then mkdir $DUMP_ROOT fi DUMP_DATE=`date +%Y%m%d%H%M%S` DUMP_DIR=$DUMP_ROOT/dump-$DUMP_DATE if [ ! -d $DUMP_DIR ]; then mkdir $DUMP_DIR fi echo -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" fi done if [ -r /usr/bin/sar ]; then /usr/bin/sar > $DUMP_DIR/sar.dump echo -e ".\c" fi if [ -r /usr/bin/uptime ]; then /usr/bin/uptime > $DUMP_DIR/uptime.dump echo -e ".\c" fi if [ -r /usr/bin/free ]; then /usr/bin/free -t > $DUMP_DIR/free.dump echo -e ".\c" fi if [ -r /usr/bin/vmstat ]; then /usr/bin/vmstat > $DUMP_DIR/vmstat.dump echo -e ".\c" fi if [ -r /usr/bin/mpstat ]; then /usr/bin/mpstat > $DUMP_DIR/mpstat.dump echo -e ".\c" fi if [ -r /usr/bin/iostat ]; then /usr/bin/iostat > $DUMP_DIR/iostat.dump echo -e ".\c" fi if [ -r /bin/netstat ]; then /bin/netstat > $DUMP_DIR/netstat.dump echo -e ".\c" fi echo "OK!"
七、擴展點重構
隨着服務化的推廣,網站對Dubbo服務框架的需求逐漸增多,Dubbo 的現有開發人員能實現的需求有限,很多需求都被 delay,而網站的同學也希望參與進來,加上領域的推動,所以平台計划將部分項目對公司內部開放,讓大家一起來實現,Dubbo 為試點項目之一。
既然要開放,那 Dubbo 就要留一些擴展點,讓參與者盡量黑盒擴展,而不是白盒的修改代碼,否則分支,質量,合並,沖突都會很難管理。
先看一下 Dubbo 現有的設計:
這里面雖然有部分擴展接口,但並不能很好的協作,而且擴展點的加載和配置都沒有統一處理,所以下面對它進行重構。
第一步,微核心,插件式,平等對待第三方
即然要擴展,擴展點的加載方式,首先要統一,微核心+插件式,是比較能達到 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 也能復用序列化等實現。這樣,需為傳輸方式,客戶端實現,服務器端實現,協議頭解析,數據序列化,都留出不同擴展點。
拆分后,設計如下:
第三步,全管道式設計,框架自身邏輯,均使用截面攔截實現
現在很多的邏輯,都是放在基類中實現,然后通過模板方法回調子類的實現,包括: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,減少概念。
第五步,分層,組合式擴展,而不是泛化式擴展
原因參見:談談擴充式擴展與增量式擴展。
泛化式擴展指:將擴展點逐漸抽象,取所有功能並集,新加功能總是套入並擴充舊功能的概念。
組合式擴展指:將擴展點正交分解,取所有功能交集,新加功能總是基於舊功能之上實現。
上面的設計,不自覺的就將 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 核心的一個擴展,設計如下:
第六步,整理,梳理關系
整理后,設計如下:
摘自:http://dubbo.apache.org/books/dubbo-dev-book