即便我们能够极尽所能把代码写整洁,规避各种坏味道,但我们小心翼翼维护的代码,还是可能因为新的需求被破坏。
新的需求总会在路上,所以,写代码时需要时时刻刻保持嗅觉。
实现驳回
有个功能,内容作品提交之后要由相应的编辑审核。
审核有审核通过和不通过,这是系统中早就开发完成的。
有一天,新的需求来了:驳回审核通过的章节,让作者重新修改。造成作品需要驳回的原因有很多,比如,审核标准的调整,这就会导致原先通过审核的作品又变得不合格。
先看代码库已有怎样的基础。
首先,系统已有审核通过、审核不通过的接口。
PUT /chapter/{chapterId}/review
DELETE /chapter/{chapterId}/review
该设计将章节(chapter)的审核(review)当作了一个资源。
创建章节时,章节的审核状态就创建好了:
- 审核通过
相当于对这次审核进行了修改 - 审核不通过
相当于删除了该资源
对应这俩接口的服务接口:
章节上有个状态字段,标识现在章节处于什么样的状态:
- 待审核
- 审核通过
- 审核不通过
已知这些基础,那驳回的需求如何设计?
新增一个驳回功能,自然要:
- 新增一个驳回接口
- 然后,在服务中增加一个驳回服务
- 最后,在状态中增加一个驳回状态
看起来很合理,要准备写代码了呢。
这里有个坏味道来自要新增一个接口。
来一个新需求,就增加一个新接口,对大部分同学,这是一种多么正常的编程思维呀。
但必须对新增接口保持谨慎。
接口,是系统暴露出的能力,一旦一个接口提供出去,你就不知道什么人会以何方式使用该接口。
很多系统有大量接口,仔细梳理会发现,有很多接口提供相似功能,这会让新人懵逼。即便你打算对系统进行重构,当清理掉一个你以为没人用的接口,就会有人跑出来告诉你,该接口调整影响了他们业务。
所以,必须对接口调整尤其慎重。最好从源头就开始限制,当我们想对外提供一个接口时,扪心自问:真的必须要提供新接口吗?
我面对该需求的第一反应和大多数人一样,也是新增接口。但是否真的要新增一个接口?
如果新增接口,就要复用已有接口,但复用前提是:新增的业务动作可通过已有业务完成,或是对已有业务进行微调就可以。
到底是新增or复用,还是要回到业务。
原业务中:
- 审核通过会进入下一阶段
- 审核不通过,就退回到作者那,进行修改
那驳回后呢?也会要求作者去修改。
发现了吧?驳回的动作和审核不通过的后续操作一致,只是起始状态:
- 若原来状态是待审核
经过一个审核不通过的动作,状态就变成审核不通过 - 若原来状态是审核通过
经过一个驳回动作,状态就变成驳回
所以,完全可复用原来的审核不通过接口。
既然是复用接口,所有的变化就都是内部变化了,可根据章节的当前状态判断,设置相应状态。
代码上,既不需要新增驳回接口,也无需新增驳回服务,只需修改 Chapter 类的内部,改动量比预期的小了很多。
代码结构如下:
这样,只需增加一个驳回状态,在当前状态是审核通过时,赋值这个新状态。
看来,已经把这次要改动的代码限制在一个最小范围。
但真的需要这么一个状态吗?
是否增加一个驳回状态,回答这个问题还是要回到业务上、:
驳回后续的处理与审核不通过的状态到底有何不同?
按PM本来的需求,他是希望做出一些不同。比如:
- 审核不通过状态,编辑端则无法查看
- 处于驳回状态,编辑则可以查看
但在当前产品状态下,是否可统一二者呢?即都按审核不通过处理?
PM想了想,觉得其实也可以。于是,两种不同状态在这里得到统一,最后根本没必要新增这个驳回新状态。
最终,这次的业务调整,后端服务代码没做任何修改,只是前端在需要驳回时,增加了一个对审核不通过的调用,而所有这一切的起点,只是我们对于新增接口的嗅觉!
定时提交
一般作者写完一章后,就直接提交,这是系统已有功能。
现在有个新需求:有时,作者会囤稿,为保证自己每天都能有作品提交,作者希望作品能在自己设定的时间提交,即一个章节在它创建时,并不会直接提交到编辑那里去审核,而等到特定时间再完成作品的提交。
“每天都有作品提交”本质就是一种连续的签到,通常系统都会给连续签到以奖励,这也是对于作者的一种激励手段。
那么,你会如何实现该需求?
与这个需求最直接相关的代码就是章节信息:
class Chapter {
// 章节 ID
private ChapterId chapterId;
// 章节标题
private String title;
// 章节内容
private String content;
// 章节状态
private Status status;
// 章节创建时间
private ZonedDateTime createdAt;
// 章节创建者
private String createdBy;
// 章节修改者
private String modifiedBy;
// 章节修改时间
private ZonedDateTime modifiedAt;
...
}
要实现这个需求,需要一个定时任务,定期扫描那些需提交的作品。
但这些定时的信息放在哪?
这还不简单,在章节上加上一个调度时间不就行了:
class Chapter {
...
private ZonedDateTime scheduleTime;
}
这么实现并不复杂。但这可能也是坏味道,因为要改动实体。
一有需求,就改动实体,这几乎是大部分开发者条件反射的习惯。
然而,对于一个业务系统,实体是最核心的,改动之须谨慎考量。
因为随意修改实体,必然伴随其它部分调整,经常变动的实体,会让整个系统难以稳定。
系统的业务一般不会经常改变,所以,核心的业务实体应该是一个系统中最稳定的部分。
你可能会说:我有什么办法,需求总在变,就总会改动到这个实体呀!
需求总在变,这没有错,但是否真的就要改动业务实体?
很多时候,这只是应有职责没分析清楚而已,写代码从不考虑更好的设计!
我们现在需要的是定时提交一个章节,而这个定时信息并非核心业务实体的一部分,只是在一种特定场景下才需要的信息。
所以,它根本不该添加到 Chapter 类。
那应该放在哪呢?
显然,这里少了一个模型,关于调度的模型。
只需新增一个模型,让它和 Chapter 关联(组合):
class ChapterSchedule {
private ChapterId chapterId;
private ZonedDateTime scheduleTime;
...
}
这样,后续再有关于调度的信息,即可放至该模型里。而且核心模型 Chapter 保持不变。
把定时提交的信息与章节本身分开,是因为这二者的改变原因不同。将二者混在一起,就违反了单一职责原则。
看来已经得到很合理的方案了,有了基础数据结构,修改对应接口和服务都易如反掌了。
但这就结束了吗?
新增的需求是定时发布,有这么个需求,和作者激励有关。
要想确定作者的激励,就要确定章节的提交时间,但如何确定章节提交时间?
在原来实现中,创建时间就是提交时间,因为章节是立即提交的,而现在创建时间和提交时间有可能不同了。
你可能会想到,创建时间不行,那就用修改时间。我告诉你,这也不行,修改时间是章节信息最后一次修改的时间,它有可能因为各种原因变更,最简单的就是编辑审核通过,这个时间就会变。
至此,突然发现,模型里居然没有存放提交时间的地方。
是的,得修改实体了,给它增加一个提交时间:
class Chapter {
...
private ZonedDateTime submittedAt;
}
肯定有读者好奇了:之前讨论那么多,不就为了不在 Chapter 新增信息,你现在就这么轻易新增字段了?
一个字段该不该加在一个类里,取决于其改变原因:
- 定时时间确实不该加
- 这里的提交时间却应该加
提交时间本就是章节的一个属性,只不过之前,这个信息与创建时间共用。如今,因为定时提交的需求,二者应该分开了
难道不能直接用 submittedAt 去存储调度时间?
严格地说,不行。因为调度时间可能与具体提交时间有差异。
比如,因为某种原因,系统宕机了,启动后,调度任务执行,这时可能已经过了调度时间很久了,但这时提交章节,它的时间就不会是调度时间。
还记得为什么要做这个分析吗?
因为要改动核心实体,而这就是一个坏味道高发区。
总结
新需求到来时需要关注:
- 增加新接口
- 改动实体
接口和实体,也是一个系统对外界产生影响的重要部分,一个是对客户端提供能力,一个是产生持久化信息。所以,我们必须谨慎地思考它们的变动,它们也是坏味道产生的高发地带。
对于接口,我们对外提供得越少越好,而对于实体,必须仔细分析它们的定位。
谨慎地对待接口和实体的变化。