關於介面設計的一些思考
引子
做維護型工作,最大的收穫也許就是知道什麼叫做醜陋了。本文針對我遇到的一些介面設計問題,總結了如下一些經驗分享給大家,希望我們能夠吸取經驗,對外提供最美的一面,即使我們的實現可能很醜,但是使用者不關心也看不到,這就是封裝的好處,哈哈。
1. 關於介面的粒度——應該提供應用無關的細粒度介面和應用相關的粗粒度介面
介面的粒度其實很大程度上是介面的職責問題。一般來說越細粒度的介面職責越內聚(偏向於Service),越粗粒度的介面職責越寬泛(偏向於Facade)。
筆者認為下面的做法是比較合理的:
-
對上層應用應該儘量提供粗粒度的介面,可以而且很多情況下是應用相關的。好處在於
- 提供更方便簡介的介面,遮蔽多介面的協作細節。
- 避免細粒度介面可能導致的多次讀取所帶來的不必要的效能消耗。比如,第一個方法呼叫查詢了資料庫得到的物件,在接下來的介面中都可以被使用。
- 減少不必要的多次引數檢查。
-
同時應該提供與具體應用無關的細粒度的介面,允許應用自己組裝應用邏輯。好處在於
- 業務無關,粒度夠細,有利於重用。
- 粒度細也就意味著職責清晰和內聚,利於解耦和維護。
- 利於單元測試
筆者認為介面一定要職責清晰,經常看到這樣的程式碼,在一個for迴圈中"順帶"做了一些其他事情,原因是在這裡作比較方便,否則需要重新遍歷一次,影響效能。但是由於這順帶作的事情,往往會導致介面職責不單一,汙染了介面,導致介面的複用性變差。所有應該儘量抵制誘惑,介面與類都應該是職責單一的。
再說介面的粒度,提供細粒度的介面有利於重用,就像積木塊一樣,應用可以根據需要自行組裝。如果一開始就提供太粗粒度的介面,往往會有這樣的情況,有些應用場景下我只需要做其中的幾個步驟,這時候就容易導致重複類似的程式碼出現了。這種情況下,應該將粗粒度的介面進行分解,將通用的步驟封裝成細粒度的介面(應該是應用無關),將粗粒度的介面暴露給上層應用(應該是應用相關的)。
使用粗粒度的介面可能帶來的一個問題是返回情偏多,具體處理詳見下面的關於返回值的討論。
2. 關於介面的命名——使用面向場景的介面簽名
介面名稱應該儘量面向場景,這不僅是介面友好性的表現,另一方面也是避免內部邏輯外洩的重要措施。比如:現在是否通過AV認證,是否提交AV認證都沒有相應的介面,而是提供了一個對AV_INFO_NEW表的查詢操作介面,這樣使用者如果要檢查是否已經提交AV認證資訊,就必須這麼做:
public static boolean hasUserSubmitAV(Integer companyId) {
AvInfoNewDO avInfoNewDO = avInfoService.findAvInfoNewByCompanyId(companyId);
if (avInfoNewDO == null || AvInfoStatusHelper.isAvinfoTransient(avInfoNewDO.getStatus())) {
return false;
} else {
return true;
}
}
這就是內部業務邏輯的外洩,如果以後是否提交AV認證不是這麼一個判斷邏輯,就會導致大量的應用需要修改。另一方面,也是導致客戶端很多重複程式碼。
目前為了方便使用spring的宣告式事務處理,我們的service在配置上都是繼承自intl-biz-datasource二方庫中的transactionDefinition。
這本來是一件好事,但是帶來的一個問題是Spring是根據方面簽名進行AOP的,而父類定義的是CUD資料庫操作型別的接口才攔截,這也導致了我們的介面看起來就象是一個CRUD介面。AOP不應該成為我們的一個限制,如果預設的AOP模式不能滿足我們的需求,可以過載父類定義。這其實是很有必要的,在多service介面協作的過程中,可能需要不同的service介面有不用的事務傳播型別。
另外,也可以考慮使用Anotation進行事務標註。
3. 關於介面的引數——最小粒度原則
現在很多介面都是這樣動不動就是一個DO或者DTO物件作為引數,但是到了實現一看,發現其實就是用到DO/DTO的兩三個欄位而已。這種大物件作為引數實際上是一種非常不好的作法,特別是如果你只用到了大資料物件的一小部分欄位而已。舉個例子大家就比較容易理解我為什麼對它如此深惡痛絕了:在com.alibaba.intl.biz.product.service.interfaces.ProductService獲取產品獨立detail頁面URL的介面定義如下:
com.alibaba.intl.biz.product.service.interfaces.ProductService
String getProductDetailUrl(URIBroker uriBroker, ProductSearchDTO product);
而在com.alibaba.intl.biz.product.service.impl.ProductServiceImpl中其實現只是用到了ProductSearchDTO的三個欄位:getProductId(),getSubject(),getServiceType()。但是ProductSearchDTO這個類呢有十幾個屬性,並且還關聯了一些DO物件。你說這樣的一個介面給使用者,它怎麼知道應該填充這個DTO的那些欄位呢?!這對記憶體空間也是一種浪費。 直接定義成這樣的介面多簡單: com.alibaba.intl.biz.product.service.interfaces.ProductService
String getProductDetailUrl(URIBroker uriBroker, Integer productId, String subject, ServiceType serviceType);
其實傳遞URIBroker給下層也是一個不合理的做法,但這不在我們這一次討論範圍內
什麼情況下傳遞整個DO/DTO物件是合理有用的呢?筆者認為以下三種情況可以考慮:
- 引數太多(超過6個),可以考慮將這些引數封裝成一個DO/DTO物件,
- 務必確保DO/DTO中的所有屬性都被該介面使用到了。
- 作為內部實現,以pipeline處理方式填充DO/DTO物件。
4. 關於介面的返回值——如何處理多種返回情況
介面的處理結果可能有多個返回情況,特別是介面粒度越粗,返回情況就越多。如何將處理結果返回給客戶,是一個需要好好考慮的問題。比如釋出產品,如果將所有邏輯放在Service介面,那麼介面必須支援多個返回結果,因為頁面需要根據不同的結果進行不同的提示。比如:如果使用者未提交AV認證,引導其先填寫AV資訊。如果使用者釋出的產品數超過了限度,提示之。如果使用者類目失效,提示之。如果。。。
一般來說有以下兩種方式:
-
一種定義一個ResultCode,即介面不只是返回true或者false,而是返回具體錯誤資訊。頁面層根據呼叫結果程式碼做相應的處理。但這會導致介面變得複雜,不好理解。
-
另一種方式是不通過返回值,而是定義自己的業務異常,通過丟擲異常來告訴呼叫者結果。這會導致客戶端好多try catch,不過我們的宣告式事務控制也是要求Service介面丟擲異常的。
建議是兩者的結合。但是返回結果碼是需要事先訂下的,後來再加上相當於改變了介面簽名。
5. 關於介面的可測性
好的介面不僅僅是對使用者友好,還應該是對自己友好。其中很大方面表現在可測性上。簡單來說,POJO物件可測性最高,所以儘量提供POJO引數介面。
在我們現在很多web層的util類中,web層物件到處走(甚至是web層框架特有物件),這導致可移植性,可測性和可複用性大大變差。比如WebUser雖然放在了ThreadLocal中,但是在biz和dal中獲取一個webUser做相應的檢查顯然是有問題的,因為他的定義就是web層使用的。還有在web層的很多util和helper類中,大量傳遞webx特有物件——rundata和context,直接從rundata中過去引數,驗證或者處理完又直接將結果塞入context中。特別是後者,看起來是方便,但是可測性就差好多了。因為你必須啟動整個容器,才能測試。 這裡舉的例子主要都是針對web層的,其實biz層也是一樣的道理。
6. 總結
面向物件設計最大的原則就是針對介面設計。可見介面的重要性,如果介面能夠定義好,不僅便於自身維護,而且也導致上層應用不需要太多變動。想想Unix/Linux這麼大的核心,也就200多個系統呼叫,非常穩定,沒有太多變化,人家是面向過程的程式設計思想,能做到這樣,確實有值得我們思考和學習的地方。希望我們以後在定義新介面的時候能夠多思考一下。另外,我們的intl-biz-product實在太單薄了,考慮我們像會員線一樣遷移到新的二方庫中,新增的介面一律放在新二方庫中,並且新增介面務必保證單元測試的完整性和可冪性。目前構想新的二方庫結構應該是api和impl分開(為服務化作準備),dal和biz分開(經常看到很多forBOPS,forMoree的SQL...),biz層還要細分通用邏輯層(細粒度的,應用無關的介面)和業務門面層(粗粒度的,應用相關的),每一層可能都有相應的common/share包。當然。只是一個我個人的一些粗步的構想(跟海滔討論過),歡迎大家提供建議。文件地址如下:http://b2b-doc.alibaba-inc.com/pages/viewpage.action?pageId=45513924
關於UT的冪等性
所謂UT的冪等性,即只要被測函式沒有變化,跑N次這個UT應該都是一樣的結果。要保證這一點,需要做到如下幾點:
-
首先資料不能依賴於老資料,這意味著需要針對這個UT作相應的資料準備,可以使用DBUnit從資料庫中匯出資料,根據需要簡單編輯一下。
-
其次還要保證資料的獨立性,幾個人同時操作一個數據庫或者操作一個存在大量未知資料的資料庫,將不能保證資料操作的正確性。要保證資料的獨立性和隔離性,可以使用先清空,執行測試,最後再回滾的方式。另一種方式是每個人跑自己的測試資料庫(可以使用記憶體資料庫)。
-
第三個是減少外部函式的依賴。如果你的被測函式的結果依賴於另一個函式,那麼你很難保證被測函式的冪等性。解決這個問題的一個方式是使用Mock物件。
有了上面這三點保證,我們就可以保證這個UT在如下的輸入下必然有如下的輸出,這就是冪等性。