台灣的職業運動中,最具代表性的應該就是棒球了。大家有去打擊練習場玩過嗎?現在的打擊練習場,在業者持續改良轉型下,已經慢慢轉變成大人小孩都適點的綜合型娛樂場所了。而說到棒球的打擊,筆者也是略懂略懂,可以依個人喜好,選擇進行 Inside-Out,或是 Outside-In 的攻擊。
Outside-In 是一種強力拉回型的攻擊方式,打者在球進到攻擊範圍內,算準揮棒的甜蜜點,在球棒加整到最快的瞬間擊中球,將球送出。一般而言,力量較強的大砲型打者,比較喜歡這種方式。而 Inside-Out 不同,它強調把身體重心留在原地,蓄勢待發,把球看清楚,等到攻擊時機成熟,再一口氣釋放力量,把球送出。由於 Inside-Out 的攻擊方式會把球看久一點,所以很多時候會把球送到打擊方向的相反方向,形成球評主播口中常說的「反方向攻擊」。常見於安打型球員。若論國內 Inside-Out 打擊最具代表性球員,則非中信兄弟看板球星「火星恰」彭政閔莫屬了。
Inside-out 教科書 - 彭政閔,圖片截自 WikiPedia
如同棒球,寫程式也可以 Inside-Out,也可以 Outside-In,依場景不同,開發者可以自由選擇。
上一篇中我們分析了一個「申請獎學金」的實際案例,而從今天開始一連四篇,我們要來試看看 Outside-In 配合 Clean Architecture 的開發要怎麼進行。我們會從後端 API 服務中,位於 Interface Adatper 層的 Controller 開始,一層一層地往內把這個 Query 的程式與測試做出來。因為我們已經聊過 TDD 了,所以接下來每一層的程式,我們都會以 TDD 的方式進行。
筆者在以 TDD 進行開發時,習慣先把可能會遇到的場景先列出來。這樣做的好處,除了先幫助自己先釐清接下來要做的事情以外,也可以在開發的路上,有個可以參照的對象,比較不會不小心走歪路。
測項 | 結果 |
---|---|
學生不存在 | 400 |
獎學金不存在 | 400 |
資料存取錯誤 | 500 |
其他異常 | 500 |
成功 | 200 |
請留意,這些測項是一開始我們對這個類別行為的規劃,而不是規定。因此,隨著待會開發的進行,我們會在需要的時候隨時對其進行修改。
這是一個 Controller,其任務就是要「轉化」前端來的申請單,找到合適的 service 並發請求,最後把結果(或錯誤)回給前端。
第一步,我們來寫一個會 Fail 的測項,來確保待會兒綠燈時,這個 Controller「學生不存在」時的反應是如預期的,所以我們要跟 Dependency Injection 的 Service 串通好,待會有人來申請獎學金時,要丟出指定 Exception。
@Test
void student_NOT_exists() throws Exception {
ApplicationForm applicationForm = new ApplicationForm(
9527L,
55688L
);
ApplyScholarshipService applyScholarshipService = Mockito.mock(ApplyScholarshipService.class);
Mockito.doThrow(new StudentNotExistException("ANY_MESSAGE"))
.when(applyScholarshipService)
.apply(applicationForm);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.post("/scholarship/apply")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(applicationForm));
mockMvc.perform(request)
.andExpect(status().is(400))
.andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.bad(987))));
}
我知道很醜。醜沒關係,我們待會會重構。現在先跑一下,確保它會 Fail:
好,沒問題,我們什麼 code 都沒寫,連路徑都還沒指定,會 404 很正常。這就代表這個測項有測到東西。
喔對了,這個測項還有另一個很重要的任務:「定路徑」,這個測項一旦通過,這個功能在系統中的 url 也就定了,所以,第一個測項還蠻責任重大的,不建議同時再背負其他太複雜的邏輯。
那就來寫程式吧!這裡我不會像 Kent Beck 在 Test Driven Development 書中那樣一步跨那麼小,因為篇幅有限,我會直接把邏輯寫出來。在其實工作中,一步要多大,各位可以自己決定,舒服就好。
@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
return ResponseEntity.status(400).body(ApiResponse.bad(987));
}
各位可以看到我不管三七二十一,見面就丟 400 出去,還附一個先前介紹過,我們與前端講好的 error code:987。這是故意的,因為我就是要少做一點事,等新測項把真正的判斷邏輯「逼」出來,我才要來做,才不會浪費時間。
是的,測試太醜了。來重構吧!我先用 Extract Method 的方法,把操作細節隱藏起來,暴露抽象意圖:
@Test
void student_NOT_exists() throws Exception {
assume_student_not_exist(9527L);
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(400))
.andExpect(content().json(bad_response_content(987)));
}
各位不妨假裝自己不懂程式,就光是念出這個測項中的「英文單字」,應該就能推敲出這個測項在測什麼場景。如果可以,代表我們這個重構效果不錯。
如果學生存在,但對方要申請的「獎學金」根本不存在,那我們也肯定能使這個申請成立。一樣,我們由測試開始。有了重構過的第一個測試,我們期待第二個測試會好寫一點:
@Test
void scholarship_NOT_exists() throws Exception {
Mockito.doThrow(new ScholarshipNotExistException("ANY_MESSAGE"))
.when(applyScholarshipService)
.apply(application_form(9527L, 55688L));
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(400))
.andExpect(content().json(bad_response_content(369)));// 369: scholar not exists
}
運行結果應該要是錯的,而且錯得跟我們預期的一樣,想要 369,結果卻得到 987:
這個測項,我們做的事很少,只是 copy-paste 一下前一個測項,確保我們有測到東西。其實可以預期以後應該也不會多太多,這就是時常小幅重構的好處。
總之,現在我們可以來寫程式了。
此時,剛剛故意不寫,拿來判斷 Exception 種類的邏輯就被新的測項給「逼」出來了:
@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
try {
applyScholarshipService.apply(applicationForm);
} catch (StudentNotExistException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(987));
} catch (ScholarshipNotExistException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(369));
}
return null;
}
這裡我們抓了兩種 Exception,按照與前端的協議,一種給 369,一種給 987,這下不管是哪一個資料有誤的 case,未來一旦被改錯,我們也不怕了。
至於方法最後的 return null,因為我們還沒測到那,就先留著吧!
一樣,測試太醜了,同一個方法的兩行邏輯的「抽象程度」不同。這在 Uncle Bob 的 Clean Code 有特別提到,是會干擾閱讀的。我們不喜歡,所以改掉它:
@Test
void scholarship_NOT_exists() throws Exception {
assume_scholarship_not_exists(55688L);
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(400))
.andExpect(content().json(bad_response_content(369)));// 369: scholar not exists
}
有時候我們會遇到資料庫一些不可抗力的因素,譬如資料庫突然故障,機房網路瞬斷等等,這時也要給前端一個適當的通知,讓它知道是後端出了問題,不是使用者的問題。這會有助於前端顯示正確的訊息,或適度的隱藏。
先寫一個測試描述場景:
@Test
void data_access_error() throws Exception {
Mockito.doThrow(new DataAccessErrorException("ANY_MESSAGE"))
.when(applyScholarshipService)
.apply(application_form(9527L, 55688L));
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(500))
.andExpect(content().json(bad_response_content(666)));// 666: data access error
}
跑測試,確定會錯,而且錯在我們預期的地方:
看來,我們越來越熟練了呢!那就直接加邏輯吧:
@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
try {
applyScholarshipService.apply(applicationForm);
} catch (StudentNotExistException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(987));
} catch (ScholarshipNotExistException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(369));
} catch (DataAccessErrorException e) {
return ResponseEntity.status(500).body(ApiResponse.bad(666));
}
return null;
}
有沒有發現,我們現在在做的事越來越小步?因為結構已經慢慢穩定下來,現在只要不加太大新功能,這個 Controller 差不多就長這樣了。
重構測試,讓測試更具表現力:
@Test
void data_access_error() throws Exception {
assume_data_access_would_fail(9527L, 55688L);
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(500))
.andExpect(content().json(bad_response_content(666)));// 666: data access error
}
在進入下一步前,我想整理另一塊地方。我們剛剛建了三個 Exception,這使得 service 的簽名變得非常長:
public void apply(ApplicationForm applicationForm) throws StudentNotExistException, ScholarshipNotExistException, DataAccessErrorException {
// To be implemented...
}
我們不喜歡太長的簽名,於是想來重構一下,讓他簡單一點。在 Teddy Chen 的「例外處理的逆襲」書中,有教我們一些「重構例外」的方法,各位有興趣可以看看,這裡,我們試著用其中一個手段來整理。
這裡我們發現,「學生不存在」與「獎學金不存在」都是「用戶的問題」,而目前為止對此種錯誤的處理都大同小異,只有回覆的代碼不同,所以我想要來把這兩個錯誤合併,並以「錯誤代碼」來細分就好:
@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
try {
applyScholarshipService.apply(applicationForm);
} catch (ClientSideErrorException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
} catch (DataAccessErrorException e) {
return ResponseEntity.status(500).body(ApiResponse.bad(666));
}
return null;
這裡我們可以看到,兩個跟「使用者出錯」有關的 Exception 已經合併,service 的簽名也可以縮短,而前端照樣可以由 code 來區分錯誤的細節。至於 server 端的錯誤,目前還沒有長太大,所以就暫時這樣了,等未來真有困擾再說也行。
天有不測風雲,寫程式哪有永遠對的。在運行途中,service 難保不會出什麼預料之外的亂子。而因為我們這裡對已知可能發生的問題都已用了 Checked Exception 來接,還可能發生問題的話,肯定只剩代表 bug 的 Runtime Exception 了。
我們來寫個測項吧:
@Test
void unknown_error() throws Exception {
given_some_bug_exists(9527L, 55688L);
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(500))
.andExpect(content().json(bad_response_content(999)));
}
來跑看看:
果真錯了。
這裡我刻意一下子就「又寫又抽方法」,我只是想表現:各位在進行時,可以自己決定自己步伐要多大,不一定要照別人或我的方式,只要你自己覺得舒服自在,且能短時間內頻繁綠燈就好。
來吧,我們來讓我們的測試也亮綠燈:
@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
try {
applyScholarshipService.apply(applicationForm);
} catch (ClientSideErrorException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
} catch (DataAccessErrorException e) {
return ResponseEntity.status(500).body(ApiResponse.bad(666));
} catch (Exception e) {
return ResponseEntity.status(500).body(ApiResponse.bad(999));
}
return null;
}
看看,好像沒什麼特別糟糕的味道,那就跳過不重構吧!
終於,來到最後一個測項了。這時,要的做事與前面大同小異,我想應該可以不用細談中間過程了吧!
總之,當所有事都正確,理應回傳 200 給前端,而且內容可以是空的:
@Test
void all_ok() throws Exception {
mockMvc.perform(request(
"/scholarship/apply"
, application_form(9527L, 55688L)))
.andExpect(status().is(200))
.andExpect(content().json(objectMapper.writeValueAsString(ApiResponse.empty())));
}
此時程式也完成:
@PostMapping("/scholarship/apply")
public ResponseEntity<ApiResponse> apply(@RequestBody ApplicationForm applicationForm) {
try {
applyScholarshipService.apply(applicationForm);
} catch (ClientSideErrorException e) {
return ResponseEntity.status(400).body(ApiResponse.bad(e.getCode()));
} catch (DataAccessErrorException e) {
return ResponseEntity.status(500).body(ApiResponse.bad(666));
} catch (Exception e) {
return ResponseEntity.status(500).body(ApiResponse.bad(999));
}
return ResponseEntity.status(200).body(ApiResponse.empty());
}
跑一下測試,全綠燈,且測試時間非常短:
非常划算,可以多跑幾次 XD
這時可以再檢查一下,如果有什麼看不順眼的就重構,沒有就大功告成囉!
我個人是對現在「Service 會知道與 Client 溝通的 error code」這件事比較感冒啦,如果是平常工作時,我會再針對這一點修改一下,不過,這還是看你自己習慣。正如我一再強調的:「沒有標準答案,你舒服就好。」
我們花了不短的篇幅,演示了怎麼用 TDD 的方式,一步步建構後端 API 服務的門面:Controller。
Controller 位於 Clean Architecture 的 Interface Adapter 層,直接與最外層的 Framework 與 Internet 相接。如果我們要把這一層寫得非常乾淨,與 Framework 完全隔絕,那會非常麻煩,付出的成本也許會超出收益。
再者,現在的 Framework 功能都很強大,例如我們專案中使用的 Spring Boot,妥善地利用,它可以為我們解決很多與外面世界溝通的事情,譬如 url 路徑與 Http 方法的 Mapping。但於此同時,測試的獨立性也就被犧性了,因為這會使程式很難與框架分開測試。
幸好,也正因為現在的框架都很厲害,它們大多都提供了很方便的測試套件,因此,在 Controller 的測試,我們可以直接使用框架提供的套件來幫助我們測試,本篇中的測試就屬於此類。
方便歸方便,再往內的 Service 層就不建議如此了。越是核心的元件,就越要遠離框架,才能保持我們的核心邏輯的自由度,不會被「細節」給綁架了。畢竟,解決商業上問題的是「核心邏輯」,不是框架等細節。
謎之聲:「框架是細節,網路是細節,Web 是細節,Dababase 也是細節。」
ithelp2021