笔者写作年资不算长,但写到後来,还是多多少少能在动笔之前,感受一些主题的容易度,譬如理论的主题,对我来说比较好写,跟程序比较相关的主题就比较没那麽简单。倒也不是说逻辑很难,而是在已经用程序表达了一次意图後,又要用文字再阐述一次,而且不能只是把程序翻成中文重讲一遍,是要延伸一些深入的论述,这件事其实(至少对我来说)没那麽容易。
但没关系,写程序跟写作一样,本来就是要不断练习,要能够「行礼如仪」地写出足够量的文章,又不能只是不断复制贴上自己的文章(或程序码),不然就变成「行将就木」了。
日本作家桦泽紫苑的「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
>>: Day 10:为你的 Hexo 增加页面:标签、分类与自订页面
It is impossible to track a phone's exact location...
本系列文之後也会置於个人网站 Realm,中文或许会翻作「域」,但基本很像是程序开发上,语言层面提...
Day 4 - 虚拟机的设置 今天我会讲Android Studio虚拟机的设置,那我们废话不多说,...
经过昨天对Hook有个初步的认识之後,下方介绍几个Hook的范例。 Hook 的规则 建议只在最上层...
RGB -> Gray scale Gray scale(灰阶影像) from PIL imp...