炼金术(7): 何以解忧,唯有重构

很多时候,把代码梳理一遍,把逻辑写正确,把依赖关系理顺,BUG就不见了。一个Bugly的遗留系统,只有彻底的重构,让程序首先处于「良构」状态,才可以正常的开发、维护和发版本。其中有一个本质的问题,就是让代码实现「高内聚、低耦合」。下面是我的重构笔记。

干活

我发现我原来习以为常的编程习惯,我一开始就不会写出这种乱七八糟耦合的问题,所以有很长一段时间以来我都感觉不到写代码要注意「高内聚、低耦合」问题了。可是这次重构,让我又看到了那些意大利面条代码是怎么回事,而要拆开它们,一步步解除耦合,重新把这些代码写到「正常」,我才又「感觉」到写代码需要「高内聚、低耦合」这件事,对很多人来说是需要经过学习和练习的。

这次重构再一次证明了「全局变量是万恶之源」,这个人用JavaScript写了很多类,但是呢,每个模块里都返回了这个类的一个「假单例」,进一步又「向上」「向下」,在上下两层都是用了这个虚假的单例,导致两边的内部都严重耦合这些「类的实例」,也就等价于直接使用了一堆的全局变量。更恶劣的是,这些类的成员变量是直接暴露,到处赋值,把所有变量都暴露在「没有任何封装和保护」下的「任意修改」。

我这几天简直就是反复在一层一层重构:

  1. 解除双向耦合,层跟层之间只能是 A<----B<---C<----D 这种单向依赖,而不能互相依赖。程序里的层跟层之间,要做到单向依赖,就能让流程清晰,构架合理。
  2. 所有的变量修改「封装」到类内部,全部通过方法来修改。在这个基础上,内部变量的修改,在内部状态机里面做保护。
  3. 仔细、彻底清理几个重要的有限状态机(Finite State Machine),画出状态转换的完整状态转换图,内部必须有enterState转换方法保护,任何错误转换都直接报错。我觉的这是直接体现「编程」是什么的地方,不懂有限状态机,就不是真正的编程。我看到很多定义了一堆状态,但是状态之间是可以随意跳转的代码,这种都是Bugly的根源。
  4. 收缩一个类状态被修改的点。一个类定义了一组方法和属性,只应该在某个场合下被使用,所有使用了这个类的地方,如果不是尽量控制在狭小的范围,那么状态修改就在扩散,这些分散不但让状态的变化难以被理解,也不利于维护。一步步收缩范围,根据「相关性」逐渐分析,哪些逻辑应该集中在某个地方管理。
  5. 函数里的逻辑,不应该是一堆看不出干什么的代码构成。而应该尽量由一组一眼就看的清楚的函数调用构成,如果不是,那么就需要重构这部分逻辑,让它们在合适的地方组成一个合适的,功能明确的函数。
  6. 分离不同进程的类到不同的文件夹。每个进程只应该使用自己进程里的类,否则,你会遇到诸如「这个变量我明明修改了,怎么就是不对呢」的问题,因为你修改的和你读取的根本就是两个不同进程的变量,虽然看上去是「同一个类」,如果你有多线程代码,也是类似。明确每个类属于哪个进程。用含义明确的文件夹物理分离它们。每个类只应该被一个进程使用,除非它是一个没有状态的工具类。这也进一步说明了不要使用全局变量,一不小心,你就在两个进程内使用了「同一个变量」的属于两个进程的副本。不要给自己制造这种混淆的机会。
  7. 如何解除 A<--->B 这种耦合呢?虽然我是在JavaScript里写代码,我还是会思考什么时候使用「接口」,什么时候使用「函数」来解除耦合的问题。许年年来,基于面向对象的设计模式,都在告诉你要面向接口来解除耦合,真的是这样的吗?

很久以来,我都已经 忘记了要写一个接口了,因为动态语言里并不需要什么直接的接口。我认真思考了下,如果一个类确实有可能含有多种不同的相似的子类型,这个时候继承是很自然的,例如,B1,B2,B3继承B。此时AB的依赖,B可以是一个抽象类,也可以就是一个接口IB,这没有什么区别。反之,B也可以对IA依赖。由此设计模式一个系列基本上就是在说这件事。

但是,我可以不用接口实现解除耦合么?合理设计回调函数就可以做到。例如:

B.xxxxx(params, onXXXX, onYYYY)

只要B的函数参数里定义好合适的回调函数,那么我并不需要B内部调用任何A的方法,A如果要把自己逻辑混进Bxxxxx方法的逻辑里,只要使用B的时候,处理这些回调就可以:

b.xxxxx(params,(...)=>{
    这里加入A的逻辑
},(...)=>{
    这里加入A的逻辑
});

这个时候,B如果要做到通用,就是尽量设计好合适的参数和回调。

进一步,你可能会在A的内部使用B。这样B虽然解除了对A的依赖,但是AB的依赖还是在,那么,应该怎样进一步解除这种耦合呢?一种抽象方法如是有效的,那就反复使用它:

A.yyyyy(params, onXXXX, onYYYY);

这个时候,把A的逻辑和B的逻辑绑定在一起就是更外层的「责任」,AB负责「提供机制」,外层,例如C负责「使用策略」,从而做到「机制和策略的分离」

C:

a ,b;

a.yyyyy(params, (...)=>{
  // 其他逻辑,例如加入c的逻辑
  b.xxxxx(prams,(.....)=>{
      // 加入A的逻辑
  }, (...)=>{
      // 加入A的逻辑
  }
}, (...)=>{
  // 其他逻辑,例如加入c的逻辑
});

这当然可能引起「回调嵌套地狱」,在许多情况下,可以使用语言层提供的async/await来让代码更清晰一些。但是async/await并不是回调的完备替代品,它只能让单出口的异步回调变成「伪同步」代码。例如:

xxx((ret)=>{
    zzzz(ret)
});

变成:

let ret = await xxxx();
zzzz(ret);

但是这种能力它就比较啰嗦

xxxx((ret)=>{
   zzzz(ret);  
},(ret)=>{
   yyyy(ret);
}); 

要处理这种多出口的回调,如果xxx内部要么在第1个回调结束,要么在第2个回调结束,那可以通过返回值判断要怎么处理:

let {err,ret} = await xxxxx();
if(err){
   zzzz(ret);
}else{
   yyyy(ret);
}

但是,如果xxxx内部在第1个回调之后,也可能再次调用第2个回调。或者任何一个回调会调用多次。这个时候把xxxx函数变成不带回调的async函数,逻辑会变的复杂,甚至不可能。

总之,这是题外话。我的核心要说明的是,通过在函数参数和回调的设计,就可以解除A<---->B这种依赖关系。并且让C在调用地方的代码「一眼就看出来AB之间如何协同工作完成任务」,这点是我考虑很多代码应该写在哪里的关键。

那就是,一个函数应该是:

run(); // 内部完成了神秘的任务

还是应该是:

if(a.init()){
   a.xxxx();
   a.zzzz();
};

更好呢?我认为,至少应该在xxxx函数的上一层调用地方,在那个粒度提供直观的这个「程序在干什么」的直观逻辑。

我认为接口的解藕,在于有同一个接口有多个不同的场景,但是相似子类的时候。而如果不是,那么「高阶函数」的组合就是更好的选择。这个更好是类似「如无必要,务增实体」这类的思想,或者说「奥姆卡剃刀」原理。

以上就是重构的几点感受,在重构项目中,也有助于我们理解构架是什么,因为为了让项目达到「良构」,我们必须理解很多「为什么」。

思辨

我在软件行业干这么久,有一个问题就是,人们经常看到某个吐槽某个术语的一个方面的描述,就把那个术语看的一文不值,其实不是这样的,某个术语是有其存在的假设条件。而人们之所以会吐槽,是因为这些东西并没有可以客观衡量东西,即不像数学是严格的演绎系统,也不像科学严格做随机双盲,而很多时候是在创造和涂鸦之间的大量主观行为,所以就总是有人做的其实是错的,有人做的其实对的,人们吐槽的其实是那些并不真正理解概念,以及在其上做最佳实践的那些现象。当然,其实我们是可以用一些指标来衡量你的工作是使得软件更稳定运行了,提供了更好的质量了,还是变的更不稳定,变的更差了。

比如我这次改代码,我其实不用标记说这是「重构」、其实我可以只是说「这程序现在没法正常干活,里面的状态机工作并不正确,我要把这个状态机改到正确,能稳定运行,同时便于出现问题时诊断,有良好的构架文档方便其他人阅读和查找,最好不要再来找我麻烦」。

所以,不要看「标签」是什么,而要看做对了没,例如如果像题图那样,对太阳系这样稳定运行的系统出手重构...

--end--

posted @ 2020-03-26 11:24  ffl  阅读(...)  评论(...编辑  收藏