筆者寫作年資不算長,但寫到後來,還是多多少少能在動筆之前,感受一些主題的容易度,譬如理論的主題,對我來說比較好寫,跟程式比較相關的主題就比較沒那麼簡單。倒也不是說邏輯很難,而是在已經用程式表達了一次意圖後,又要用文字再闡述一次,而且不能只是把程式翻成中文重講一遍,是要延伸一些深入的論述,這件事其實(至少對我來說)沒那麼容易。
但沒關係,寫程式跟寫作一樣,本來就是要不斷練習,要能夠「行禮如儀」地寫出足夠量的文章,又不能只是不斷複製貼上自己的文章(或程式碼),不然就變成「行將就木」了。
日本作家樺澤紫苑的「AZ原則」,要我們以輸出為目的輸入,以達到更好的學習成效,我想就是支持我一直寫作的動力之一吧!
我們來繼續 Service 未完成的工作吧。
在取得 Student 與 Scholarship 的資料以後,這位「獎學金申請」專員,接下來要做的事,就是要真正去驗證此申請是否合乎規定,如果是,那就要為申請者填寫一份正式的申請書,並回頭請「檔案管理員」把這份申請書保存起來,工作才算完成。
我們來看看剩下的測項有哪些:
測項 | 行為 |
---|---|
超過申請時間 | Exception 374 |
資格不符 | Exception 375 |
Repository 儲存資料時發生錯誤 | Exception 666 |
等等,「超過申請時間」何時蹦出來的?一開始列測項時沒說有這回事呀!難不成這待辦清單還會長大?
是的。它本來就會長大。
在列測項時,我們的確是照著我們當時的最佳理解來進行的,但是人對同一件事的理解,隨著你的接觸越多,是會越來越完整的,這點 Kent Beck 在 TDD 一書也有提到。當我們對這個功能的需求了解多了,自然會知道一些原本沒有料到的問題。而測試代表需求,當需求成長,那測項也會長大,這是自然而然的事。
所以,此為自然現象,請安心服用。
我們來看看,如果超過申請時間,會發生什麼事情。首先先用測試來描述場景與需求:
@Test
void when_overtime_then_374() throws RepositoryAccessDataFailException {
given_student_exists(12345L);
given_scholarship_exists(98765L, scholarship(2021, 7, 31));
given_today_is(2021, 8, 1);
when_apply_and_fail_on_server_side(application_form(12345L, 98765L));
then_client_side_error_code_is(374);
}
這個測項也很簡單,學生與獎學金都存在,獎學金的 deadline 是 7/31,但今天已經是 8/1 了,於是預期 service 會用 374 這個 error code 來表示「申請逾期」。
各位可以看到我這裡步伐跨很大,直接把 Composed Method 寫出來了,而不是用重構的。我故意的,我不是任性,我只是想要不厭其煩地再強調一次:「步伐要小一點是通則,但實際要多小,沒有嚴格規定,你自己多練習,自己覺得舒服就好。」
寫完測試來寫程式吧:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
LocalDate deadline = scholarship.getDeadline();
LocalDate now = LocalDate.now();
if (now.isAfter(deadline)) {
throw new ClientSideErrorException("application over time", 374);
}
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}
來重構吧。這裡的程式有抽象程度不對等的問題,操作細節與商業邏輯被擺在一起了。這個可以靠 Extract Method 的方法解決:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
checkDeadline(scholarship);
// 查驗是否符合資格
// 填寫正式申請書
// 存檔
}
又回到「Composed Method」的樣貌了。
可能已經有人發現了,這裡的 CheckDeadline,與先前的某一段示範 Mock Static 的程式碼很像。是的,因為我用了同一個場景來舉例,但你應該也同時發現了,這兩個例子的實作方法並不相同。其實,在真實工作上,也常有機會遇到這種情形。
遇到類似情形,有人會選擇實施「複製貼上大法」,這沒什麼不好,但別忘了,重複乃萬惡之淵藪,一旦測試通過了,還是要趕快重構來消重複才行。尤其這裡根本不是類似,而是同一場景,其實更是要重複利用程式碼才行,不過這邊主要是為了要示範,所以筆者就故意兩邊都留下,方便讀者對照。
真實生活遇到了的話,我是會建議各位做完新功能後,直接移除舊功能,或是抽共用,因為重複了,而我們不喜歡重複。
邏輯再往下走,我們來看看資格不符時,會發生什麼事。我們假設這個獎學金的規定是「只有博士班」能申請,如此一來,大學生跑來申請肯定不行吧!我們來看看怎麼處理這樣的邏輯。
一樣先寫測試:
private static final LocalDate july31 = LocalDate.of(2021, 7, 31);
@Test
void when_disqualified_then_375() throws RepositoryAccessDataFailException {
Mockito.when(studentRepository.find(12345L))
.thenReturn(Optional.of(new Student("Michael", "Jordan", "Bachelor")));
given_scholarship_exists(98765L, scholarship(july31));
given_today_is(july31);
when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));
then_client_side_error_code_is(375);
}
這裡我們假設今天是 7/31,那申請截止日同為 7/31 的獎學金理應沒問題,但我們假設這個獎學金只有「博士生」能申請,那麼很明顯就資格不符了。依照我們先前講好的規範,資格不符是 Client 端的問題,並且要提示 375 的 error code。
來寫個 code 使測試通過吧:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
checkDeadline(scholarship);
// 查驗是否符合資格
if (!student.getDegree().equals("PhD")) {
throw new ClientSideErrorException("this scholarship is for PhD students only", 375);
}
// 填寫正式申請書
// 存檔
}
重構測試與程式。我們一樣不想曝露太多細節:
@Test
void when_disqualified_then_375() throws RepositoryAccessDataFailException {
given_student_exists(12345L);
given_scholarship_exists(98765L, scholarship(july31));
given_today_is(july31);
when_apply_with_form_and_client_side_error_happens(application_form(12345L, 98765L));
then_client_side_error_code_is(375);
}
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
checkDeadline(scholarship);
// 查驗是否符合資格
checkProgramIsPhD(student);
// 填寫正式申請書
// 存檔
}
這裡其實有個蠻明顯的壞味道:Feature Envy。我們晚點會重構掉。為什麼不現在做?因為這會跟下一篇要講的 Entity 有關,於是這裡就先不動了。
Service 的最後一項工作,就是寫一份正式的申請書,並且請檔案管理員代為歸檔。這時如果檔案管理員處理有誤,Service 的責任就是轉包為 Controller 看得懂的錯誤,並且往外發出去。先寫測試:
@Test
void when_DB_fail_on_writing_application_to_DB_then_666() throws RepositoryAccessDataFailException {
given_student_exists(12345L, "PhD");
given_scholarship_exists(98765L, scholarship());
given_today_is(july31);
Mockito.doThrow(new RepositoryAccessDataFailException())
.when(applicationRepository).create(any(Application.class));
when_apply_and_fail_on_server_side(application_form(12345L, 98765L));
then_server_side_error_code_should_be(666);
}
程式:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
checkDeadline(scholarship);
// 查驗是否符合資格
checkProgramIsPhD(student);
// 填寫正式申請書
Application application = applicationForm.toApplication();
// 存檔
try {
this.applicationRepository.create(application);
} catch (RepositoryAccessDataFailException e) {
throw new ServerSideErrorException("failed to create application", 666);
}
}
很好,我們越來越熟練了。接著重構程式:
public void apply(ApplicationForm applicationForm) throws ClientSideErrorException, ServerSideErrorException {
// 調閱學生資料
Student student = findStudent(applicationForm);
// 調閱獎學金規定的資料
Scholarship scholarship = findScholarship(applicationForm);
// 查驗是否符合資格
checkDeadline(scholarship);
// 查驗是否符合資格
checkProgramIsPhD(student);
// 填寫正式申請書
Application application = applicationForm.toApplication();
// 存檔
createApplication(application);
}
重構測試:
@Test
void when_DB_fail_on_writing_application_to_DB_then_666() throws RepositoryAccessDataFailException {
given_student_exists(12345L, "PhD");
given_scholarship_exists(98765L, scholarship());
given_today_is(july31);
assume_DB_would_fail_on_creating_application_data();
when_apply_and_fail_on_server_side(application_form(12345L, 98765L));
then_server_side_error_code_should_be(666);
}
圖片截自網路
這裡我們發現了一個新的問題:當此人重複申請的時候,應該怎麼處理?我們一開始的確是沒想到,不過既然現在發現了,那還是得處理。
當然,我們可以先問 Repository 此申請是否已經存在,但是,在這個微服務當道的年代,可能同時有幾百台機器也在線服務著,前一刻詢問時還不存在的資料,有可能幾百毫秒後要寫入時就被另一台機器搶先寫入了。
這個問題其實不難,也不容易,因為能影響決策的因素太多了,譬如儲存裝置是否是 RDB、是本機去存還是委託其他機器存、資料的設計本身是否就有可供分辨的欄位、要 Consistent 還是要 Eventually Consistent 就好…等等,所以,應該要綜合考慮真實世界的完整 Context 才行。
這裡我將會故意留一個思考題給各位,邀請各位來想想,在怎樣的 Context 下,你會怎麼處理這個問題。
我們用了兩篇文章來描述在 Clean Architecture 中,位於 Use Case 層的 Service 如何進行它「自動化」的工作,並且我們使用了 TDD 的方式來一步步建構出完整邏輯,最後,我們也留了一個思考題,邀請讀者思考一個與「實際 Context」有高度相關,但其實各位在工作中應該蠻常會遇到的問題。
至此,我們還剩下 Repository 與 Entity 還沒完成。我們將在下一篇裡接著討論這兩個角色的實作準則。
下回見!
ithelp2021