世事難預料,寫程式總會遇到例外。例外該怎麼處理,邏輯該怎麼驗測,本篇將進行討論。
圖片擷取自網路
「例外處理有什麼難的。不過就是 try-catch 嗎?」
嗯,其實對也不對。例外處理基本起手式的確就是 try-catch,但是同樣是 try-catch,處理得好不好,結果差很多。根據筆者過往經驗,就算例外處理做得再差,還是能把功能做出來是沒錯,但是後續維護上就會遇到很多麻煩。包括可讀性、易修改性等。Kent Beck 曾在「Implementation Patterns」一書中提到,現代的軟體開發,花在維護與修改上的成本,比起開發本身,還要高出許多。因此,做出難以維護的功能,不是長久下來會吃虧,而是很快就會吃虧。
說到例外處理,我們就先來簡單看一下例外本人。
在程式中,會遇到的例外有兩種情形:
簡單來說,可修復的例外稱為 Checked Exception,可以試試 retry,採取替代方案,或是 log 下來。不可修復的意外稱為 Unchecked Exception,代表程式有 bug,這時程式本身已經幫不上什麼忙,應該要讓 RD 來排查,把 bug 解掉。
然而,光靠例外的 1) 類型與 2) 可恢復性,也還不足以決定怎麼處理,程式設計者應該要再追加考慮三個條件:
以上五個因素綜合考慮,便可以幫助程式開發者在遇到例外時,依照一定的原則,做出比較妥善的處理,如此一來一旦上線真的遇到問題,就不會這麼難以排查了。
當然,本文沒有要詳細介紹例外的意思,主要是因為這其實是門專門的學問,而且本系列文章也不是以例外為中心。以上內容節錄自部落格「搞笑談軟工」作者 Teddy Chen 的著作:「笑談軟體工程:例外處理設計的逆襲」。建議讀者可以參考一下,會很有幫助的。書買不到沒關係,訂閱部落格也有相同功效。
我們準備要跳回測試了。在本文中,我們假設各位抓到一個例外,並且依據上述的五大因素,準備要採取行動了。以下介紹兩種較常見的處理方式,以及對應的測試方法。
任何時刻底層出錯,你覺得在這一層「應該處理,也有能力處理」時,就可以將特定 Exception 攔下來,並在 catch block 裡面做對應的處理。譬如,在網站後端的 Controller 層,當收到了 Service 層拋出來的例外,他應該要攔下來處理,因為他如果不處理,這個 Query 就會整個出錯。以 Java 的 Spring Boot 來說,這會使得前端網頁收到一個很詭異的「500 - Internal Server Error」訊息,如下:
圖片截自網路
當看到這個畫面,用戶只知道「有地方出錯了」,但其他訊息一無所知(或是看不懂)。這會大大影響用戶的感受,因此這件事不能發生。
而且,假設我們的處理,是要回傳前端認識的錯誤代碼,那麼 Controller 層也有能力處理,因為他可以從來源的 Query、自己身上的資料、及 Exception 裡面傳遞的訊息,自行組成前後端講好的資料格式並回傳。此回傳可能是代表錯在前端的 400,也可能還是代表錯在後端的 500,但無論如何,它都應該還要在回傳數據裡塞入足夠讓前端分辨原委的資訊,才不會搞得用戶一頭霧水,不知道哪裡出問題。
舉例來說,假設我們要做一個「學生註冊」的網頁與 API,Repository 處理到一半發現學校根本沒有這個學生的資料,這時應該怎麼辦?這時我們應該要把前後端開發者叫來,兩邊講好一個溝通方式,讓相同情況發生時,前端有足夠訊息顯示正確畫面給用戶看。譬如:
@PostMapping("/register")
public ResponseEntity<ApiResponse> createTeam(@RequestBody RegisterRequest request) {
try {
service.execute(request);
// ApiResponse 是前後端共同講好的回傳資料格式
// 這裡與前端說好,如果成功就直接回 200 就好,內容可以「空白」
return ResponseEntity.ok(ApiResponse.empty());
} catch (StudentNotExistException e) {
// 我們不認為這是系統出錯,所以寫 info 就好,以免過多 error 在 log 裡干擾閱讀
log.info("Student not found. " + e.getMessage());
// 用 Http 400 告訴前端這是用戶有問題,
// 並在 body 裡帶一個特殊代碼「987」,代表「用戶不存在」
return ResponseEntity.status(400).body(ApiResponse.bad(987));
}
}
程式雖短,但我還是在程式碼中加了一些註解,以幫助不熟悉 Java 與 Spring Boot 的讀者更容易了解意圖。
這裡對於底層吐出來的例外,經判斷決定要「處理」,並且處理的方法為,先記下 log,再回傳 Http 400 給前端,並透過溝通好的格式,告知詳細原因。因為邏輯分支有兩條,於是,單元測試就得包含兩種情況:
事不宜遲,我們就趕緊藉由測項來檢查程式有沒有把這兩個邏輯分支都寫對吧:
@SpringBootTest
@AutoConfigureMockMvc
class RegisterControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private MockMvc mockMvc;
@MockBean
private RegisterService service;
@Test
void all_ok() throws Exception {
MockHttpServletRequestBuilder postRequest = MockMvcRequestBuilders
.post("/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new RegisterRequest(35L)));
mockMvc.perform(postRequest)
.andExpect(status().is(HttpStatus.OK.value()));
}
@Test
void student_not_found() throws Exception {
Mockito.doThrow(new StudentNotExistException("ANY_MESSAGE"))
.when(service)
.execute(any(RegisterRequest.class));
MockHttpServletRequestBuilder postRequest = MockMvcRequestBuilders
.post("/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(new RegisterRequest(35L)));
mockMvc.perform(postRequest)
.andExpect(status().is(HttpStatus.BAD_REQUEST.value()))
.andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.bad(987))));
}
}
至於先前提到的「重構以隱藏細節」,讀者可以自行試看看,如果懶得試,也可以下載 GitHub Repository 裡的檔案來參考看看。連結在下方 Reference 處。
有些讀者或許已經發現,「寫 log」這個邏輯沒被測到。這不是筆者的疏漏,我故意不測的。有寫過單元測試的讀者也許有這個經驗:改了程式裡一點點無關緊要的細節,卻壞了很多測試。這種情形很普遍,坊間稱之為「過度測試」。其實測試跟程式一樣,寫的粗跟細都各有優缺點,程式的每個細節行為都測得鉅細彌遺固然安全,但也使得付出的成本提高。
其實很多技術細節都是取捨。壞掉或行為不一致會造成巨大損失的東西,那就一定要測,像在這裡寫的這一行 log,如果把內容稍做修改,應該也不會造成什麼損失,那我們就選擇不測,讓測試裡面只保留最重要的邏輯,同時閱讀時也可以避免干擾,一舉兩得。
例外處理的另一種方式,就是「攔下來轉拋」。譬如說,底層也許會拋個 IOException,而你判斷這一層並沒有能力處理,或是在架構上由上一層處理比較適合,這時你就可以攔下來以後,在 catch block 裡面,轉包一個跟上層講好的 Exception 出去,至於上層要怎麼處理,你就不用管了。
一樣是舉剛剛「學生註冊的例子」,假設在第二層的 Service,收到來自 Repository 層的 Exception,並且從各個方訊息判斷出這個情況應該是「學生不存在」,這時我們就要來看看他是否應該處理。
由於「學生不存在」這件事應該要回報給前端顯示,所以訊息的發送應該要讓 Controller 進行。事實上,也只有 Controller 應該要知道前端想收什麼 Error Code。因此,這時就適合「攔下來轉拋」這個做法。程式大概會長這個樣子:
public void execute(RegisterRequest request) throws StudentNotExistException {
try {
repository.register(request);
} catch (DataNotFoundException e) {
throw new StudentNotExistException("Student not exists", e);
}
}
這裡因為會轉拋一個例外,所以測試就不能讓他順利跑完,而是要讓在測項裡把例外抓下來,並檢查這個例外長得是否符合預期:
class RegisterServiceTest {
private final StudentRepository repository = Mockito.mock(StudentRepository.class);
@Test
void when_student_not_exists() throws DataNotFoundException {
given_student_NOT_exists(35L);
try {
create_register_service().execute(request(35L));
fail("should throw exception");
} catch (StudentNotExistException e) {
assertThat(e).hasMessageContaining("not exists");
}
}
// ... 以下細節省略
}
這裡讀者可以發現兩件事:
首先,我雖然有驗 Exception 的 message 內容,但是我沒有規定他整句話的樣貌。這跟前面討論 log 時很像,我不太想要在這裡驗太細,只要 message 裡有提到「not exists」,我認為就足夠了,其他細節後面的人可以隨意調整,我不認為有太大影響。
第二,我這裡用了一個初登場的語法:fail。這是因為我希望製造出一個「轉拋 Exception」的場景,所以如果在這場景下,程式竟然能夠順利跑完,我就認為這其中肯定哪裡有誤會,於是就強制把整個測試都 fail 掉。
其實坊間主流的測試框架有提供蠻多方法可以驗例外的,譬如,寫成這樣,各位覺得如何?
@Test
void when_student_not_exists_alternative() throws DataNotFoundException {
given_student_NOT_exists(35L);
StudentNotExistException actualException = Assertions.assertThrows(
StudentNotExistException.class,
() -> create_register_service().execute(request(35L))
);
assertThat(actualException).hasMessageContaining("not exists");
}
其實這裡只是提供一兩種做法給各位參考而已。測試方法會隨者各位使用的框架與套件而有所不同,各位讀者或是你們團隊可以選擇自己喜歡的工具與方法來使用就好。
這裡留個思考題:「大家覺得這兩種寫法有什麼不同,哪種你比較喜歡,為什麼?」大家可以拿自己手上的專案來套一下,感受一下,也歡迎文章下方留言分享 :)
「迪米特法則」,又稱為最少知識原則(Least Knowledge Principle),包含三大原則,其中一項就是「只與你直接認識的人交談」。你說這跟例外有什麼關係?有啊!舉個例子,當存取資料時, Repository 會進行 DB 操作,而當出錯時會拋出 SQLException,這時 Service 收到 SQLException 就知道底層的 DB 操作出了問題,這樣的介面很合理,對吧?
「不對喔!」
圖片截自網路
誰告訴你 Repositry 一定要操作 DB 的?它不能操作 API 嗎?它不能跟 Redis 拿嗎?它可不可以操作本機上的檔案?可以吧!既然可以,那憑什麼 Service 非得認識 SQLException 不可?
說到底,Repository 應該要是 Service 定義好的介面,根據 OOP 的依賴反轉原則,Service 不應該直接依賴於 Repository 的實作,而應該「共同依賴一個介面」才對。
換句話說,既然實作可以多變,那收到 Exception 的那一個實作,就不應該直接轉拋出去,而是轉化成它與上層共同依賴的介面所定義好的 Exception 才對,否則他就會強迫呼叫方去認識他原本不該認識的人,就違反迪米特法則了。
先講結論:「很簡單,因為我不喜歡。」我聲明,不是他不好,只是我不喜歡而已。
我知道,我知道大家都有讀過 Clean Code,我知道大家都記得,Uncle Bob 在書中建議大家盡量多用 Runtime Exception。但你知道為什麼他會這麼建議嗎?也許你一時忘了,我們一起來複習一下:Uncle Bob 認為,有很多人會把丟例外跟處理例外的邏輯,放在好幾層遠的地方,而語言與框架也允許我們這麼做。然而萬一底層有需要修改,沿途的所有類別都得修改簽名,違反了「開放封閉原則」,非常麻煩。
此時,如果我們丟的是 Runtime Exception,因為不用在簽名上宣告,所以錯誤從最低層往上走,一路都不用宣告,他自然會被傳遞到最高層,也就是你的「系統邊界」,這時再抓下來處理就好,非常方便,而且底層丟的例外就算有換,沿路的類別也不用再修改,滿足了「開放封閉原則」。非常多 Java 開發者(譬如 MyBatis 那群人)也喜歡這種處理方式,因為很方便。
好處還不只如此。根據 Teddy Chen 在「例外處理設計的逆襲」書中的論述,例外的處理,有幾種強健度等級,最差就是「放給它爛」,稍好一點就是做到「通知」,一路到最高等級就是「使命必達」(亦即容錯),而成本也隨著強健度等級越高而越貴。使用 Runtime Exception 後,啥都不幹,就可以自動達到「通知」的強健度等級。是不是也很方便?
方便是方便,但是這種做法,使得最上層的人「跨層」跑去認識他不該認識的 Exception。如前所述,每個人都應該要像迪米特女神一樣,只認識「直接的朋友」。譬如,處在系統邊界的 Controller,表定的工作應該是要擔任框架與 Service 的橋樑,轉化物件與資料,讓兩邊能順利溝通。結果到頭來整天在處理一些表示 DB 連線異常、檔案不存在...等等錯誤,說老實話,這些錯誤關它什麼事?它應該要處理語意上更高階的錯誤才是,譬如「學生不存在」、「成績未達標」等等。因為它是負責把這些語意再轉成前端看得懂的訊息回出去的人啊!
再者,Java 表現 Unchecked Exception 的類就是 Runtime Exception,而 Unchecked Exception 本應該代表 bug,如果拿來代表所有錯誤,那判斷上又稍微模糊難辨了一點,這點我也不太喜歡
我們回到 Uncle Bob 描述的場景,不難發現,使程式違反開放封閉原則的元兇,並不是 Checked Exception 本身,而是「不良的設計習慣」。收到底層丟出的例外後,不假思索地沿路往外拋,才會造成違反開放封閉的問題。此時如果「全面改用 Runtime Exception」,雖能解決問題,但副作用卻是又違反了迪米特法則。因此我認為這不是一個非常好的解法。
參考「例外處理的逆襲」,比較乾淨的解法,應該是要每當遇到 Checked Exception 時,要使用「轉包或處理」模式。亦即,如果能處理就處理,萬一不能處理,也不要原封不動丟出去,要攔下來轉包成上層應該認識的樣貌,這樣就可以同時解決「開放封閉」的問題,也不會造成「迪米特」的新問題。這時如在系統邊界還是遇到了 Unchecked Exception,就只剩下「bug」了,那事情就好辦多了,有 bug 修理就是了。然而,不可否認像這樣的解法,每層都要考慮到底是要轉包還是處理,真的很麻煩,要一直想事情、一直 try-catch 很討厭。
「說這麼多,就是每個解法都有問題囉!」欸對啊,本來世上就不存在完美的解法,所以我一開始針對「使用 Runtime Exception」這件事,只說了「我自己不喜歡」,我可沒說各位不能用,更沒說 Uncle Bob 不對哦!請千萬別誤會啊各位,小弟只是讀過兩年書,塵世中一個迷途小 RD 而已啊...
圖片截自網路
謎之聲:「沒有完美的解法,端看自己想要承擔哪一種副作用而已。」
ithelp2021
Teddy 大有公開《笑談軟體工程:敏捷開發法的逆襲》、《笑談軟體工程:例外處理設計的逆襲》兩本絕版書的原稿電子檔下載
http://teddy-chen-tw.blogspot.com/2020/04/blog-post_20.html
感謝 Kuma 大的系列文章,小弟受益良多,也感謝佛心的 Teddy 大開放著作下載
感謝支持,小弟也是略懂而已,主要是工作上試用後發現有刻意好好使用 Exception 之後,解決了很多問題,於是野人獻曝一下