Controller 完成後,Service 的介面形狀其實已經幾乎被確定了。在 Clean Architecture(簡潔架構)的分層裡,位於 Use Case 層的 Service 本來就是為 Controller 而存在的,於是 Service 接下來的任務就是:「依據 Controller 給的資料完成自己的工作,並在需要時回報。」
延續前一章的範例,本章先列出 ApplyScholarshipService 該做的事:
- 調閱學生資料
- 調閱獎學金規定資料
- 查驗是否符合資格
- 填寫正式申請書
- 存檔
對應的測項清單:
| 情境 | 結果 |
|---|---|
| 找不到學生資料 | Exception 987 |
| Repository 取得學生資料時失敗 | Exception 666 |
| 找不到獎學金資料 | Exception 369 |
| Repository 取得獎學金資料時失敗 | Exception 666 |
| 資格不符 | Exception 375 |
| Repository 儲存資料時失敗 | Exception 666 |
| 成功 | void |
同樣地,這份清單只是計畫,過程中發現不足或不對都可以調整。
測項一:完全成功#
先確保「沒有出錯就不通知 Controller」的設計:
@Test
void all_ok() throws DataAccessErrorException, ClientSideErrorException {
StudentRepository studentRepository = Mockito.mock(StudentRepository.class);
ApplyScholarshipService applyScholarshipService
= new ApplyScholarshipService(studentRepository);
ApplicationForm applicationForm
= new ApplicationForm(12345L, 98765L);
applyScholarshipService.apply(applicationForm);
}public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, DataAccessErrorException {
// 調閱學生資料
// 調閱獎學金規定的資料
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}什麼都沒做測試就過了。先放著醜陋的測試碼,等下個測項做完一起重構。
測項二:學生不存在#
@Test
void when_student_not_exist_then_987() {
StudentRepository studentRepository = Mockito.mock(StudentRepository.class);
Mockito.when(studentRepository.find(12345L))
.thenReturn(Optional.empty());
ApplyScholarshipService applyScholarshipService
= new ApplyScholarshipService(studentRepository);
ApplicationForm applicationForm
= new ApplicationForm(12345L, 98765L);
ClientSideErrorException actualException = Assertions.assertThrows(ClientSideErrorException.class,
() -> applyScholarshipService.apply(applicationForm));
Assertions.assertEquals(987, actualException.getCode());
}採用
Optional是想表達:每一層的實作都該把依賴隱藏起來,只透露與上層約定好的行為。被呼叫者的介面,應該以「使用者」好用為優先考量。
實作時利用 orElseThrow 一行解決:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, DataAccessErrorException {
// 調閱學生資料
studentRepository.find(applicationForm.getStudentId())
.orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
// 調閱獎學金規定的資料
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}兩個測試現在有足夠的重複可以一起重構,把細節抽進 private method:
@Test
void all_ok() throws DataAccessErrorException, ClientSideErrorException {
given_student_exists(12345L);
when_apply_with_form_then_NO_error(application_form(12345L, 98765L));
}
@Test
void when_student_not_exist_then_987() {
given_student_NOT_exists(12345L);
when_apply_with_form_and_error_happens(application_form(12345L, 98765L));
then_error_code_is(987);
}測項三:取得學生資料時 Repository 失敗#
資料來源故障屬於伺服器端問題,採用「攔下來轉拋」的策略:
@Test
void when_DB_fail_on_getting_student_then_666() throws RepositoryAccessDataFailException {
studentRepository = Mockito.mock(StudentRepository.class);
Mockito.when(studentRepository.find(12345L))
.thenThrow(new RepositoryAccessDataFailException());
applyScholarshipService = new ApplyScholarshipService(studentRepository);
dataAccessErrorException = Assertions.assertThrows(DataAccessErrorException.class,
() -> applyScholarshipService.apply(application_form(12345L, 98765L)));
Assertions.assertEquals(666, dataAccessErrorException.getCode());
}public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student result;
try {
result = studentRepository.find(applicationForm.getStudentId())
.orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
} catch (RepositoryAccessDataFailException e) {
throw new ServerSideErrorException("failed to retrieve student data", 666);
}
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}順手把 DataAccessErrorException 改名為 ServerSideErrorException,與 ClientSideErrorException 形成對稱命名,並重構測試只露出抽象意圖:
@Test
void when_DB_fail_on_getting_student_then_666() throws RepositoryAccessDataFailException {
assume_repository_would_fail_on_getting_student(12345L);
when_apply_and_fail_on_server_side(application_form(12345L, 98765L));
then_server_side_error_code_should_be(666);
}測項四:獎學金不存在#
@Test
void when_scholarship_not_exist_then_369() throws RepositoryAccessDataFailException {
given_student_exists(12345L);
given_scholarship_NOT_exists(98765L);
when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));
then_client_side_error_code_is(369);
}public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
try {
studentRepository.find(applicationForm.getStudentId())
.orElseThrow(() -> new ClientSideErrorException("cannot find student", 987));
} catch (RepositoryAccessDataFailException e) {
throw new ServerSideErrorException("failed to retrieve student data", 666);
}
// 調閱獎學金規定的資料
scholarshipRepository.findOptional(applicationForm.getScholarshipId())
.orElseThrow(() -> new ClientSideErrorException("cannot find scholarship", 369));
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}測項五:取得獎學金資料時 Repository 失敗#
@Test
void when_DB_fail_on_getting_scholarship_then_666() throws RepositoryAccessDataFailException {
given_student_exists(12345L);
assume_DB_would_fail_on_getting_scholarship_data(98765L);
when_apply_and_fail_on_server_side(application_form(12345L, 98765L));
then_server_side_error_code_should_be(666);
}主程式現在累積了兩個 try-catch,方法又長又重複,可以用 Extract Method 把細節隱藏成「複合函式(Composed Method)」的樣貌:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}Composed Method 出自 Joshua Kerievsky 的《重構與模式》。當希望方法只表現抽象邏輯而隱藏細節時,這個模式非常實用。
暫停休息#
Repository 把資料取出來後,Service 完全不需要關心 Database 的形貌,甚至不在意資料是否真的存在於 Database 中。良好的分層讓 Service 可以更專心處理它的份內工作:自動化流程。
接下來 Service 還有「驗證資格」、「填寫正式申請書」、「保存正式申請書」這幾項任務未完成,下一章繼續。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10270364