对一些架构设计原则的反思

2020-08-14 15:55:43 浏览数 (1)

在架构设计的领域,⼈们总结出了很多原则。这些原则的⽤语⼤都很简略,容易传播。但是提出这些原则的⼈,往往不会告诉你,为什么应该是这样的原则。哪怕说了背景,过了⼀段时间,听的⼈可能已经不知道原则提出⼈的初衷。⽽且这些原则,粗看起来是很有道理,可是在实践中,却往往不是这么回事,那么就沦为⼼灵鸡汤了。在看这些原则的时候,每个⼈都要形成⾃⼰的判断能⼒,不要⼈云亦云才好。以下是个⼈对⼀些设计原则的思考,不⼀定正确,期望能够引发读者⾃⼰的思考,形成读者⾃⼰的判断。

KISS 原则

KISS(Keep It Simple, Stupid) 原则,翻译成中⽂就是“保持简单、愚蠢”。这是⼀句没有主语的话,猜想主语应该是指设计师,并且这个“It”应该指的是设计师所设计的⽬标系统。这条原则⼤意应该是告诉设计师在设计时要保持系统的“Simple and Stupid”。这个原则仔细分析⼀下,有两个⼤问题:

⾸先,“Simple and Stupid”的判断原则是什么,怎样才算是“Simple and Stupid”︖这是这个原则中最让⼈迷惑的地⽅,⼀般⼈下意识给出的是⾃⼰所认知的“Simple and Stupid”,这未必是提出这条原则的⼈所理解的“Simple andStupid”。比如让⼀个体操队员来做⼀个后空翻,对于他来说,这是“Simple and Stupid”。可是让⼀个没经过体操训练的普通⼈做⼀个后空翻,这绝对不“Simple and Stupid”,可能会摔断脖⼦出⼈命的。

也就是说“Simple and Stupid”是因⼈⽽异的,对于技术⽔平不同的⼈,他会的或者他能够熟练掌握的技术才是“Simple and Stupid”。⼀个⽔平很⾼的设计师,看到⼀个系统,他可以给出他设计的“Simple and Stupid”⽅案,可是如果实现的⼈水平很糟糕、技术⽔准达不到,勉强去实施的话,有可能会实现不出来的,哪怕做出来了也⼀定是问题多多,弄不好要搞出⼤事情。因此,要设计⼀个系统,要根据实施团队的⽔平,做适合他们的架构设计,这样才可以算得上是“Simple and Stupid”。

其次,⼀个系统的⽬的是为了给⽤户来使⽤的。⼀个设计师设计了⼀个“Simple and Stupid”的系统,那么这个系统对⽤户来说就⼀定好⽤吗︖如果⽅便了设计师,或者说⽅便了实施⼈员,说不定会影响使⽤系统的⽤户呢︖恰恰是让⽤户⽤起来“Simple and Stupid”的系统,才是⼀个好⽤的系统。那么给系统的设计师⼀个“Simple and Stupid”的原则,到底是在帮助谁︖损害谁︖符合业务⽬标吗︖许多公司的设计师在设计系统的时候,不断的和业务团队冲突,为了保持设计师所持的“Simple and Stupid”理念,很容易会降低⽤户的体验,导致最后⾃⼰也⼀点都不“Simple and Stupid”。

可见,这个设计原则⽤到软件⾏业是有问题的,我们不能够为了“Simple and Stupid”去设计,不能够设计⼀个系统时,依照“Simple and Stupid”为⽬标去设计。因为设计⼀个系统的⽬的是为了更好的完成系统所服务的⽤户的访问,⽽不是为了设计者或者实施者。考虑设计者和实施者的时候,是在这个完成⽤户访问的前提下,怎么做到低成本的、可持续的迭代。因此,如果⼀个设计者不能够理解⽤户的需求,不能够理解⽤户通过不同的访问⽣命周期来达到⾃⼰的⽬的,是无法设计好⼀个系统的。

只有通过对⽤户的业务⽣命周期、访问⽣命周期进⾏分析,根据流量的压⼒不同,进⾏合理的树状拆分,也因此形成不同的系统,那么这些所形成的系统⼀定是内聚的,边界⼀定是清晰的,也⼀定是“Simple and Stupid”。也就是说,只有从业务上去分析、去拆分,才能够得到⼀个“Simple and Stupid”的结构,这是⼀个副产品,⽽不是⽬标。如果依照这个原则为⽬标去设计,则可能会破坏业务本身的整体。

这个原则本身是从军⼯⾏业出来的,说明“Simple and Stupid”含义是有军⼯背景的,有军⼯⾏业⾃⼰的标准,读者感兴趣的话可以去研究⼀下。不去了解⼀个概念的历史,盲⽬的直接引⽤到软件⾏业来,很容易吃亏。⽽且其中“Stupid”的含义是为了形容系统组件的修理维护简单程度,不知道这是不是这个原则提出者的“Simple and Stupid”的本意。按照这个意思,如果所设计的系统根本不允许或不需要考虑维护或者修理的话,还需要考虑“Simple and Stupid”吗︖反过来,我们回头去审查⼀个系统的时候,如果发现所设计的系统对于⽤户访问的拆分不够清晰,不是树状结构的时候,那么⼀定是不够“Simple and Stupid”的,倒是可以作为⼀个架构审查的判断点,帮助改进设计,但是不能够作为设计的依据。

SOLID 原则

SOLID 原则,据 WikiPedia 所说是由 Robert C. Martin 总结的⾯向对象设计的原则。这个名字其实是以下⼏个原则的⾸字母简写:

  • Single responsibility principle;
  • Open/closed principle;
  • Liskov substitution principle;
  • Interface segregation principle;
  • Dependency inversion principle。

“Single responsibility principle” ,翻译成中⽂是“单⼀职责原则”。这也是⼀个缺乏主语的话,但推断应该是指所设计的系统吧,这个系统应该是单⼀职责的。可是这个“职责”的“单⼀”,如何来判定呢︖不同的⼈会有不同的认知。据作者原⽂所给出的参考⽂章所描述的:“This principle was described in the work of Tom DeMarco and Meilir Page-Jones . They called it cohesion”,原本叫做 Cohesion,翻译成中⽂是“内聚”。说成内聚很容易理解,但是作者给出的解释是“A class should have only one reason to change”,就很难理解了。据给出的例⼦,说的是⼀个保龄球的游戏编程,原本 Game 类有两个责任,⼀个是负责跟踪当前帧,⼀个是负责计算分数,最后把这两个责任分别给了两个不同的类。作者给出这个拆分的理由是,“Because each responsibility is an axis of change”,意思是“因为每个职责是⼀个变化的维度”。猜想作者想表达的意思是两个正交的维度,拆开可以互不影响的意思。

原本现实⽣活中打保龄球,可以⾃⼰算分,也可以让别⼈帮忙算。为什么可以拆分开来,这是因为打保龄球的核⼼⽣命周期是打球,算分只是⼀个游戏规则,没有这个规则,保龄球也可以打的,因此这个分数计算规则可以拆分出来。并且保龄球游戏产⽣的结果是计算分数的输入,这两个步骤是打保龄球游戏的两个连续的⽣命周期活动,因此非核⼼⽣命周期可以拆分出去,形成树状结构。Game 的原本功能没变,只不过其中⼀个步骤的实现分离出去,通过⽅法调⽤的⽅式回归了⽽已。这样 Game 的职责更专注,分数计算也更专注,修改时可以互不影响,确实叫“内聚”比较好。

可是⼀旦改成“单⼀职责”,意思就变化了。后⾯又把详细解释的内容从“an axis of change”改成“one reason to change”,意思进⼀步不同了。“an axis of change”指的是⼀个维度,⽽“one reason to change”指的是⼀种理由,⼆者很难等同,应该是有很⼤的争议的。

那么怎么样才算“单⼀”呢︖这个是没有确定的标准的。Game 包含打球和算分两个步骤,难道 Game 就不“单⼀”了吗︖保龄球要打球和算分的话,这是⼀个“单⼀”的运动,放在⼀起并不算职责不单⼀,这样做并不错。但是后续修改和维护的角度来看,如果分数计算规则要频繁的修改,但不希望动 Game 的话,分数计算可以拆分出来,这是⼀种架构拆分,但并不是因为“单⼀职责”的缘由才拆分的。那么打球和分数计算分离开来了后,难道分数计算职责就“单⼀”了吗︖不⼀定,如果分数计算有很多不同规则,还可以把规则做架构拆分,分数计算职责也并不“单⼀”呀。

因此,我们说“单⼀”职责,并不能表述“内聚”的含义。⽽且“单⼀”是⼀个相对的词语,要看针对什么来说是“单⼀”的。⼀个事情分两个步骤,并不能说这个事情不“单⼀”,这是⼀个事情,是单⼀的。把这两个步骤分开后,由两个⼈来分别执⾏,对于这两个⼈来说,各⾃的职责是单⼀的,但是不能因此否认原来这个事情就不“单⼀”了,因为这两个⼈各⾃“单⼀”职责的完成,组成了原本的那个“单⼀”的事情。其实从原作者的本意来看,不过是想表述“内聚”⽽已,“内聚”这个词最贴切。

从“单⼀”的思路去看,最近出现的 CQRS(Command Query Responsibility Segregation),“命令查询责任分离”,把命令和查询拆分开来,分开后这个职责“单⼀”吧,可是这个做法却完全破坏了业务本身的内聚。还是前⾯那个保龄球的例⼦,想象⼀下算分和查询分数是不同的类,那么⼀旦算分的规则发⽣变化,那么查询分数的规则不⼀定能够跟着算分来变化,Bug 就很容易出现。如果这两个类分为两个不同的⼈来维护的话,⼀旦出现问题的话,这两个⼈就可以没完没了的扯⽪了,责任也很难分清楚,需要⼤量的沟通成本,最后⼀定会⼀团糟,这就是破坏了业务本身的内聚所带来的后果。CQRS 这个做法往往是⽤于数据读写的场景,⽤于提升读或写的性能,只有当读、写时不存在业务逻辑的时候、仅仅是做读写通道的拆分的时候才⽤的。

Open/closed principle ,也就是“开 / 闭原则”。作者总结了这个原则的发明者 Bertrand Meyer 的观点“Software Entities (Classes, Modules, Functions, etc.)should be open for extension, but closed for modification.”,作者对这句话的理解是,⼀个模块同时要能够适应新的变化,还要不允许修改,这是相冲突的。为解决这个冲突,作者得出⼀个⽅案,那就是⽤抽象类来解决这个问题。⽽抽象类则会带来许许多多的其他问题,但仍然无法完全做到对修改关闭。

为什么会出现这个原则呢︖从这个原则出现的时期来看,是在 1980 年代提出来的,猜想是因为软件⼯程不够发达,开发⼈员惧怕改变所导致的。为什么要对修改关闭呢︖因为害怕修改所带来的连锁反应。在早期的开发实践中,没有完善的版本控制、依赖管理等的帮助,无法承担频繁的修改所带来的对项⽬的冲击。另⼀⽅⾯,可能是想要去修改别⼈的代码或者类库,许多⼈如此的解释。或许也有可能也不得⽽知。如果能够去修改别⼈的代码或者类库,说明⾃⼰已经有源代码了,⾃⼰做好版本控制即可,可以⾃⼰维护。如果想让⾃⼰的修改还要兼容别⼈的后续升级,无论是⾃⼰修改还是继承,都会遇到兼容的问题,这种情况最好的办法是去请求原作者来修改,保持权责对等是成本最低的。⾃⼰修改的话,就要做好⾃⼰维护的准备,并且后续升级的代价会比较⾼。可是为什么要去修改别⼈的代码或类库呢︖如果是功能不满⾜,就不要去⽤别⼈的代码,换⼀个别的类库或者⾃⼰写⼀个︔如果是有 bug,就去请求原作者去修复,尽量不要⾃⾏动⼿修改︔或者⾃⼰参加进去成为⼀个贡献者也可以。总之要保持代码创造者对其代码的权责对等。

随着现代开发理念的发展,越来越多的⼈看到了抽象、继承的坏处,越来越多的⼈采⽤组合的⽅式来协作,其实抽象类可以看成是组合的⼀种特殊情况。⽽且随着代码的变化越来越频繁,拥抱变化反⽽成为了⼀个风⽓。只要代码中的类做到了“内聚”,只要业务代码能够做到内聚、访问通道做到不重⽤,那么要重⽤的只会是业务代码,这样修改的范围会⼩很多,同时依靠版本与依赖管理,完全可以避免修改所产⽣的影响。因此这个“开 / 闭原则”,也需要重新再看待,理性使⽤。使⽤开闭原则,就意味着⼤量的抽象类、⼤量的继承,意味着内聚的丧失,意味着要付出耦合的代价。

Liskov substitution principle ,中⽂是“⾥氏代换原则”。前⾯的“开 / 闭原则”导致了抽象与继承,“⾥氏代换原则”则是继承的进⼀步体现,也最终形成了多态的特性。作者总结了发明者 Barbara Liskov 的话“Function that use pointers or references to base class must be able to use objects of derived classes without knowing it”,意思就是⼀个功能如果引⽤的是某个⽗类,如果实际传的是该⽗类的⼀个⼦类的话,这个功能本身的⾏为不会发⽣变化。这个原则是很多程序员喜欢抽象的⼀个理论来源,这⾥试着分析⼀下。

⾥氏代换原则的本意,应该是对开闭原则的拓展,⽤来实现开闭原则。只有能够⽤⼦类来代换,才能够符合开闭原则。但是总不能够每次修改都创建⼀个⼦类吧︖也因此可以看到,开闭原则也是⼀个无奈之举。正确的做法是针对修改创建不同的版本,针对不同的版本来进⾏构建、发布。

但是有了这个代换的办法,结果⼤家倒是不⽤来遵守开闭原则了,⽽是⽤来尽可能的抽象,结果把本来应该内聚在⼀个类中的⽅法和属性,分散到许多不同的⽗类中去了,这是很⼤的⼀个弊病。记得以前 Java 认证考试就专门考继承时的变量初始化,许多⼈掉进这个坑⾥。并且这种情况非常容易造成⽣产事故,因为这种错误只有在运⾏时才能够发现,还不好排查,往往修改⽗类时,⼦类的 bug 就出现了。没有做到内聚的后果是很严重的。

另外⼀个问题是⾥氏代换的时候,比如⽗类中有 Instrument.play()⽅法,可以⽤⼦类 Piano.play(),Violin.play() 来代换,虽然引⽤⽗类时可以⼯作的很正常,但是 play() 出来的声⾳确是不确定的,因此也不能说⾏为没有发⽣变化,只能说都能够 play,但是 play 的结果是不⼀样的。但是当实际业务很复杂,不光要 play,后续还要调整具体的乐器的话,这个抽象就比较麻烦了,因为不同乐器调整各式各样不同,然后就发现原来的抽象不够⽤,要费很⼤⼒⽓去进⼀步抽象。慢慢的在业务的变化下,抽象就变成⼀团糟了,最后连⾃⼰也看不懂代码了。

可是何必要花⼒⽓去抽象呢︖直接引⽤实际的乐器就好了。除非能够做到⼀次抽象能够适应以后所有的变化,否则还是⽼⽼实实的⾯对实际情况吧,哪怕有多个乐器需要选择,写个 if-else 就好了,没⼏⾏代码,并且还是可测试的,并且错误是编译期可以发现的,以后修改、扩展也容易。为了这⼏⾏代码,引入那么多抽象,破坏“内聚”不说,⾥氏代换时,⼀般都是运⾏时才能确定的,反⽽导致运⾏时探查问题的麻烦,同时,代码也很难阅读,没⼈敢去修改,影响⽣活质量。

Interface segregation principle ,即“接⼜隔离原则”。这个原则相当于是预设了调⽤者与被调⽤者两⽅的前提,对于调⽤者来讲,被调⽤者的接⼜数量应该最⼩化。这个原则其实就是通道访问的隔离。在访问通道上,不同的客户端,不可以使⽤同样的访问通道,因为会导致它们之间的访问互相影响,这是很简单的道理。比如⼀个居民⼩区的车道和⼈⾏道必须要分离,否则两者通道混杂的话,⼀定会出事情的,⼈会很容易有⽣命危险,产⽣额外的问题。

可是为什么会变成⼀个⼤接⼜呢︖恰恰就是为了要重⽤这个接⼜,以便让各种不同的调⽤者来访问。所以访问通道上的重⽤是万万不可的,因此也会导致服务端会变成⼀个⼤接⼜,从⽽慢慢会变成团队之间的纠纷点,故障点。

Dependency inversion principle ,即“依赖倒置原则”。作者举了⼀个 Copy 的例⼦,本来是把字符从键盘 copy 到打印机,后来增加了需求要 copy 到磁盘,因此要重⽤Copy 的⽅法,以后可能还要有多个设备要读写,不希望 Copy 程序依赖设备,因此引入了⼀个抽象类,放在 Copy 程序和具体的设备之间。

其实这个例⼦的业务背景只是利⽤Copy 程序作了⼀个字符传递的通道,这个通道本身是没有任何逻辑的。作者强制性的把不同设备之间的通道绑在了⼀起,相当于是共享了设备之间 copy 的访问通道,在业务上来说,这是不符合通道独⽴的原则的,因为这些不同的通道可能是属于不同的业务与不同的⽤户,他们之间的需求后续可能会不⼀致,很有可能因为某个通道的修改,导致其他的通道受到影响。

⽽这个例⼦仅仅是为了在编译期间不依赖于设备⽽已,为了这个⽬的⽽花这么⼤的代价,有什么⼤的意义吗︖最终运⾏期间对设备的依赖是逃不掉的。其实访问通道依赖于设备是没有关系的,因为通道没有逻辑,不需要测试。所以这种通道的共享是⼀个过度设计,根本没有必要。⼀旦 copy 有⾃⼰的逻辑、⽽且这个逻辑可能还与通道有关的话,那么这么多通道混杂在⼀起,反⽽额外的增加了复杂度。并且这个 Copy 程序根本没有必要重⽤,因为没有逻辑就没有重⽤的价值。如果要从键盘写到磁盘,不如重新写⼀个 CopytoDisk 更简单,因为⽤户可能不⼀样,⽤起来也更简单,也更独⽴。

作者这个 Copy 程序例⼦的场景可能举的不好,但是依赖倒置也是有⾃⼰的场景的,不是什么时候都需要。依赖倒置的做法,无非是在两个步骤之间增加⼀个节点,其实就是作了⼀个架构拆分,形成了⼀个新的访问通道,形成了树状的结构。这个依赖叫“倒置”也不太对,只不过变成了依赖中间增加的那个节点,避免了直接依赖⽽已,但是间接依赖还是在的。真正正确的架构拆分,其依赖⼀定是树状的,从拆分的起点开始往树的下层依赖,不会出现下层依赖上层的情况,甚至不会出现兄弟节点之间的依赖,因为他们都是从顶层拆分下来的,是⼀个访问通道上的不同节点。

⼀旦发⽣架构拆分,就意味着要管理这个新增加节点的⽣命周期,也意味着额外的成本。只有相依赖的两⽅会对对⽅的⼯作造成影响的时候,才需要通过拆分增加⼀个节点,以便让两⽅可以独⽴的⼯作,互不影响。并且增加的这个节点不⼀定是 Abstract 的,也可以是⼀个实体。⽽且建议⽤实体,不要⽤Abstract,因为依赖于 Abstract 意味着依赖⼀个继承树,成本太⾼。⽤通俗的话说,尽量去⾯对正规公司,不要去依赖⽪包公司,层级越少,沟通越少,效率越⾼。所以,不要⼀开始就去架构拆分,要根据当时所⾯对的情况,合理的采⽤。

所以,对于 SOLID 原则,第⼀个其实是说内聚,只是“单⼀职责”的提法不好。第⼆、第三个说的是继承的问题,这是⾯向对象语⾔的特性。继承会有很多的坑,会破坏内聚,也不⼀定合适。第四第五个,其实说的是访问通道的问题,只要做好访问通道的隔离就不会有问题。如果⼤家从这些原则的字⾯上的意思去理解,怕是要⾛入误区了。

业务内聚与访问通道内聚

当然,很多⼈也会提到“⾼内聚、低耦合”的原则。这个“⾼、低”的说法不够严谨。只要某个业务的⽣命周期活动不在⼀个类中确定,那么这个类就没有形成内聚,反之就是做到了内聚。只要做到内聚,就没耦合了,就只有依赖关系,⽽且这个依赖是⼀个树状的结构;只要没做到内聚,肯定耦合了,没有⾼低之分,最后都会带来麻烦,区别在于带来麻烦的多少⽽已。所以⼀个应⽤要么没有内聚,只有耦合,要么只有内聚,没有耦合,只有其中⼀个情况。

可是要做到业务的内聚,却离不开业务访问通道的隔离,这个原则我把它称作“访问通道不重⽤”原则。观察⼆者的关系可以发现,重⽤业务与重⽤访问通道⼆者,只能够选⼀个。因为重⽤访问通道会导致业务无法内聚、也就无法重⽤;重⽤业务则会导致访问通道无法重⽤。如果想两者都达成,那么最后的结果⼀定是只成功的重⽤了访问通道,⽽业务内聚则⼀定会被破坏。

为什么会是这样呢︖因为⼀个事物对物理空间的占有是独享的,⽽访问通道则是事物跨越物理空间的通路。必须确保⽤户对⼀个事物的访问通道是独享的,才能够保证这个访问通道是内聚的。如果不同类型⽤户的共享同⼀个访问通道,就意味着访问通道不再是独占的了,这就是对访问通道内聚的破坏,最终这个访问通道就变成⼀个不确定的通路,内步冲突不断、阻碍重重,⼀定会反应到对业务内聚的破坏。

比如⼈们做公共的交通⼯具,往往不允许带宠物,这就是要遵守宠物和⼈类的访问通道内聚原则,因为宠物和⼈类在狭窄的空间⾥共存,会产出非常多额外、不必要的冲突。所以我们说“内聚”,绝对不能只提业务的内聚,访问也是⼀种独特的业务,也需要达到内聚的原则。也就是说,“访问通道不重⽤”原则其实说的就是“访问通道的内聚”。

如果做好了业务的内聚,并隔离不同类型客户端对业务的访问通道,形成访问通道的内聚,基本上程序就不会太差,代码就会很稳定。有了这个基础,再根据运营过程中所产⽣的瓶颈点,有针对性的做业务架构拆分或访问通道架构拆分就很容易了。做为⼀个架构设计师或者程序员,如果不把“内聚”放在最重要的位置,最终⼀定会被需求给淹没的。

因此,架构设计的的核⼼原则就是“内聚”,任何架构原则都不能违反此原则。这个“内聚”包括两部分:“业务内聚”,“业务访问通道内聚”。所以,对于我们遇到的任何⼀个架构原则都可以这样去判断:如果发现它违反了“业务内聚”原则,我们都要三思,因为会导致业务分散、无法重⽤︔如果它违反“业务访问通道内聚”原则,也就是“业务访问通道不重⽤”原则,我们也要三思,不要去追求访问通道重⽤。

“访问通道内聚”原则是软件⾏业普遍忽视的,这个原则太不起眼,也太容易被破坏,⼤家都忽视了。“访问通道内聚”的缺失⼀定会导致“业务内聚”原则的破坏,导致业务无法重⽤。于是,系统就开始陷入困境了。

0 人点赞