重构是一个非常常见且古老的课题,涉及重构的文章、书更是不可胜数。
但其实做程序做久了就会知道,想把一个复杂的系统做好,尤其是参与人数较多的中大型项目,靠看几本设计模式的书,去试图寻找设计模式的奥秘,其实是不够的。很多时候,看书时觉得很有道理,例子也能理解,但到实际开发时,却无从下手,不知道怎么灵活套用。
很多项目,在持续的版本迭代中,还伴随着人员的更替过程,往往为了解决眼前的需求,最常见的就是直接复制类似的逻辑,或者就是在末尾追加逻辑。同时,受限于对老版本的需求理解,很容易出现新需求覆盖老需求的参数值,无意识的更改老版本结果等。那么倘若系统的隔离性做的不好,则极易产生A功能的改动,影响BCD等一大堆功能的正确性。
以京东App的后台为例,就是一个典型的复杂系统,涉及开发人员众多,模块巨多,迭代时间很长(已近10年),很多业务逻辑已无从考证,开发人员也已经换了好几轮,那么对于这样的系统,如何让开发人员做的需求、功能隔离开,互不影响,各小模块又能各自健壮、系统又具备相当的扩展性、配置化率又高(仅通过配置即可完成功能的变更),就成了一个不得不深入考虑的问题。
本篇主要是我在实际工作中,对一个复杂系统做了一些改造,并从中总结的一些经验,做的分享。
近期京东App后台核心模块发生了较大的逻辑改动,主要原因是新增了一些接入来源,从之前的独苗京东App,到后来的京东PC站、京东极速版、老年版、小程序等等,都接入了原App后台。通过完成统一接入,避免了多个后台共存,重复开发的问题。
而这些不同的来源,逻辑就有较多不同。
有的来源需要执行逻辑ABC,有的来源需要执行BCD,有的只需要执行BC,并且不同的来源返回值也有所不同,这就对之前的单一来源的系统架构产生了较大的冲击,如果处理不当,则不可避免地出现大量的if-else逻辑,以及扩展上的混乱。
那么针对这种情况,以及对提高系统整体配置化率的诉求,我们对后台架构做了一次重构。本文就是对重构内容做的一个浓缩后的抽象讲解,线上实战性质,非单纯设计模式类的demo。
如下图,我尽可能简化了细节,和小模块内的逻辑,仅保留了最外层的大模块。
我们来看背景,之前只有主App客户端来源的请求,譬如refer=1,该请求到达后,需要触发"运费"、"优惠券"等数十个上游rpc调用,之后聚合各上游系统结果,返回给客户端对应结果。
现在新增的接入方,譬如"老年版",refer=5,就删减了组件层一些复杂的促销逻辑、凑单、白条之类,结果层也有相应删改。我们该如何支撑这种可能随时增删改模块和来源的业务架构呢?
当多个层级均出现了多个变量时,这个系统的逻辑就变成了一个复杂的排列组合问题。
我们假如用户的入参是User对象,里面有一个字段refer表明了来源。
在重构前,代码进入主流程后,如下图,就是简单地根据refer来决定是否走哪些模块、返回哪些参数。
图中仅作为示例,实际情况,每个 fetch模块都有数千数万行代码,逻辑之复杂,各种if、else运用之嵌套,各种与或非使用之犀利,实属鲁班再世,也要夸赞几句鬼斧神工的。
大家都能看出来的问题,就是业务模块与入参变量的强耦合,如果入参refer=1,则执行业务模块A、B、C,不执行E、F、G,且返回值包含X、Y、Z,不包含U、V、W。
这样的设计不可避免带来了极大的维护的麻烦与混乱,到处都在判断是否是它,是他还是她?
那么该如何隔离层级,解耦模块与来源、来源与返回值之间的关联呢。
上面提到了最大的问题,我们用通俗的话来讲就是:如果是A,我就做A1、A2、A3;如果是B,我就做A2,A3,A4。
那么问题就是这个主逻辑器做了太多的事情,日后被修改的概率极大,每个逻辑变动,都会导致主逻辑器的改动。
我们主要优化的点就是将这个逻辑给去掉,让主逻辑器职责单一,每个业务单元也职责单一。将上面的逻辑变成如果是A1,则来源是A时我工作,是B时我不工作,如果是A2,则来源A、B我都工作,如果是A4,则来源A我不工作,来源B我工作。
可以看到,做的事情就是当有一堆条件判断,要决定执行N个逻辑中的M个时,调用者不应该关心调用逻辑,而应由这N个逻辑自行判断自己要不要执行。
从代码实现来看,就是调用者不关心有多少个逻辑块,也不必关心日后的增减,从而实现调用的解耦。那么代码该如何写呢?
原来的是在一个方法里,fetchStock,fetchDiscount等等,首先我们要把这些实现全部去除,并统一为对接口的遍历。
改造后的代码是这样:
代码很简单,注入一个接口的集合,并遍历这个集合,根据实现类返回的true、false决定是否要执行这个实现类的业务逻辑。
接口定义如下:
单个逻辑单元代码如下:
以上主流程的逻辑很清晰,后续随着各模块的扩展或缩减,都不需要动主逻辑,而只需要各个子模块根据自己的情况返回是否要执行自己即可。
通过以上的改造,我们已经完成了模块间的隔离,当有新增、删减模块时,可以做到不影响主流程,且将代码修改、影响范围控制在一个类里。
但是需求的变化总是很频繁,仅仅做到互不影响还不满足需求,我们还需要做到能够动态的控制各个模块的启用和关闭。
譬如『如果是A,我就做A1、A2、A3;如果是B,我就做A2,A3,A4』。希望能做到随时仅通过修改配置,不改代码不重新发布而做到『如果是A,我就做A1、A2;如果是B,我就做A3,A4』,完成对模块的启停。
动态配置该如何实现呢?
其实很简单,我们只需要修改execute方法,将refer==1这种规则存放于配置中心,将execute方法里的硬编码判断变成根据配置中心的配置进行判断即可。如下图:
那么ConfigCenter就是配置中心工具类,里面提供了根据key获取value的方法。配置中心大抵如注释所描述的,在应用启动时,全量从zk、etcd等拉取配置并保存在本地内存,并开启监听,当配置中心内容有变化时,更新到本地内存里。
通过观察各个模块的execute类,可以进一步发现,倘若配置中心里我们将类名作为key,模块所支持的refer集合为value时,各模块的execute方法就是完全一样的代码。那么整个方法又可以进一步抽成一个抽象类,由该抽象类来完成这个判断逻辑,如图:
当有了统一完成"开关"的父类后,则各个模块的逻辑单元就更加简单了,只需要关注自己的业务逻辑即可。
至此,最小业务单元职责则回归到纯粹的业务逻辑,不再参与流程控制的逻辑判断。同理,主流程也不再参与对各个子业务单元的判断和控制,只关注于对接口的遍历,各模块也不再产生相互影响。
以上我们完成了业务逻辑单元的隔离,那么对于结果层该如果控制呢?
原始代码是这样的,在主流程中对各个变量进行判断,然后设置结果的值。从原始代码可以看到,即便只有一个变量refer就已经让代码可维护性变的很差,更别提真实场景下变量可能有多个时,要维护不同变量场景下返回不同的参数该多少困难。
通过对上面业务层控制的实现,我们同样可以采用类似的方式来处理结果层。
定义一个接口如下,定义boolean型方法,让各字段决定自己要不要返回。定义key、value,用来存放字段名和value。
修改主流程如下,当需要返回时,才能待返回字段的key、value存起来。
实现类如下:
以上方式展示了对结果层进行精细化控制的简单方案,实际场景中,可能涉及结果层数据结构并不是单层、对key、value的判断需要额外的属性等,其实思路都是一样的。
如果要增加入参的判断,在接口的needOut里追加要参加逻辑判断的入参即可。如果返回的结构不是单层的key-value,则在复合结构的实现类里再嵌套一层新定义的接口的遍历也可。
长逻辑这种最常见,也是最好处理的。我们经常在写一处逻辑时,刚开始很简单,几行就解决了,后来随着业务越来越膨胀,这个方法也是越来越长。终于有一天,代码长到显示器装不下它了,后面的逻辑开始出现对前面的逻辑产生影响了,这个方法就开始变的有"坏味道"。
这种相信大家都不少见,尤其在老系统中,从1千到8千行的我都见过,编辑器右边的滚动条都要看不到了。当然仅仅是长倒还好说,主要的问题是相互影响,前面赋的值,后面就被覆盖了,这种问题往往还比较隐蔽,极其影响系统的健康。
解决这种长逻辑,其实很简单,做好两件事即可,1-将方法的顺序执行变成接口遍历,2-封装。
1 如何将方法的顺序执行变成遍历呢?
这个其实在上面已经讲了,是类似的做法。将一大堆在同一个大方法里的小方法全部变成某个接口的实现类,从而将方法的顺序执行,变成对接口集合的遍历执行。后续增加或删减方法时,只操作对应的类即可,而不需要对这个大流程做修改。
一个单独的实现类就是这样的:
可能有人会问,我的各个方法是有先后顺序的,你用了接口集合,该怎么控制顺序呢,从上图的Order注解可以看到,这个就是控制在接口实现类的顺序的,值越小,在List里越靠前。
2 如何理解封装呢?
这个更简单,之前不是说代码长了易出现值被后面的逻辑覆盖,那么就以某个最小参数为一个类,所有对他的增删改都控制在一个类,完成对某参数、对象的封装控制。而不要散落各地去修改一个参数的值。
最后一个问题,如果带有流程中断的情况。如图,一个长逻辑,在某些条件被中断了,中断后后面的逻辑自然是走不到了。那么之前的对接口集合遍历方式还能用吗?
自然是可以用的,不过就要稍加改造,让实现类的方法返回一个boolean值,当false时,中断这个循环流程即可,这样后面的逻辑就走不到了。
如果不是要中断,而是某条件下执行自己,某条件下跳过自己,这个就往上看看文章的第4段。
本文通过一些例子,描述了一些场景下对系统的改造方式,由于京东APP后台逻辑复杂,以上场景仅覆盖了部分典型场景,未全部写出改造点,当然还有一部分是特有的非典型问题,可能大部分用户碰不到的场景,也未写出。
如果有遇到类似场景,可参考文中的一些方式进行处理。
作者:平台研发武伟峰