about 2 years ago

本文源起於前幾天,前同事在 Facebook Messenger 中問我:為什麼 BeanGoException (是的,沒錯,就是最近發表的 BeanGo!,過去三年的青春都在開發這個產品) 是繼承 RuntimeException 而不是 Exception?這是一種設計選擇,但這樣的選擇是好是壞就可以討論了。

在設計軟體架構時,常常被忽略的就是例外或錯誤的處理,原因不外乎在設計時並不會知道會拋出什麼例外或以什麼形式回報錯誤,特別是還未決定或熟悉第三方套件前,因此,當真正遇到第三方套件例外拋出時,常會受限於既有的介面規範 (method signature) 只好將例外攔截下來然後無視它,於是這個錯誤就沒有人注意到了,等到產品上線後被忽略的例外會以別的形式讓產品當掉。這情況在 Java 生態特別明顯:

至於採用 Java 語言作為例子的原因很簡單:「因為它是當代流行的商業語言裡面,例外處理機制最困難 (或是說最討厭) 的語言。」搞懂了 Java 的例外處理,再應用到其他物件導向語言,就好像喝開水一樣,變得非常容易。

《笑談軟體工程:例外處理設計的逆襲》序言

會說 Java 特別難搞,原因是當初設計者希望 Java 是安全的語言,因此將例外設計成 checked 和 unchecked 二種,一旦拋出 checked 類型的例外,編譯器就會強制要求宣告成為 method signature 的一部份,若呼叫會拋出 checked 例外的函式,就一定要使用 try-catch-finally 處理或轉拋出去,簡言之,Java 設計者強制要求例外一定要有「人」處理,但是由誰處理就讓開發者自己決定,下場往往就是像下面這種啞巴處理:

try {
    inputStream.read(buffer);
}
catch (IOException e) {
    // do nothing

}

這種有強迫症的語言大概只有 Java 和最近誕生的 Swift 了,C++ 和 C# 有拋出例外的機制,但不強迫開發者宣告或捕捉,是否處理外全憑開發者的良心 (是的,寫作良心唸作時間,有多少良心做多少事)。所以大部份語言有提供一些機制,簡化因例外拋出要處理的資源釋放機制,例如 C++ 的 RAII (Resource acquisition is initialization),簡單說,由物件的建構子取得資源,解構子釋放資源,物件的生命週期隨著例外消逝的話,C++ 就能確保資源能確實地被釋放。C# 有 using 的語法,Java 則在 Java 7 之後有 try-resources 的語法,避免開發者如上例那樣忘記在 finally 區塊釋放資源。

Objective C 雖然有@try@catch@finally 關鍵字與拋出 exception 的機制,但個人好像沒有用到哪個 API 是以例外的方式處理錯誤,這麼說不太精確,runtime 還是會拋出例外,但都是像 NullPointerException 這種類型的例外,本來就不是用捕捉的方式處理,實際上官方也是建議例外只用在 runtime 錯誤上

You should reserve the use of exceptions for programming or unexpected runtime errors such as out-of-bounds collection access, attempts to mutate immutable objects, sending an invalid message, and losing the connection to the window server. You usually take care of these sorts of errors with exceptions when an application is being created rather than at runtime.

《Introduction to Exception Programming Topics for Cocoa》

其他則以 NSError 物件的方式處理:

NSURLSession* session = [NSURLSession sessionWithConfiguration:defaultConfiguration];
NSURL* url = [NSURL URLWithString:@"https://www.example.com/"];
 
[[session dataTaskWithURL:url completionHandler:^(NSData* data, NSURLResponse* response, NSError* error) {
    if (error != nil) {
        // handle error
        
    }
}] resume];

為了與 Objective C 的 runtime 相容,Swift 錯誤處理機制大致與 Objective C 相同,不過,Swift 和 Java 一樣希望有更安全的語言,所以和 Java 一樣可以在 function 上加上 throws 的宣告,但不用宣告拋出的例外類型,因此也不用擔心介面演變 (interface evolution):

func canThrowErrors() throws -> String

而呼叫宣告會拋出例外的 function,編譯器會強制開發者要進行處理,不論是用 do-try-catch,還是用 try?try! 處理,以及用 defer 清除資源 (這裡就不說明這之間的差異,對細節有興趣可以參考官方文件),總之,不處理編譯就會錯誤,個人覺得動機很好,但這機制其實常常讓開發人員在寫程式的當下,因為沒有足夠的資訊可以處理,所以只好選擇忽略,就好像生命會自己找到出路一樣,例外也是 (逃離處理機制)。

傳遞錯誤的機制而非拋出例外中斷目前執行緒的機制,個人認為有一部份的原因是非同步 (asynchronous) 機制,要處理例外需要有足夠的 context (請參考《笑談軟體工程:例外處理設計的逆襲》第 9 章),會發生錯誤的程式執行在另一個執行緒,發生時並沒有任何有用的資訊可以處理,但又不能中斷目前的執行緒 (通常這個執行緒還需要執行 task queue 的其餘 task),只好將錯誤以 callback 的方式 (例如先前 Objective C 的例子) 傳遞給當初的請求者,讓該執行緒可以執行,不過 callback 也常會造成 callback 地獄就是了 (已經有語言開始著手讓這邊能更優雅地被處理)。

繞了一大圈,回到前同事的問題,其實一開始 BeanGoException 確實是繼承 Exception,是 checked exception,這個設計決定比要符合 Java 原本的風格,不過,checked exception 所造成的介面演變馬上就出現了,雖然個人也是比較偏好顯式的介面演變,但並不是所有的介面都是可以讓我們修改的,例如 Runnable 介面,正好也是在當初設計非同步機制時,希望有跨 Android 與 PC 平台的 AsyncTask,當時遇到 checked exception 讓處理變複雜,只好繞過:

public abstract class AsyncTask implements Runnable {

    // some callbacks and implementation ...


    // unable to add throws on run() method

    @Override
    public void run() {
        try {
            if (execute()) {
                callback.taskCompleted(this);
            }
            else {
                callback.taskFailed(this);
            }
        }
        catch (Throwable e) {
            this.cause = e;
            callback.taskFailed(this);
        }
    }

    // the actaul exception type could be varied

    protected abstract boolean execute() throws Throwable;
}

個人不覺得這種繞過是一個很好的設計,因為這更容易讓開發者忽略 cleanup actions,但權衡之後仍是用了這樣的設計,並且在考慮各種情況下的處理機制後,參考 Spring framework (大多數的 exception 都是 unchecked),在一些配套設計下 (是的,變更設計一般都需要有配套),將 BeanGoException 改繼承 RuntimeException。不過,不代表每種專案我都會是這樣的選擇,一般來說還是會根據專案的類型和需求搭配軟體架構一起考慮和做選擇,但大多有一些原則:

  • 決定例外處理的最基本的策略
  • 一律轉成 domain model exception
  • 決定 exception 的非同步處理機制

決定例外處理的最基本的策略

《笑談軟體工程:例外處理設計的逆襲》一書提到了三種強健度等級:(等級 1) 錯誤回報、(等級 2) 狀態恢復和 (等級 3) 行為恢復,每往上一個等級,要付出的成本就高出許多,所以,一般不會全部的程式都要求做到行為恢復等級,但如果在思考軟體架構時,完全不要求,大多數的情況會變成什麼都沒有處理。因此,一般來說,我會要求最起碼要到錯誤回報,不論是透過使用者介面回報或是 log 記錄下來,若是設計 Web API,exception 的回報還包含思考怎麼搭配 HTTP Status Code,什麼情況下該用 4xx 的錯誤碼,什麼情況下該用 5xx 的錯誤碼,那些例外 server 應該處理,處理到哪個層級,那些例外該由 client 處理,處理到甚麼層級等等。

一般會再根據模組的類型,決定模組的處理策略,例如處理資料庫存取的 Repository 就會規範至少要到狀態恢復等級,也就是說除了釋放資源外,還會將資料庫還原到先前的狀態,雖然說這好像是處理資料庫時的慣用方法,但若是在設計模組時就處理好,狀態恢復的程式就不會四散在使用模組的上層程式中。一般來說,個人是用設計來處理例外,而不是在個別的函式呼叫處理,像是 processor-chain 搭配重試機制等等,因此,會提前思考處理的策略。

一律轉成 domain model exception

為了避免因 exception 引起的介面演變,通常會將專案的 root exception 設計成 XException,X 是專案名稱 (若有公司內部的函式庫,那 X 可能會遵循內部函式庫管理的規則),各個模組內可能拋出的例外都會被攔截下來並處理,若無法處理要往上層拋時,一定會轉成繼承 XException 的例外,例如 將 SQLException 轉成 RepositoryException,若之後模組內部的實作更換了,也可以用同樣的方式轉成相容的例外,像是將 MongoException (也是 unchecked exception) 轉成 RepositoryException,又例如 Android 的 SQLiteDatabase 並不會拋出例外,而是回傳錯誤碼,所以也可以轉成 RepositoryException,如此,模組的介面就不會因此被破壞或被迫改變。

轉成 domain model exception 另一層意義是提供更明確的語意,讓呼叫者清楚知道該怎麼處理,至於 XException 是 checked 或 unchecked 還要考量其他因素來決定。但轉成 unchecked exception 倒是有一個好處,可以告訴團隊成員:若不知道怎麼處理例外,最起碼用 XException 包起來再轉拋出去,如此一來,可以避免啞巴把當下的例外吃掉,若例外真的發生還可以靠其他設計捕捉轉拋出來的例外。

決定 exception 的非同步處理機制

若是 Objective C,基本上處理機制是固定的,不過還是盡量會根據專案有統一的處理機制,若原生的 API 不相容,會進行轉換,讓處理方式盡可能一樣。若是 Java 語言就比較討厭一點,偏偏 Android 又不允許在 UI thread 呼叫網路,所以蠻多情況下都是用 Android 原生的 AsyncTask 中呼叫網路,然後直接在裡面處理因網路問題而拋出來的例外,這沒什麼問題,只是程式挺醜的又不好讀,因此有蠻多套件將這樣的機制都轉成 callback 的形式,若搭配 Java 8 的 method reference Lambda,就可以有較好讀的程式。

基本上就是這樣,例外處理是可以在軟體架構設計時就考慮進去,或者說,在軟體架構設計時就該考慮進去,制定方針讓團隊有一個原則可以遵循,透過設計讓例外的處理較容易與一致,最終讓軟體的品質可以更好。最後,想了解更多關於怎麼處理例外,推薦可以看《笑談軟體工程:例外處理設計的逆襲》(直接去書店買,找我沒打折 XD),文字很幽默但又有很多更深入的內容。

最後,幫前東家的 BeanGo! 打廣告,大家快去下載吧:

系列索引
上一篇:《閒談軟體架構:意志力不是萬能藥
下一篇:《閒談軟體架構:Plug-in

← 閒談軟體架構:意志力不是萬能藥 閒談軟體架構:Plug-in →