談談 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 包裝領域對象做爲返回值。注意,對外不建議,可考慮用於內部方法。如下接口,“沒有權限” 是一個正常的業務失敗,調用方可能會判斷並做一定的業務邏輯處理:

// 查詢有效用戶,如果用戶存在但狀態非有效狀態則返回“用戶狀態錯誤”的錯誤碼,如果不存在則返回null
Result<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 的發放接口,有哪些錯誤碼(這問的實際是業務失敗,他也只關心業務失敗),我幾乎難以回答。很可惜,這塊目前即便重構,難度也很大。

異常處理機制

前面我們講到,即便是拋異常的形式,我們也可以爲我們的異常類設計錯誤碼,異常錯誤碼的增加會很快,往往也和當前業務語義無關,因此千萬不要和業務失敗的錯誤碼定義在一起。異常內的錯誤碼主要用於日誌、監控等,核心原則就是,要方便定位問題。

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

  1. 在原始發生錯誤的地方 try catch,比如調用 HSF 接口的 Facade 層代碼,主要目的是爲了記錄原始的錯誤以及出入參,方便定位問題,一般會打日誌,並轉換成本應用的異常類上拋

  2. 在應用的最頂層 catch 異常,打印統一日誌,並根據 “爲什麼針對 HSF?” 小節中的建議,處理成合適的異常後再拋出。對於 HSF 接口,可以直接實現 HSF 的 “ServerFilter” 來統一在框架層面處理。

  3. 中間層的代碼,不必再層層 catch,比如 domain 層,可以讓代碼邏輯更加清晰。

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

  1. 參數正確一般是我們當前上下文執行的前提條件,我們一般可以使用 assert 來保證其正確。即我們的後續邏輯是認爲,當前的參數是不可能錯誤的,我們沒必要爲此寫過多繁瑣的防禦性代碼。

  2. 一旦發生參數錯誤,則一定是調用方有代碼 bug,或者配置 bug,應該通過拋出異常的方式,充分提前在開發或測試階段暴露。

  3. 參數錯誤對調用方來說,是無法處理的,程序不可能自動恢復,一定是會需要人工介入纔可能恢復,調用方不可能會 “判斷如果是 xx 參數錯誤,我就做某個業務邏輯” 這樣的代碼,因此通過返回值定義參數錯誤碼沒有意義。

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

結尾

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

 

作者 | 書牧

編輯 | 橙子君

出品 | 阿里巴巴新零售淘系技術

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0yLGG_Ude4f3XhS4zy_QvQ