做产品开发时,我们经常跌落在一种无法打破的轮回中。
我们经常说,产品上线最重要,可以未来再重构代码, 但是结果大家都知道,产品上线以后重构工作就再没人提起了。首先,线上跑的好好的,动出问题怎么办?其次,后面有无数的新需求不断涌来,工程师应对这些新功能已经捉襟见肘,还要花很大一部分精力对现有系统的修修补补。所以, 重构的时候永远不会再有了。我们拆了东墙补西墙,循环往复,劳心劳神,互相扯皮,提桶跑路。
是什么原因导致这样的?
1. 程序员的实际工作
大部分程序员认为将需求文档转为实际的代码就是他们的全部工作,上面的领导者也只是关注你的需求是否得到正常实现,没有bug,而不会关注你的代码写的如此优雅。我亲身经历一个需求,当我说要额外花1周时间重构一下代码,新增一些自动化测试用例,被TL拒绝了,他认为BVT跑过了已经具备了上线条件就不要折腾了,提前上线吧,此后我再也没有写过自动化测试用例,手验几个就转测了。
我们所有人在不停的往前冲,即使是屎山堆屎,即使是自己实现的代码好比硬要把方螺丝拧到圆螺丝孔里面,究其原因无非是业务部门原本就是没有能力评估系统架构的重要程度的。
展开之前,我们先看看我们编程有哪些范式,也就是我们一直以来遵循的一种公认的编程模型或模式,然后从这种模型中探讨如何设计出优雅的组件,以及如何优雅地将多个组件聚合成一个成熟的架构。
2. 编程的三种模式
从编程发明开始至今,写代码的编程范式只有三种,从1958年提出的函数式编程,到1966年提出的面向对象编程,再到1968年提出的结构化编程。尽管我们的工具变了,硬件变了,但是软件编程的核心思想没有变,也就是约束我们的范式没有变,即计算机程序无一例外的由顺序结构,分支结构,循环机构和间接转移这几种行为组合而形成的,无可增加,无一例外。
2.1 结构化编程
结构化编程主要的价值是将一段程序递归降解为一系列可证明的小函数,然后再编写相关的测试来证明这些函数是否是错误的,如果这些测试用例无法证伪,那么我们就可以认为这些函数是足够正确的,进而推导出整个程序是正确的,这个解决方案是大名鼎鼎的结构程序设计之父Dijkstra提出来的,熟悉算法的朋友们应该对这个名字不陌生的,最短路径也是这位大神提出的。
2.2 面向对象编程
面向对象编程的主要价值是以多态为手段来对源代码中的依赖关系进行控制,这种能力能让我们可以构建出某种插件式架构,让高层策略组件和底层实现组件分离,底层组件可以被编译成插件,实现独立于高级组件的开发和部署,这就是我们常说的面向接口编程,即依赖倒转模式。
举个栗子,对于某种系统行为决定了控制流的软件架构,main函数调用函数A,函数A的具体实现又调用了B,这种情况下每个函数方必须要引用被调用方所在的模块,但有了多态就不一样了,我们抽象出一个中间的接口层<I>,函数A去直接依赖这个接口<I>,最底层的函数B去实现这个接口<I>,这样A和B就都可以单独部署,互不影响:
2.3 函数式编程
这个函数式编程不是我们熟知的那种将小功能封装成一个函数,而是一种编程理念,即变量被赋值后是不可变的。比如说for循环语句的循环变量i,它就是可变的,而函数式编程中,函数的值取决于函数的参数的值,不依赖于其他状态,比如abs(x)函数计算x的绝对值,只要x不变,无论何时调用、调用次数,最终的值都是一样。
其实,变量的可变性会导致很多问题,比如说多线程、多处理器下的死锁问题,资源竞争问题等等,如果没有可变变量的话这些都不可能发生了,不需要考虑加锁,编程更加简单,程序跑的飞起。
既然有了这些范式,那么如何构建出一个优雅的组件呢?发布一个组件应该遵循哪些原则呢?下面将会介绍Bob大叔(架构整洁之道就是Bob大叔写的)提出的SOLID原则。
3. 构建出优雅的组件
3.1 单一组件构建原则
什么是一个优雅的组件,大家应该都有自己的一套准则,但不外乎要满足下面几点:
- 使软件可容忍被改动
- 使软件更容易被理解
- 构建可在多个软件系统中复用的组件
SOLID原则其实是5个设计准则的首字母组合而成,分别是SRP单一职责,OCP开闭原则,LSP里氏替换,ISP接口隔离原则,DIP依赖反转,我来一一介绍一哈。
1.SRP单一职责:每个模块都应该只做一件事,确保每个函数只完成一个功能,这样就一个好处,就是避免了不同人为了不同的目的修改了 一份源代码,这很容易造成新问题,比如说一个组件会被两个业务团队修改,导致业务逻辑错误和代码合并的问题。
2.ISP接口隔离原则:在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害的,从源代码层次来说,这样的依赖关系会导致不必要的重新编译和重新部署,对更高层次的软件架构设计来说问题是类似的。
3.LSP里氏替换:使用子类和父类时,两者完全可以互相替换。也就是说,任何基类可以出现的地方,子类一定可以出现,我们不会感知这个是子类还是父类。有一个臭名昭著的例子,就是正方形类派生于长方形类,并且新增了一个设置边长的函数:
这个反例就说明了Square类与Rectangle无法替换,因为Rectangle类的高宽可以分别修改, Square则必须一同修改,由于 User始终认为自己在操作Rectangle会带来一些混淆,唯一办法就是在User 类中增加用于区分Rectangle和Square的检测逻辑,那这就会引入硬编码,更加灾难。
4.DIP依赖反转:高层次的代码应该多引用低层次的抽象接口,而不是具体实现,依赖反转有一个非常好的应用就是抽象工厂模式:本来是application使用service,那么依赖关系是application->service,现在变成了application -> 抽象service工厂<- 具体的service工厂 -> 具体的service,控制流发生了反转。
5.OCP开闭原则:增加新功能不应该改老功能的逻辑,一般可以用两个步骤进行实施,我们可以先将不同需求的代码分组(SRP) ,然后再来调整这些分组之间的依赖关系(DIP)。
3.2 组件发布原则
1.REP复用发布等同原则:软件复用的最小粒度应等同于其发布的最小粒度,也就是说如果想要复用某个软件组件的话,一般就必须要求组件的开发由某种发布流程来驱动,并且有明确的发布版本号和发布文档。这样一来,软件工程师才能在收到相关组件新版本发布的通知之后,依据该发布所变更的内容来决定是继续使用旧版本还是做些相应的升级。
2.CCP共同闭包原则:提示我们要将所有可能会被一起修改的类集中在一处,也就是说,如果两个类紧密相关,不管是源代码层面还是抽象理念层面,永远都会一起被修改,那么它们就应该被归属为同一个组件,通过遵守这个原则,我们就可以有效地降低因软件发布、验证及部署所带来的工作压力。
3.CRP共同复用原则:当我们决定要依赖某个组件,最好是实际需要依赖该组件中的每个类。换句话说,我们希望组件中的所有类是不能拆分的,即不应该出现别人只需要依赖它的某几个类而不需要其他类的情况。否则,后续就会浪费不少时间与精力来做不必要的组件部署。
总结一哈,我们发布的组件,一定是最小粒度,每一个可能被修改的类都应该放在一块,每一个容易修改的类也要放在一块,这样会降低发布和验证的压力,并且用户可以根据发布的版本号和文档选择是否需要升级。
我们现在有了各个组件,那么如何将多个组件如何构成系统?
4.系统构建原则
4.1 ADP无依赖环原则
组件之间依赖关系没有环形,如下组件依赖关系就是有环的,这会非常难受:
当Database组件的程序员需要发布新版本时,他们需要与 Entities 组件进行集成,但现在由于出现了循环依赖, Database组件就必须也要与 Authorizer 组件兼容,而 Authorizer组件又依赖于Interactors组件,这样一来, Database 组件的发布就会变得非常困难。
这还只是问题的冰山 角,请想象一下我们在测试 Entities 组件时会发生什么?情况会让人触目惊心,我们会发现自己必须将Authrizer,Interactors集成到一起测试, 这些组件之间的耦合度非常令人不安。
4.2 稳定依赖原则
依赖关系必须要指向更稳定的方向,任何一个我们预期会经常变更的组件都不应该被一个难于修改的组件所依赖,否则这个 变的组件也将会变得非常难以被修改。如何量化一个组件的稳定性呢?可以采用入度出度的比值,I= Fan-out /(Fan-in + Fan out),其中Fan-in为被依赖个数,Fan-out为依赖其他组件的个数。
该指标的范围是 [0,1], I=0意味着组件是最稳定的,I=1意味着组件是最不稳定。
如果一个系统中的所有组件都处于最高稳定性状态,那么系统就一定无法再进行变更了,这显然不是我们想要的,事实上,我们设计组件架构图的目的就是要决定应该让哪些组件稳定,让哪些组件不稳定。
4.3 解耦+决策延迟
良好的软件架构不是一开始就确定,比如要采用的框架,数据库,工具库,我们尽量将这些决策延后进行,也就是说最开始我们只做组件+用例+部署源码的解耦,但不强制规定各个组件之间的交互方式,该系统就可以随时根据不断变化的运行需求来转换成各种运行时的线程、进程或服务模型,简言蔽之,随着系统在开发、部署、运行各方面所面临的问题持续增加,我们应该挑选一下可以将哪些可部署单元转化为服务,并且逐渐将系统向这个方向转变,而随着时间的流逝,系统的运维需求可能又会降低。之前需要进行服务层次解耦的系统可能现在只要进行部署层次或源码层次的解耦就够了。这也就是我们常说的,拖着拖着,事就不用干了(hhhh)
4.4 可测试架构
一个好的架构是可测试的,系统架构的所有设计都应该围绕用例来展开,我们在运行测试的时候不应该运行Web服务,也不应该需要连接数据库,测试的应该只是一个简单的业务实体对象,没有任何与框架、数据库相关的依赖关系,总而言之,应该通过用例对象来调度业务实体对象,确保所有的测试都不需要依赖框架。
谦卑对象模式的设计目的是帮助单元测试的编写者区分容易测试的行为与难以测试的行为,并将它们隔离,其设计思路非常简单,就是将这两类行为拆分成两组模块或类,其中一组模块被称为谦卑组,包含了系统中所有难以测试的行为,而这些行为已经被简化到不能再简化了,另一组模块则包含了所有不属于谦卑对象的行为,例如, GUI 通常是很难进行单元测试的,因为让计算机自行检视屏内容,并检查指定元素是否出现是非常难的事情(奇林平台可以做这个事)。 然而, GUI中的大部分行为实际上是很容易被测试的,这时候,我们可以利用谦卑对象模式将 GUI 的这两种行为拆分成展示器与视图两部分。
最后以Kent Beck关于软件构建的建议作为结尾,他描述了软件构建过程中的三个阶段:
- "先让代码工作起来"一一如果代码不能工作作,就不能产生价值
- "然后再试图将它变好"一一通过对代码进行重构, 我们自己和其他人更好地理解代码,并能按照需求不断地修改代码
- "最后再试着让它运行得更快"一一按照性能提升的"需求"来重构代码