不止于面向对象的SOLID原则

SOLID原则是由人称"鲍勃大叔"的Rober C. Martin所提出来的。他用五个面向对象设计原则的首字母组成了SOLID,并使其得到了广泛传播。这五个原则罗列如下:

  • 单一指责原则(Single Responsibility Principle):类的职责应该是单一的。所谓单一,是从变化的维度衡量的,既一个类应该只有一个变化的原因。
  • 开闭原则(Open-Closed Principle):设计模块应该对修改封闭,对拓展开放。
  • 里氏替换原则(Liskov Substitution Principle):子类应该完整地实现父类所要求的所有行为。在替换父类后不会导致程序的行为发生变化。
  • 接口隔离原则(Interface Segregation Principle):类之间的依赖应该建立在最小的接口上。不应该让使用方依赖于它们用不到的方法。
  • 依赖倒置原则(Dependency Inversion Principle):高层模块不应该依赖于低层模块,二者都应该依赖于接口。

SOLID原则涉及到面向对象的许多方面,例如内聚性、耦合性、良好的关注点分离等。尽管这五个原则既不全面,也不正交,却依然有非常积极的指导意义。它们并不仅仅局限于面向对象设计中,从函数、类、组件、再到系统架构,在软件设计的各个层次中它们都是优秀的指导方针。下面笔者将逐一介绍,并从自身的主业 前端/客户端开发领域中摘取例子来细化讲解。

SRP:单一职责原则

单一职责是首要介绍的,同时也是笔者认为最简单却最重要的一个原则。我们这样子来描述它:

任何一个软件模块都应该有且仅有一个被修改的理由。

无论我们在做何种层次的设计中,都是在不断地根据目标分解模块元素并为这些模块分配职责;模块间互相协作,组合起来成为一个更大的模块从而完成更大的职责,自下而上最终构成了完整的系统。以下从不同视角来举些正面/反面例子,帮助我们更好的理解该原则:

1. 低内聚的组件

在可视化编辑器的物料库中有一种类型的SVG组件如下所示,该SVG组件既能接受一系列配置来更新SVG渲染的样式、还可以在路径上播放物移动的动效:

如此带来的一个后续问题是,processStyle方法依赖于getParsedPath方法;而动画效果是后续添加的新功能,同样也直接依赖于getParsedPath。当最初维护该SVG组件的同学接到修改需求后,根据需要对getParsedPath方法内部做了调整,并在验证满足了新需求后边提交了更改。但是,同样依赖于getParsedPath方法的processAnimation却并不知情该变更,并在下一次发版后不再能于其正常协作而抛出了异常。

一个显而易见的地方是,动画处理的职责并不应该属于SVG组件。当我们将这些不同职责的代码放在一块时,就容易产生冲突。要解决上述问题,思路便是:将不该属于SVG组件的职责给剥离出来。

这里新产生了一个AnimationController类,专职来处理动效,并被SVG组件所关联。同时,这两者都依赖于有关路径的处理方法,并且在可预见 的未来中,类似功能会有很大概率复用到;因此抽离出一组通用于SVG路径处理相关的工具函数SVG Utils

经过重构之后的代码,尽管在代码量上略有增加并且调用关系相对复杂了一些,却带来了更多的好处:

  • 隔离职责,降低了模块变更带来的风险
  • 关注点分离地更加清晰
  • 代码复用性的提升

2. 包含越多职责的模块越容易产生冲突

通常来说,一个模块对应的只有一个源文件,可能是一个函数、一个类或是一个组件。而同一模块中所包含的职责越多,它所面对的维护者就越多。我们就以上面未重构前的SVG组件为例子:

当某一天SVG组件和动画效果都接收到了新需求,亦或是需要处理Bug。而这两项任务刚好是由两个开发者承接的;这很常见,因为动画需求往往不是针对某一个组件的而是针对库中一批同类别的组件,所以将其分配给不同的人开发是合理的。

接着这两位开发者就从主干上拉出了新分支到本地,在完成自己的任务后,再将其合并回主干上。因为他们都在SVG组件的那个源码文件上做了修改,不出意外地在合并代码的过程中就产生了冲突。这时就得其中的一位开发者来完成冲突处理的"脏活",阅读冲突部分相关的代码并同另一位开发者交流确保没有歧义后再提交合并后的代码。

依据职能将代码进行分割后可以很大程度避免这种情况发生。尽管对于大部分人尤其是项目熟手来说,会觉得处理代码合并过程中产生的冲突只是小事一桩。但对于一个多人协作完成的大型项目来说,频繁触发的代码合并冲突,就意味着更多的额外工作量以及更高的出错概率。

3. 高内聚的组件带来更好的可维护性

我们以Unity下一个支持热更新的项目架构为例,简化为如下所示:

自下而上地简述下各组件的功能:

  • Unity引擎核心:自立项之初就确定的底层框架,在此之后几乎不可能变更。构建后为一份可执行文件及DLL。
  • 库文件与自己编写的游戏脚本:该部分主要由C#脚本实现游戏中的通用支持和性能敏感的逻辑模块,如引擎API桥接、资源加载/卸载、网络请求、游戏寻路算法等。在不同的构建方式下中间流程会有差异(Mono/IL2CPP),但最终产物都是DLL文件。
  • Lua脚本:游戏中会被频繁修改的业务逻辑大部分都放在了此处,例如UI、角色战斗逻辑、怪物的AI等。其是Unity能够支持热更新的原理所在。
  • Asset Bundle:游戏中代码以外的资产(模型、纹理、prefab和音视频等资源)都可以打成ab包。支持在游戏运行时动态地下载/卸载资源。

通过以上对各组件的简述,我们很容易看出各个组件之间有非常明显的边界;它们所拥有的职责,需要被修改的理由都非常明确。如果将SRP原则从系统组件层面上描述的话,可以说是把那些为了相同目的而修改的文件都放到同一个组件中;那么在需要修改时我们只需将变更到尽可能少的组件,并能够独立地将它们发布、验证及部署

当游戏需要即时修复线上Bug、调整数值时,只需通过热更新 替换lua文件即可。通过加载ab包,可以在遇到临时的节日运营活动/审核政策调整时可以立即替换游戏内的美术资源。在游戏底层机制经过许多改动后发布大版本更新时,则需要以冷更新的方式,在游戏客户端停机后替换新构建的DLL等文件。

OCP:开闭原则

设计良好的计算机软件应该易于拓展,同时抗拒修改。

先对这句话中的两个概念再作一次翻译,"拓展"指的是增加代码实体,"修改"指的是在已有的代码实体上进行修改。这个原则要求我们不应该在实现新需求时总需要去对既有的代码作出修改,而是只需要增添新代码即可;否则的话,随着后续新需求的不断增加,同一模块内代码的复杂性和出错的风险就会不断增加,这就是一个不好的设计。

同单一指责原则一样,开闭原则大多数时候都不是作为一种设计手段,而是检验手段;用于判断一个设计是否足够的好。我们来看一个富文本渲染的例子。在一个支持渲染多种元素的富文本应用中,如果缺乏合理抽象的设计,可能会写出如下"面条式"的代码:

jsx 复制代码
class RichEditor extends React.Component {
    // ...

    renderElement(type, params) {
        if(type === 'img') {
            /* ... */
        } else if(type === 'url') {
            /* ... */
        } else if(type === 'dateTime') {
            /* ... */
        }
    }

    render() {
        // ...
    }
}

很显然,这是一个不内聚的模块设计,不符合单一指责原则。我们再以开闭原则的视角去审视它:

现在有了一个新需求,富文本还要能够渲染一块表格内容。大多数人最自然的选择就是在renderElement()后边再加一个新的判断条件:

else if(type === 'table') {/* ... */}

这是一个典型的违反开闭原则的例子:不支持拓展,需要对既有代码作修改。对于渲染类型的关注点都集中在了renderElement()里面,随着需求的不断增加,我们会在这个函数中不断地追加代码,致使renderElement()越来越复杂和臃肿。

让我们把这个设计重构一下吧。

首先将控制元素渲染的逻辑从RichEditor中剥离出来,实现一个管理器ElementRegister接管这部分逻辑,并提供注册接口用作拓展入口。这样一来就成了RichEditor依赖ElementRegisterElementRegister依赖于具体的元素渲染实现(RichEditor ---> ElementRegister ---> xxxElement)。从依赖链条来看如此设计和及和原先的代码没本质区别,因为RichEditor始终还是依赖到底层的具体实现,对修改不够封闭 。我们再将依赖方向反转 一下,提供一个接口IElement,变为由RichEditor依赖于接口而不再是具体实现。底层的渲染元素负责实现IElement接口:

调整为这种依赖结构后,底层元素的具体实现对上层就不可见了,RichEditor/ElementRegister面向的只是接口IElement了;对于修改就封闭了。当有新元素需要增加时,则只需要再添加一个符合接口的实体即可了;对于拓展也是开放的了。

组合优于继承

再来看一个例子,选取自阿里的Galacean引擎专栏中对系统架构的介绍:

场景中的实体(Entity)是在运行时被创建出来的一个个对象,能够在场景中被渲染出来并通过添加组件(Component)的方式来提供各种能力。基于组件进行架构的系统,组合优先于继承。比如希望一个实体既可以发光也可以出声,那么添加灯光组件和声音组件就能做到了。这种方式非常适合互动这种复杂度高的业务------特定功能只增加一个组件即可,便于扩展。

如果是采用继承的方式,那么在每次需要新添有特定功能的实体时,都有可能需要调整原先的继承关系。尤其是在整棵继承链上的类关联较复杂,层级结构较深的情况下,这种关系是非常脆弱的。在互动小游戏业务的迭代中这样需要频繁新增实体的场景下,就意味着继承关系可能会被频繁破坏。这样对修改毫不封闭 ,会造成极大的维护成本。反之,以组合式扁平的结构,对拓展更为友好,通过增删组件的方式可以拓展出许多中不同的新实体(在这种架构之下,所有的实体通常都只需要继承一个统一的基类即可)。

LSP:里氏替换原则

里氏替换原则由Barbara Liskov提出,其表述如下:

若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编写的程序 P 中,用 o1 替换 o2 后,程序 P 的行为功能不便,则 S 是 T 的派生类型。

将这段学术化的表达翻译成大白话就是子类应该完整地实现父类所要求的所有行为,这样一来在使用了父类的程序中,即使后续替换了其他子类后该程序也不会受到影响/感知不到变动。

根据上述表达可以知道,一个符合里氏替换原则的设计最为明显的好处在于,对于高层模块所依赖的类/接口,如果所有继承子类/接口都按预期实现了所要求的行为,那么这些依赖就都具有了可替换性。大大降低了在后期需要替换底层依赖时的迁移成本。下面就举一个笔者在项目实践中遇到的具体例子:

设计实现一个地图建模引擎,除了展示地图的底图外还能够回显/用户手动绘制各种图形、绘制路径、播放动画等。业务领域的需求是已经确定好了的,我们准备先使用高德的地图SDK(AMap)来作为底层的地图渲染引擎,完成功能后部署一套在公网上方便给客户演示。后续是在甲方的内网环境上部署的,因此还需要开发去驻场,地图SDK肯定不能是高德的,而是替换为甲方指定的了。

所以考虑对屏蔽掉具体的地图引擎,使其对上层的业务方不可见。提供一个抽象类AbstractMapWidgetClass,在该类中定义了底层的地图引擎应该具备那些行为能力。上层业务依赖于该抽象类:

接着使用高德地图的SDK来实现一个AMap类,使他继承自AbstractMapWidgetClass并正确实现父类要求的所有行为。后续在不同的甲方环境中部署时要使用到不同的SDK也是以同样的方式进行替换;只需新建一个子类继承自抽象父类即可。对于上层应用来说,因为它只依赖于抽象而不是具体的底层模块,所以我们在替换任意地图渲染引擎后都不会影响到它。

在软件架构层面,同样也应该注意到LSP原则的应用。系统架构中那些在未来预期中可能产生变动的部分如上层应用所依赖的底层模块、平台的基础设施等,都应该具备较高可替换性,使得后期迁移时不对上层造成影响(这里的"上、下层",都是相对而言的)。

使用方与替换部分之间的衔接桥梁,就是接口(Interface) 。这里可以延伸出面向接口编程这一重要的思维方式(这里所说的接口并不是指面向对象语言中的接口,而是广义上的接口,或许更贴近的叫法应该是"契约");笔者将在最后再展开这部分的探讨。

ISP:接口隔离原则

ISP是一项指导接口该如何设计的原则。它建议接口应尽量的小并且内聚,依赖方使用不到的东西就不应出现在接口中。违反这项原则的场景往往不是在从零实现的新设计中,而是在功能演进时产生的:

起初我们的可视化设计器只有报告设计器FreeReport(一种类似于PPT的自由布局),有一个上下文FreeReportContext的依赖。这个上下文实现了相应的接口IContext。这些在一开始看起来都很良好,但问题显现是在后续我们新增了新的设计器之后。后续新增了仪表盘设计器Dashboard,它也有一个上下文的依赖DashboardContext。该上下文同样实现自IContext接口。

现在的问题在于,IContext中定义的大多数行为FreeReportContextDashboardContext都应该满足。但原先的极个别方法例如上图中的页面信息pageCount在新添的DashboardContext没有相应的行为,而DashboardContext所需要新加入的minimap行为在FreeReportContext中又是不被需要到的。如果后期再继续拓展新的设计器类型,那么上述情况则会愈加地多,往IContext添加的任何新行为都会影响到先前所有的接口实现类。

对于不支持的行为,基于TS语法我们可以将其设为可选性?或者直接实现为空。这么做不符合语义,也没有必要。合理的解决方案应该是将不同依赖方所依赖的不共同的行为"分离"开来。可以有以下两种方式:

如此一来FreeReportContextDashboardContext所依赖的接口都变得更干净了:接口中不再包含有自己不需要的行为了。至于上面两个方案哪个更优,这就要取决于系统未来的演进方式了。

DIP:依赖倒置原则

高层模块不应该依赖于低层模块,二者都应该依赖于抽象。

当我们修改抽象接口的时候,对应的具体实现一定也要做修改。但修改了具体实现后,相应的抽象接口则不一定需要做修改。此外,抽象的接口通常来说都是经过精心设计的,在未来的演进过程中会被做调整的概率更小。因此,我们可以说抽象接口这一层是稳定的。让高层模块和低层模块都依赖于抽象,能够带来更为稳定的设计。

依赖倒置原则这儿笔者不打算再举新的例子。回顾上面几项设计原则的例子中,特别是富文本和地图的例子中。我们能发现优化后的设计方案都是如下图形式的,通过让高层模块和低层模块都依赖于接口,使得本来指向低层模块的依赖箭头方向"倒置"了上去:

前面还提到了一个概念:面向接口编程。我们再回顾一遍里氏替换、接口隔离、依赖倒置这三项原则,它们都是面向接口编程的具体表现。所以笔者觉得最后有必要再提一下这一编程范式。

面向接口编程的方式把"A依赖于一个具体的B"变成了"A依赖于接口定义的标准"或者"A依赖于接口定义的能力"。这是一个非常重要的思维模式的不同。

"A依赖于一个具体的B"类似于我们在日常生活中遇到的非标准件。假设汽车上的一个小零件坏了,而且这个零件是一个非标准件,那么需要把车开到专门的汽车门店去修理。这个门店要是没有这种零件,就还需要花费时间订购。可如果是家里的灯泡坏了,那么只需要到附近的五金店,就可以买到新的,很快就能修好。之所以能有这种便利,是因为所有灯泡都必须遵循国家标准,从而能够灵活互换。更重要的是:标准化的接口让所有家庭的照明系统和各家照明设备制造厂商成功解耦了,仅和国家标准存在耦合。国家标准非常稳定,自然整个照明系统的维护成本就大幅降低了。

标准化是现代工业的基础。对于在上一段中提到的标准化,现行最新的标准是 GB/T 1406.1-2008《灯头的形式和尺寸》。例如,日常生活中最常用的灯头是E27螺口灯头,更细一点的是E14灯头。正是因为有了这些标准,各家灯具制造厂商和灯泡制造厂商才可以各自独立,产品互相兼容。这种简单性和互换性也是软件系统设计所追求的目标。尽管由于软件系统的复杂度要远远超出照明系统,导致实现完全的标准化定义非常困难,但是依赖于接口,而不是依赖于具体的实现,是一个普遍的原理。

接口作为一项"设计契约",分离了做什么(接口)和怎么做(接口的具体实现)这两个关注点。接口实现方需要确保自己正确履行抽象接口中定义的所有职责,而接口依赖方在确保自己正确地调用接口的情况下则可以获得相对应的服务。由此带来更为稳定的设计。