天天看點

談談Java接口Result設計

作者|書牧

談談Java接口Result設計

這篇文章醞釀了很久,一直想寫,卻一直覺得似乎要講的東西有點雜,又不是很容易講清楚,又怕争議的地方很多,就一拖再拖。但是,每次看到不少遇到跟這個設計相關導緻的問題,又忍不住跟人讨論,但又很難一次說清楚,于是總後悔沒有及早把自己的觀點寫成文章。不管怎樣,觀點還是要表達的,無論對錯。

故障的推手——“Result"

先說結論:接口方法,尤其是對外HSF(開源版本即dubbo) api,接口異常建議不要使用Result,而應該使用異常。阿裡内部的java編碼,已經習慣性對外API一股腦兒使用“Result”設計——這是導緻許多故障的重要原因!

▐  一個簡化的例子

// 使用者查詢的HSF服務API,使用了Result做為傳回結果public interface UserService {    Result<User> getUserById(Long userId);}      
// 一段用戶端應用facade的調用示例。讀寫緩存邏輯部分省略,僅做示意public User testGetUser(Long userId) {    String userKey = "userId-" + userId;    // 先查緩存,如果命中則傳回緩存中的user    // cacheManager.get(123, userKey);    // ...
    try{        Result<User> result = userService.getUserById(userId);        if (result.isSuccess()) {            cacheManager.put(123, userKey, result.getData());            return result.getData();        }        // 否則緩存空對象,代表使用者不存在        cacheManager.put(123, userKey, NullCacheObject.getInstance(), 3600);        return null;  } catch (Exception e) {        // TODO log        throw new DemoException("getUserById error. userId=" + userId, e);    }}      

上面的代碼很簡單,用戶端應用對User查詢服務做了個緩存。有些同學可能一眼就看出來,這裡隐藏的bug:第10行的“result.isSuccess()”為false的實際含義是什麼?是服務端系統異常嗎?還是使用者不存在?光看API是很難确定的。不得不去找服務提供方或文檔确認其邏輯,根據錯誤碼進行區分。如果是服務端系統異常,那麼第15行将導緻線上bug,因為後續1小時對該使用者的請求都認為使用者不存在了。

▐  嚴謹點的寫法

如果要寫正确邏輯,那麼代碼可能會變成這樣:

public User testGetUser(Long userId) {    String userKey = "userId-" + userId;    // 先查緩存,如果命中則傳回緩存中的user    // cacheManager.get(123, userKey);    // ...
    try{        Result<User> result = userService.getUserById(userId);        if (result.isSuccess()) {            cacheManager.put(123, userKey, result.getData());            return result.getData();        }        if ("USER_NOT_FOUND".equals(result.getCode())) {            // 否則緩存空對象,代表使用者不存在            cacheManager.put(123, userKey, NullCacheObject.getInstance(), 3600);        } else {            // 可能是SYSTEM_ERROR、DB_ERROR等一些系統性的異常,TODO log            throw new DemoException("getUserById error. userId=" + userId + ", result=" + result);        }    } catch (DemoException e) {        throw e;  } catch (Exception e) {        // TODO log        throw new DemoException("getUserById error. userId=" + userId, e);    }    return null;}      

很顯然,代碼變得複雜起來了,加上對外部調用的try catch異常處理,實際代碼變相當複雜繁瑣。

▐  不使用Result的例子

public interface UserService {    User getUserById(Long userId) throws DemoAppException;}      
public User testGetUser(Long userId) {    String userKey = "userId-" + userId;    // 先查緩存,如果命中則傳回緩存中的user    // cacheManager.get(123, userKey);    // ...
    try {        User user = userService.getUserById(userId);        if (user != null) {            cacheManager.put(123, userKey, user);            return user;        } else {            // 否則緩存空對象,代表使用者不存在            cacheManager.put(123, userKey, NullCacheObject.getInstance(), 3600);            return null;        }    } catch (Exception e) {        // TODO log        throw new DemoException("getUserById error. userId=" + userId, e);    }}      

這樣一看,代碼簡潔清晰很多,也更符合對普通API的調用習慣。

▐  使用Result的幾個問題

  1. 調用成本高:雖然通過對依賴的API深入了解異常設計,可以寫出嚴謹的代碼以避免出現bug,但是簡單的邏輯,代碼卻變得複雜。換言之,調用的成本變高。但是很可惜,我們忘記判斷而寫成“一個簡化的例子”這樣是往往常事。
  2. 無意義錯誤碼:SYSTEM_ERROR、DB_ERROR等系統異常的錯誤碼,雖然放在Result中了,但是調用方除了日志和監控作用外,業務邏輯永遠不會關心,也永遠處理不了。而些錯誤碼的處理分支,實際與抛異常的處理邏輯一樣。既然如此,為何要将這些錯誤碼放在傳回值裡?

關于阿裡巴巴開發規約

我們看《阿裡巴巴Java開發手冊》的“異常處理”小節第13條:

【推薦】對于公司外的http/api開放接口必須使用“錯誤碼”;跨應用間HSF調用優先考慮使用Result方式,封裝isSuccess()方法、“錯誤碼”、“錯誤簡短資訊”;而應用内部推薦異常抛出。

這條推薦非常具有誤導性,在2016年孤盡對于這條規範進行調研時的文章:《【開發規約熱議投票02】HSF服務接口定義時,是Result+isSuccess方式傳回,還是是抛異常的方式?》有部分同學不建議使用Result,但大部分同學推薦了Result的做法。

▐  為什麼說這條規約具有誤導性?

因為這個問題本身沒有講清楚“對什麼東西的處理”要用Result還是異常的方式,即這裡沒有講清楚我們要解決的問題是什麼。事實上我們常說的“失敗”,往往混淆了2種含義:

  1. 系統異常:比如網絡逾時、DB異常、緩存逾時等,調用方一般不太可能基于這些錯誤類型做不同的業務邏輯,常用用于日志和監控,友善定位排查。
  2. 業務狀态:比如業務規則攔截導緻的失敗,比如發權益時庫存不足、使用者限領等,為友善後文叙述和了解,暫時稱為“業務失敗”。這類“失敗”,從機器層面來看,嚴格來說不能算做是失敗,這隻是一種正常的業務結果,這和“調用成功”這個業務結果對系統來說沒有任何差別,隻是一個業務狀态而已。調用方往往可能關心對應的錯誤碼,以完成不同的業務邏輯。

有經驗的開發,都會意識到這2種含義的差別,這對于幫助我們了解接口的異常設計非常重要!對這條開發規約而言,如果是第2種,并沒有什麼大的問題,但如果是第1種,我則持相反的意見,因為這違背了java語言的基本設計,不符合java編碼直覺,會潛移默化造成前面案例所示的了解和使用成本的問題。

▐  為什麼針對HSF?

當我們讨論要用Result代替Exception時,經常會以這是HSF接口為由,因為性能開銷等等。我們常說HSF這種RPC架構,設計的目的就是為了看起來像本地調用。那麼,這個“看起來像本地調用”到底指的是哪方面像呢?顯然,編碼時像,運作時不像。是以我們寫調用HSF接口的代碼時,感覺像在調用本地方法,那麼我們的編碼直覺和習慣也都應該是符合java的規範的。是以,至少有幾點理由,對于系統異常,我們的HSF接口更應該使用Exception,而非Result的方式:

  1. 隻有同樣遵循本地方法調用的設計,來設計HSF的api,才能更好做到“像本地調用一樣”,更符合HSF設計的初衷。
  2. HSF接口是往往用于對外部團隊提供服務,更應該遵循java文法的設計,提供清晰的接口語義,降低調用方的使用成本,減少出bug的機率。
  3. Result并無統一規範,而Exception則是語言标準,有利于中間件、架構代碼的監控發現和異常重試等邏輯生效。

當然,由于“運作時不像”,對于HSF封裝帶來的抽象洩露,我們在使用異常時,需要關注幾點問題:

  1. 異常要在接口顯式聲明,否則用戶端可能會反序列化失敗。
  2. 盡可能不帶原始堆棧,否則用戶端也可能反序列化失敗,或者堆棧過大導緻性能問題。可以考慮異常中定義錯誤碼以友善定位問題。

▐  結論

無論是HSF接口,還是内部的API,都應該遵循java語言的編碼直覺和習慣,業務結果(無論成功還是失敗)都應該通過傳回值傳回,而系統異常,則應該使用抛出Exception的方式來實作。

關于Checked Exception

講到這裡,我們發現,java的Checked Exception的設計,作用上和反映業務失敗的Result很像。Result是強制調用方進行判斷和識别,并根據不同的錯誤碼進行判斷和處理。而Checked Exception也是強制調用方進行處理,并且可能要對不同的異常做不同的處理。但是,基于前面的結論,業務失敗應該通過傳回值來表達,而不是異常;而異常是不應該用于做業務邏輯判斷的,那麼java的Checked Exception就變成奇怪的存在了。這裡我明确我的觀點,我們應該盡可能不使用Checked Exception。另外,《Thinking in Java》的作者 Bruce Eckel就曾經公開表示,Java語言中的Checked Exception是一個錯誤的決定,Java應該移除它。C#之父Anders Hejlsberg也認同這個觀點,是以C#中是沒有Checked Exception的。

Reselt 的實質是什麼?

我們看看一個java方法的簽名(省略修飾符部分):

  1. 方法名:用于表達這個方法的功能
  2. 參數:方法的輸入
  3. 傳回值類型:方法的輸出
  4. 異常:方法中意外出現的錯誤

是以,傳回值和方法功能必須是配套的,傳回值類型,就是這個方法的功能執行結果的準确表達,即傳回值必須正好就是目前這個方法要做的事情的結果,必須滿足這個方法語義,而不應該有超出這個語義外的東西存在。而異常,所說的“意外”,則是指超出這個方法語義之外的部分。這幾句話有點拗口,舉個例子來說,上面這個使用者接口,語義就是要通過使用者id查詢使用者,那麼當服務端發生DB逾時錯誤時,對于“通過使用者id查詢使用者”這個語義來說,“DB逾時錯誤”沒有任何意義,使用異常是恰好合适的,如果我們把這個錯誤做為錯誤碼放在傳回值的Result裡,那麼就是增加了這個方法的使用成本。

▐  Result的由來

到底為什麼會有“Result”這樣的東西誕生呢?如果設計的方法傳回值是Result類型,那麼它必須能準确反應這個方法調用的結果。實際上,以上面的例子為例,這個時候的Result就是User類本身,User.status相當于Result.code。這聽起來可能有點和直覺不符,這是為什麼?

public class UserRegisterResult {    private String errorCode;    private String errorMsg;    private Long userId;    // ...
    public boolean isSuccess() {        return errorCode == null;    }    // ...}      
UserRegisterResult registerUser(User user) throws DemoAppException;      

我們再來看看上面這個“注冊使用者”的方法聲明,會發現,這個方法定義一個Result顯得很合适。這是因為前一個例子,我們的方法是一個查詢方法,傳回值剛好可以用領域對象類型本身,而這個“注冊使用者”的方法,顯然沒有現成合适的類型可以使用,是以就需要定義一個新的類型來表達方法的執行結果。看到這裡,我們會以為,對于“寫”與“讀”類型的方法有所差異,但實際上,對于java語言或者機器來說,并無二緻,第二個方法UserRegisterResult的和第一個方法的User是同等地位。是以,最重要的還是一點:需要有一個合适的類型,做為傳回值,用于準确表達方法執行的功能結果。而偏“寫”類型,或者帶業務校驗的讀接口,往往因為沒有現成的類型可用,為了友善,常常會使用Result來代替。

▐  是否有必要統一Result?

講到這裡,想想,當我們這種“需要Result”的方法有多個時,我們會說“我需要一個統一的Result類”時,實際上說的什麼呢?

  1. 我希望各種接口方法都統一同樣的Result,友善使用
  2. 我希望有個類複用errorCode、errorMsg以及相關的getter/setter等代碼

顯然,第1點理由經不起推敲,為何“統一就友善使用”了?如果各種方法傳回類型都一樣,那就違背了“傳回值要和方法功能配套”的結論,也不符合高内聚的設計原則。恰相反,傳回值越是設計得專用,對調用方來說了解和使用成本越低。是以,我們實際想要的,僅僅是如何“偷懶”,也就是第2點理由。是以我們真正要做的是,隻是在目前領域範圍内,如何既滿足讓每個方法傳回值專用以便使用,同時又可以偷懶複用部分代碼即可。是以,絕不必要求大家都統一使用同一個Result類型。

接口傳回設計建議

根據前文的結論,我們知道,對于接口方法的傳回值和異常處理,最重要的是需要遵循方法的語義進行設計。以下是我梳理的一些設計上的原則和建議。

▐  對響應合理分類

接口響應按有業務結果和未知業務結果分類,業務結果不管是業務成功還是業務規則導緻的失敗,都通過傳回值傳回;未知結果一般是系統性的異常導緻,不要通過傳回值錯誤碼表達,而是通過抛出異常來表達。這裡最關鍵一點,就是如何了解和區分某個“失敗”是屬于業務失敗,還是屬于系統異常。由于有時候這個區分并不是很容易,我們可以有一個比較簡單的判斷标準來确定:

  1. 如果一個錯誤,調用方隻能通過人工介入的方式才能恢複,比如修改代碼、改配置,或資料訂正等處理,則必然屬于異常
  2. 如果調用方無法使用代碼邏輯處理消化使得自動恢複,而是隻能通過重試的方式,依賴下遊的恢複才能恢複,則屬于異常

▐  找到合适的場景

普通查詢接口,如無必要,不要使用Result包裝傳回值。可以簡單分為3類做為參考:

  • 普通讀接口

查詢結果即是領域對象,無其他業務規則導緻的失敗:建議直接用領域對象類型做為傳回值。如:

User getUserById(Long userId) throws DemoAppException;      
  • 寫接口

或者帶業務規則的讀接口:

  1. 理想情況是專門封裝一個傳回值類,以降低調用方的使用成本。
  2. 可考慮将傳回值類繼承Result,以複用errorCode和errorMsg等代碼,減輕開發工作量。但注意這不是必要的。
  3. 将本方法的錯誤碼,直接定義到這個傳回值類上(高内聚原則)。
  4. 若有多個方法有共同的錯誤碼,可以考慮通過将這部分錯誤碼定義到一個Interface中,然後實作該接口。
// UserRegisterResult、UserUpdateResult可以繼承Result類,減少工作量,但調用方不需要感覺Result類的存在UserRegisterResult registerUser(User user) throws DemoAppException;
UserUpdateResult updateUser(User user) throws DemoAppException;      
  • 帶業務規則的的領域對象讀接口

完全遵循上面第2點,會給方法提供者帶來一定的開發成本,權衡情況下可以考慮,套Result包裝領域對象做為傳回值。注意,對外不建議,可考慮用于内部方法。如下接口,“沒有權限”是一個正常的業務失敗,調用方可能會判斷并做一定的業務邏輯處理:

// 查詢有效使用者,如果使用者存在但狀态非有效狀态則傳回“使用者狀态錯誤”的錯誤碼,如果不存在則傳回nullResult<User> getEffectiveUserWithStatusCheck(Long userId) throws DemoAppException;      

▐  内外部區分

對外接口,尤其是HSF,由于變更成本高,更要遵循前面的原則;内部方法,方法衆多,如果完全遵循需要編碼成本,這裡需要做權衡,根據代碼規模和發展階段不斷重構和調整即可。

▐  避免直接包裝原生類型

我們對外的接口,傳回值要避免出現直接使用Result包裝一個原生類型。比如:

Result<Long> registerUser(User user) throws DemoAppException;      

這樣設計導緻的結果是,擴充性很差。如果registerUser方法需要增加傳回除了userId以外的其他字段時,就面臨幾個選擇:

  1. 讓Result支援擴充參數,通過map來傳遞額外字段:可讀性和使用成本很高
  2. 開發一個新的registerUser方法:顯然,成本很高

▐  避免所有錯誤碼定義在一個類中

有人建議,做一個全局的錯誤碼定義,以做統一,友善排查和定位。但這樣做真的友善嗎?這樣做實際上有幾個問題:

  1. 完全違背了高内聚、低耦合的設計原則。這個“統一的定義”将與各個域都有耦合,同時對于某單個接口而言,則不夠内聚。
  2. 這個統一定義的錯誤碼,一定會爆炸式增長,即便我們對其進行分類(非常依賴人的經驗),遲早也會變得難以維護和了解。
  3. 不要将系統異常類的錯誤碼和業務失敗錯誤碼放在一起,這點其實和方法響應分類設計是一回事。

我們在設計拉菲2權益平台的錯誤碼時,就犯了這樣的錯誤。現在這個“統一的”錯誤碼已經超過400個,揉合了管理域、投發放域、離線域等各種不同域的業務失敗、系統異常的錯誤碼,不要說調用方,即便我們自己,也梳理不清楚了。而實際上,每個域、每個方法自己的業務失敗是非常有限的,它的增長一定是随着業務需求本身的變化而增長的。現在如果有個業務方來問我,拉菲2的發放接口,有哪些錯誤碼(這問的實際是業務失敗,他也隻關心業務失敗),我幾乎難以回答。很可惜,這塊目前即便重構,難度也很大。

▐  異常處理機制

  • 異常錯誤碼

前面我們講到,即便是抛異常的形式,我們也可以為我們的異常類設計錯誤碼,異常錯誤碼的增加會很快,往往也和目前業務語義無關,是以千萬不要和業務失敗的錯誤碼定義在一起。異常内的錯誤碼主要用于日志、監控等,核心原則就是,要友善定位問題。

  • 避免層層try catch

到處充滿異常處理的代碼,會導緻整個程式可讀性變差,寫起來也非常繁瑣,可以遵循一定的原則:

  1. 在原始發生錯誤的地方try catch,比如調用HSF接口的Facade層代碼,主要目的是為了記錄原始的錯誤以及出入參,友善定位問題,一般會打日志,并轉換成本應用的異常類上抛
  2. 在應用的最頂層catch異常,列印統一日志,并根據“為什麼針對HSF?”小節中的建議,處理成合适的異常後再抛出。對于HSF接口,可以直接實作HSF的“ServerFilter”來統一在架構層面處理。
  3. 中間層的代碼,不必再層層catch,比如domain層,可以讓代碼邏輯更加清晰。
  • 參數錯誤

抛異常的場景,除了前面說的系統性異常外,參數錯誤也推薦使用異常。原因如下:

  1. 參數正确一般是我們目前上下文執行的前提條件,我們一般可以使用assert來保證其正确。即我們的後續邏輯是認為,目前的參數是不可能錯誤的,我們沒必要為此寫過多繁瑣的防禦性代碼。
  2. 一旦發生參數錯誤,則一定是調用方有代碼bug,或者配置bug,應該通過抛出異常的方式,充分提前在開發或測試階段暴露。
  3. 參數錯誤對調用方來說,是無法處理的,程式不可能自動恢複,一定是會需要人工介入才可能恢複,調用方不可能會“判斷如果是xx參數錯誤,我就做某個業務邏輯”這樣的代碼,是以通過傳回值定義參數錯誤碼沒有意義。
  • 系統異常和業務結果轉換

系統性異常并非一定是異常,因為有些層可能有能力處理某些異常,比如對于弱依賴的接口,異常是可以吞掉,轉換成一個業務結果;相反,有些接口傳回的一些業務失敗,但調用方認為該業務失敗不可能出現,出現也無法處理,那麼這一層可以将其轉換成異常。

結尾

前面講了接口的響應,包括傳回值Result和異常抛出的設計,有很多結論是與現在公司内部大家常見做法是不同的,這也是我為什麼特别想要表達的,有可能正是日常我們的這些習以為常做法,才導緻了團隊間接口依賴調用的成本提高,也是導緻故障的一個很重要原因。當然,我相信,我的觀點也不一定都是對的,很多同學并不一定同意上面所有的結論,是以,歡迎大家在文章下面讨論!