导读
做业务开发,比较常经历的问题就是因为时间压力较大,为了快速实现业务需求,对于自己写的代码没有很好地思考可扩展性、易读性、易维护性等。
我最近在看一些以前写的业务代码时,就发现有很多这样难以维护的例子,再想到以前有团队中的同学说感觉设计模式难以落地于实践,所以就想到在实际工作中使用设计模式改造这些代码。
本系列的文章将会结合经典的设计模式,以案例的形式来呈现代码的改造过程,每一次改造都会使用一到多个设计模式,希望可以给到读者一些经验的分享。
在使用这些设计模式的时候,我希望和大家一起思考这个设计模式背后的设计思想,而不是为了教条般地使用设计模式而使用,所以有时对这些模式的使用方式可能会出现一些和定义有差异的“变种”,从这个角度上来说,我更看重对设计模式思想的借鉴。
如果对于我的用法有不同意见,或者有更好的建议,也欢迎直接通过订阅号对话框和我联系,互相交流。
问题代码
我们来分析本系列的第一个案例。
图1:圈复杂度和认知复杂度较高的案例1
如图所示,这段代码的圈复杂度比较高,达到了12,认知复杂度也达到了32。
一般来说一段代码的圈复杂度最高不应超过10,认知复杂度最好也控制在15以下。复杂度较高说明这段代码的可维护性、可读性较差,可测性也较低,改动起来就容易出错,导致系统稳定性的降低。
复杂度过高最直观的表现,就是代码中if-else或者switch-case较多。我们有一些手段来降低这个复杂度,常见的包括使用卫语句、提炼函数、合并/分解条件表达式、多态等。
图1中充斥了很多的if-else,理解起来比较费劲,这段逻辑核心做的事情,其实就是根据用户的不同身份,来设置不同的信息,用户身份比较多样,并且需要使用多种方式查询。
我们可以看到,这段代码的可维护性和可读性都比较差了,如果一个不太熟悉的人基于这段代码再做修改,就很有可能会出问题。
接下来就是具体的改造过程了。
逻辑梳理
我们先对这段代码的核心逻辑进行梳理,逻辑图如下:
图2:案例1逻辑流程图
其主要逻辑分支为4段,如图所示的1、2、3、4,每个分支逻辑基本都有一些内置的逻辑判断,所以导致整体代码复杂度较高。
4段逻辑中,1和2、1和3是互斥的,但是2和3是有可能共存的(用户同时有两种用户身份),逻辑4是当前三段逻辑都为非时,才命中。
在这段代码背后的业务场景中,我们也有逻辑间的优先级偏好,即按照客户的重要程度来排布优先级,因此我们目前设计逻辑流转顺序为1 → 2 → 3 → 4。
设计模式选择 - 状态模式
选取设计模式进行重构时,有两个相似的设计模式可以选:责任链模式和状态模式。
这两个模式的定义这里不谈,不了解的可以在网上搜索。这两个设计模式最大的差别如下:
1、状态模式是让各个状态对象自己知道其下一个处理的对象是谁,即在编译时便设定。相当于If 、else-if、else-if …… 设计思路是把逻辑判断转移到各个State类的内部实现(相当于If、else If),执行时客户端通过调用环境—Context类的方法来间接执行状态类的行为,客户端不直接和状态交互。
2、职责链模式中的各个对象并不指定其下一个处理的对象到底是谁,只有在客户端才设定某个类型的链条,请求发出后穿越链条,直到被某个职责类处理或者链条结束。本质相当于swich-case,设计思路是把各个业务逻辑判断封装到不同职责类,且携带下一个职责的对应引用,但不像状态模式那样需要明确知道这个引用指向谁,而是在环境类设置链接方式或者过程。使用时,向链的第一个子类的执行方法传递参数就可以。客户端去通过环境类调用责任链,全自动运转起来。
很多情况下其实两种设计模式都可以选择,我们当前case主要是对if-else的改造,并且上面提到4段逻辑其实有一定的依存和感知,逻辑间也有一定的优先级倾向,我们期望以一个固定的顺序来执行,没有灵活调整和自由组合节点顺序的诉求(甚至不希望灵活调整导致出错),我们希望每一段逻辑都清楚下一个处理逻辑是什么,因此当前的案例我偏向于选择状态模式。
因此选择好设计模式之后,我们将图1中的这段逻辑进行改造,改造后的UML图如下:
图3:状态模式改造后的UML图
如图3所示,我们将图2中的4段逻辑封装在State接口的4个子类中。子类之间识别流转到下一个状态的时机,比如起始状态类OrgAccountState中涉及到状态转移的核心逻辑如下:
图4:状态类中的状态流转逻辑
改造后在主流程中代码变成下面红框中的一段,和本文第一张截图中的复杂代码有了很大的区别,完成了代码优化:
图5:改造之后的主流程代码
经过改造之后,我们可以来看看发生了什么好的变化:
1、将原先杂乱在一起的一堆if-else按照业务逻辑的划分,切分到了4个子类中,每一段逻辑内聚在一个类里,改动其中一个的逻辑不用担心影响到其他逻辑。这是可维护性和可读性的提高。
2、对于每一种用户类型的处理逻辑,我们可以更方便地编写单元测试进行独立测试了,这是可测性的提高。
3、如果我们有新的用户类型加入,我们可以独立编写易测试的子类,修改上游节点的流转逻辑,就能设置新的逻辑流转链路,这是可扩展性的提升。
4、把这段复杂的分支逻辑从主代码流程中(见图5)切割了出来,使得我们主方法的代码行数也有效降低到一个更易维护的行数(从200行降低到了50行)。
不过也有一些缺点:
1、类的数量变多了,为了这个改造,我们新建了6个类,并且如果我们要新增用户类型,那这里的类还会常数级地变多。
2、状态类之间相互耦合,将逻辑的流转“写死”在了状态类中。
相比较缺点来说,优点更具吸引力,而且在当前的场景中,我们可以预料到用户类型是少量的,在这种情况下,并不会造成大量状态类的出现,而且因为整体逻辑的稳定,我们也没有频繁更改图2中4段业务逻辑流转顺序的诉求,因此这两个缺点在这个场景下是可以被接受的。
如果我们在其他场景下使用状态模式,要特别注意上面提到的两个缺点,合理地选择是否要使用状态模式。
建造者模式
在上一节里,我们还同时使用了建造者模式,用来构造UserInfoContext对象。UserInfoContext承担状态机流转的作用,源码如下:
图6:建造者模式创建UserInfoContext
采用了建造者模式的原因如下:
1、在构造UserInfoContext对象时,单独设置某个参数没有意义,三个参数都必须要设置,且state需要设置为起始状态OrgAccountState,也就是在这种情况下,我们在构造时对参数的完整性有一些限制,因此我们需要一口气把这个对象构造正确。采用建造者模式,则保证了这个过程。
2、我们希望将UserInfoContext创造成一个不可变对象,在它创建好之后,不要再修改userId和result参数,所以我们不希望暴露setters。
实际上,要达成上面的2个目的,在本文的case中,也可以不采用建造者设计模式,使用构造函数一样可以实现,源码如下:
图7:UserInfoContext不使用建造者模式
现在这个类的成员变量只有3个,所以采用构造函数来做反而更简洁,但是如果需要设置的属性逐渐增多,变成了8个、10个甚至更多,我们如果仍然想要保证构造这个类实例时参数值的完整性,那继续使用这种设计思路的话,构造函数的参数列表就会很长,代码的可读性和易用性会变差。并且如果这些参数中有一些同类型的,那还可能导致传错顺序,出现一些隐蔽的bug。建造者模式的出现,也就是为了解决这些问题。
所以在当前这个案例中,使用建造者模式还是有一些过度设计的,不过带来的成本也不高,所以这里为了演示设计模式的落地,我就使用了建造者模式来作为示例。
另外对于建造者模式,我们还有一种场景会选用,那就是类成员变量之间有约束上的依赖,拿UserInfoContext类举例,如果userId == null的情况下允许result == null,那这种校验就可以封装在Builder的build()方法里。
建造者模式能解决的问题,构造函数基本都可以解决,除了构造函数参数数量过多导致的易读性和可靠性问题,而这也是我们对它们俩在实际落地中选择哪种方式的考虑因素。
本文回顾
本文带大家分析了一个业务开发中的实际案例,选用了设计模式中的状态模式和建造者模式来改造我们的代码,将代码改造得更可维护、可测、可读、可扩展。
状态模式是一种行为类的设计模式,其定义为“允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类”。在我看来,它不一定要用在一些强调“状态变更”的业务场景中,对于一些流程编排+职责分离的业务场景,它也可以起到不错的作用,不过要注意规避它的缺点,如果在某些场景下,它的缺点会被放大,那可能就得权衡一下了。
设计模式不是银弹,在实践中也不一定要完全遵循“教条”,设计模式最重要的,是它背后所反映的设计理念。在具体的业务场景中灵活地选择、使用与折中,才是通向其精髓的道路。对于我来说,不敢在这条路上自称精通,只敢说刚刚入门,一些粗浅的经验与大家分享,愿我们一起求索,学习之路无止境。