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