从扇入扇出浅谈SOLID五大原则
何谓扇入扇出
扇入(fan-in):是指直接调用该模块的上级模块的个数
扇入越大,表示该模块被更多的上级模块共享。这当然是我们所希望的。但是不能为了获得高扇入而不惜代价,例如把彼此无关的功能凑在一起构成一个模块,虽然扇入数高了,但这样的模块内聚程度必然低。有违SOC(关注点分离)原则。
扇出(fan-out):是指该模块直接调用的下级模块的个数
扇出大表示模块的复杂度高,需要控制和协调过多的下级模块;但扇出过小(例如总是1)也不好。扇出过大一般是因为缺乏中间层次,应该适当增加中间层次的模块。扇出太小时可以把下级模块进一步分解成若干个子功能模块,或者合并到它的上级模块中去。
以上两个术语常见于通信、数字电路等学科,软件工程上我们通常会用依赖来阐述相关概念,不过无妨本文我要表达的内容。在程序设计中,我们通常面临各种架构、设计模式,纷繁缭乱。我们不妨暂时撇开具体业务场景的架构设计,而从程序运行的机制,比如过程调用的角度来去抽象出一个”通式“,见下图:
学过UML的朋友不难理解,上图中的符号语义,1…*表示1对多(反之是多对1);圆圈⭕表示该组件/模块对外提供的接口;半圆⌒形似”抓手“的符号表示调用某接口。那么以上UML图,无论在编程中用了什么设计模式、什么组织架构,程序运行机制不出其外。以任一组件为基准(上图的核心组件)来说,无外乎谁依赖调用我,我依赖调用了谁。因此,若把上述形式结构的模型想象成一个乐高积木的基本组件而标准化,当有若干个以上的组件进行可插拔拼接,在更大层级上是不是就形成了一套具有同样形式结构的”大组件“(乐高搭建的汽车、宫殿……用程序术语我们叫他应用上下文Application Context)?这就是聚合(Aggregation)的力量!在每个层级上都由更小的部件构建而形成一个这个层级的应用上下文,同时自身又可作为继续向上构成更大级别上下文的小部件,这就是递归得力量!所谓,”其大无外,其小无内“,小到每个函数例程单元(甚至是变量),大到应用架构设计,同构,分形。
SOLID原则
- SRP 单一职责原则
- OCP 开放封闭原则
- LSP 里氏代换原则
- ISP 接口隔离原则
- DIP 依赖倒置原则
简要罗列一下五大原则,帮助大家回忆对应,具体含义相关著述、博文一搜一大把,这里不再赘述,下面我们从以上扇入/扇出模型的角度来逐步推出、理解这五大原则。、
SRP
一个对外暴露提供服务的接口,要最大化的发挥其复用效益必然期望扇入尽可能大。那么这种高扇入必然会要求该接口具有足够的稳定性、一致性!(试想一个高扇入的组件,就像地基一样,所谓”基础不牢,地动山摇“,因为其牵一发而动全身)所谓稳定性,以函数为例,它对外提供的函数签名其实就是接口协议,即我要接收什么类型、数目的入参,我会承诺给你需要的出参。当然无参出入的也要从函数签名上体现其语义,并且保持行为一致。一致性在函数层面上,就是要求它要无副作用,即幂等性,谁来调用只要遵守本函数签名的规则,我保证完成功能而且一视同仁。因此,为了达成这样的效果,就尽量不要让函数内部的实现逻辑庞大、复杂,尽量要让它运行的事务保持原子性。至此,SRP隆重出场,一个组件应该有且仅有一个改变的理由。如此,它便为高扇出做了铺垫。
LSP
一个处于中上层的应用上下文,它是基于更多底层例程单元而封装的,它本身已经具备一定的规模(Scale)和复杂度(Complexity),问题就自然诞生了,扇出,依赖别人,不可避免会带来风险;但工程中涉及子工程项很多,我们的资源和精力有限,总要”工程外包“,就算总包它也得分包吧?因此高扇出模型,就要求分散风险,职责清晰,出了问题好定位,分工明确、责任清晰再次体现SRP的重要性。那么”分包单位“要是绑死在该工程上,一旦出现调皮捣蛋、质量堪忧的顽劣分子,搞坏了、罢工了,怎么办?工程停工,让木桶理论来救场显然绝对不行,必须要随时能更替。这就引出来LSP,即派生类要能完全替代基类。这里啰嗦一句,好多朋友把extend只是理解为继承,其实不确切,extend英文意思是延续,扩展,我们国文也常说”继承发扬“,继承是为了发展,一定要领会到这一层。LSP讲的是类的派生,不过无妨也不用纠结,重要的是思想。而要做到派生类无条件替换子类的依据是什么?是不是再次回到了”接口要统一稳定“这一要求上?!回到函数的举例上,联系我们常用的编程技巧,不就是重载(overload)、重写(override)、包装(wrapper),往设计模式上说,所谓的代理模式(尤其静态代理)、装饰器模式,不就是以增强扩展原函数的功能而不修改它,在包装函数中调用原有函数再附加逻辑嘛。当你扩展了功能又同时维持接口不变,里氏替换的使命就完成了。可以想象一下汽车拉力赛,不同路段要更换不同材质的轮胎,但能作更换的前提得以轮毂(接口)统一作前提吧?在此基础上更换不同橡胶材质得外胎、负值轮毂、加装防滑链等(我也不懂,意思达到即可)。
OCP
谈及修改和扩展,OCP隆重出场。首先要强调一点,不要小瞧CURD,因为CURD无处不在,有机会再谈这个问题。单说U(Update),一定要明确,修改本质上不是一项原子事务。修改=擦除+添加,至于数据库update操作是原子事务,那是因为数据库层面帮我们做了事务处理。因此,我们更换组件,一定尽量避免在源码上涂抹修改,因为或许这个组件的扇入很多,一旦修改完蛋不能回退,上层大厦就会坍塌。而是充分考虑扇入,遵循接口协议,对现有组件功能进行扩展升级后更替。这就是对修改关闭,对扩展开放。以上几个设计原则的出场,似乎已经很完满的解决了大部分程序设计的问题。但请思考一个问题:以Java语言的方法来说,为了减少或最小化对调用方的影响,无论怎样替换,如果重载它,则需要通过入参来区分重载方法,方法签名还是变化了;如果保持方法名不变,方法体也需要复写,还是修改了源文件的代码,一旦以后要回退版本,再手动去将源码改回去?这与OCP矛盾啊。这就是”源码(正向)依赖“的问题。想想上文更换赛车轮胎的例子,想想更换持久层数据库的问题,没错,我们需要更深一层的抽象接口。
DIP
要让调用方依赖抽象,而不是具体的实现源码。这里的抽象,不局限于语言层面的关键字,比如Java里的abstract和interface,C++里的virtual等,而是一个抽象概念模型。业界有一种玩笑的说法,“没有什么是加一个中间件解决不了的,如果有那就两个”,为了将两个正向源码依赖的紧耦合模块解耦,如果我们仅仅在前述可插拔模型中间插入一个模块,似乎无济于事,原来是caller->callee,加入中间件后变为caller->middleware->callee,业务依赖关系和源码依赖次序一致,依然都是正向的,紧耦合的。既然整体的业务流向是调用方调用服务方,而调用方caller关心谁来提供服务么?不,它只关心功能,关心承诺!牢记这一点,就明白了interface的真正含义。同样,被调用方callee关心提供的服务、数据上层怎么用吗?不,它也不关心,它只关心我要干啥活,完成服务。而这两点都统摄于接口——契约、协议。此时,接口作为抽象组件,它就反向代理了服务方;可毕竟它是一个华而不实的抽象组件,它没有实体,有完成服务能力的实体,在拦腰截断解耦后,又不知道自己要干啥。是时候请出DIP原则啦!如果在源码依赖上,让callee依赖接口类/函数,它的函数体就知道自己要做啥了,源码依赖反转(倒置)了,此时变为caller->middleware(interface)<-callee,这就不一样了。业务调用关系依然是正向的,但是源码通过反转使得组件解耦了,在不影响上层组件源代码的情况下,可以偷天换日的拿掉一组实现体,换成另一组,而上层丝毫不知觉。两套或更多下层实现组件都得已保存,在需要时进行更替,而不动旧有代码。DIP之所以能称之为原则列入SOLID,而不是23种设计模式的某一种,在我看来,其一是多数设计模式都是在不自觉的应用这一原则,其立足点就高于具化的模式而润物细无声;更重要的是,它在程序架构设计上,也是具有革命性的。正像“不是我们的认识要符合对象,而是对象要符合我们的认识”一样,这就是康德的素有“哥白尼式革命”之称的《纯粹理性批判》带来的全新认知视角。
ISP
最后再思考一个问题,高扇入是每个组件的美好期望,因为调用依赖它的人越多,越发彰显其自身的复用价值。我们前文谈及了高扇入对核心组件的稳定性、和它自身一致性的要求,却忽略了调用方的需求一致性的问题。尤其是DIP之后,一个抽象接口作为中间件加入进来,而在我们设计接口类时,如若没有严格作为服务代理听命于调用方的需求,而盲目的、自作主张的对外承诺过多职责(就像贪得无厌的承包商八方”揽活“),而导致该接口的高扇入,客观上就造成了该接口的调用方需求不一致了。带来的问题就是,接口污染,诸多实现类都要被迫实现这一集中契约,有些于己风马牛不相及。不仅仅是编码冗余的问题,一旦接口有变,殃及无辜的事就发生了,其服务质量必然下降。至此,ISP来收场了。还是”其大无外,其小无内“,SRP在接口类这一层级依然适用,ISP来让接口变得纯粹,职责清晰易维护,带来高内聚性得同时,也间接将上层的业务导向进行汇聚集中,使得上层需求分发时具有一致性。
至此,我将SOLID五大原则以自己得理解跟大家梳理了一遍,囿于才疏学浅有疏漏、错误处还请读者指正。感谢您的耐心。