从“高内聚,低耦合”说起
记得在上学的时候,⽼师就说过“⾼内聚,低耦合”,但当初对这句话的理解⽐较浅显。⼯作之后,为了说服别⼈采⽤⾃⼰设计的⽅案,常常说“……这样就做到了⾼内聚,低耦合……”。
随着⼯作经验越来越丰富,学到的内容越来越多,在解释设计⽅案的时候,可能会这样说:
“……此处使⽤了策略模式,从⽽保证了模块相对的稳定性,和较强的扩展性……”;
“……这个聚合维护了A、 B和C之间的固定规则……”。
⽽之前经常说的“⾼内聚,低耦合”却不会经常挂在嘴边了。那什么是“⾼内聚,低耦合”呢?
内聚性和耦合性,都是软件度量。内聚性是指功能相关的程序组合成⼀个模块的程度,或是各机能凝聚的状态或程度。耦合性是指,⼀个程序中模块及模块之间信息或参数依赖的程度。它们都是早期结构化分析的重要概念之⼀,⽽且是相对的概念。⼀般内聚性⾼的程序,通常是低耦合的。虽然我们不再使⽤严格的“结构化分析”步骤,但是它依然适⽤于现在⼀直存在的模块关系中。在⾯向对象的分析和设计中,⼀个类可以看成最⼩的模块,那么内聚性和耦合性也可以表达为对象之间的关系。“高内聚,低耦合”代表着这程序更健壮、更易扩展。
它似乎并未过时,但是在讨论问题的时候,我们为什么不再经常使⽤?或者说你在什么时候使⽤呢?或者我们把问题再扩⼤⼀点——你依据什么“设计⽅法”去指导架构设计⼯作呢?是SOLID?是设计模式?还是DDD呢?
在回答这个问题之前,我们看看还有哪些“设计⽅法”出现在分析和设计⼯作中,以及使⽤时遇到的问题。
令人迷茫的设计方法
故事一 SOLID不够明确?
对OO设计熟悉的读者肯定知道著名的SOLID原则。当时我对OO的认识还只有“封装、继承和多态”,读到这些理论的时候,收获颇多。直到有同事问我:“单⼀职责原则规定每个类都有单⼀的功能,那么到底⼀个类有多少功能算是单⼀呢?如果⼀个类有多个成员⽅法,是不是⼀定要拆分成多个类呢? ”
我想了⼀下,如果⼀个类中只有CRUD⽅法,是不是要把类拆成Creator、 Retriver、 Updater和Deleter才能满⾜这个原则呢?⾃认为对SOLID了如指掌的我⼀时⽆语凝噎。
故事二 设计模式违反“原则”?
接触到设计模式后,“策略模式”中的每⼀个算法被封装到⼀个类,算是满⾜了“单⼀职责”,并且“针对了接⼝编程,⽽不是针对实现”,顿时有种发现新⼤陆的感觉。此时,感觉SOLID⼀点都不“Solid”,反⽽很虚。设计模式才是指导开发的王道,策略模式也是我最常⽤的模式之⼀(可能它最简单吧)。
后来遇到更复杂的情况:不同的条件下,需要使⽤不同的“策略组”(算法组);并且随着状态的流转,“策略组”也跟着变化。使⽤“状态模式”,问题迎刃⽽解。此时的我认为,设计模式是指导设计的另⼀套原则,是对SOLID原则的拓展和应⽤,是对于模糊的原则做了⼀次详细的解释说明,⽽SOLID本身很难指导开发设计。
状态模式
问题一:状态模式中的坏味道
直到一次Code Review时,同事提出了以下疑问:
“虽然增加⼀个新的状态(State)只需改变很少已有代码,但是如果增加⼀个新动作(action),是不是所有状态⼦类都实现这个动作?这样就会把已有的State⼦类全部更改⼀遍。 ”
“如果有必要,是的。但⽬前这个动作只有在StateX下才会真正地使Context的状态发⽣改变。我会在State中⽀持默认的实现,只有ConcreteStateX才会完成状态变化的逻辑。这样⼀来就不需要改动每⼀个State了。 ”
“如果这个‘新动作’只在某⼀个具体的State中才⽣效,那么相当于‘兄弟State’不得不⽀持这个对⾃⼰毫⽆意义的动作,⽽且基类可能有越来越多类似的默认实现,此时会出现‘DivergentChange’这个坏味道。⽽且当我们使⽤State基类的时候,我们并不清楚哪些⽅法在哪个具体的State⼦类有特殊的实现,给读代码的⼈也带来很多困难。 ”
在此之前,我从未怀疑过设计模式会遭到挑战,⽽且对⽅说的貌似有点道理。
问题二:组合模式违反“单一职责”原则
再来看组合模式,Component承担了Leaf和Composite两种职责,明显违背了“单一职责”原则,以后还要不要用呢?
故事三 拍脑袋决定方案?
经历过以上故事的我,对于设计上的优缺点已经有了一定的认识,但是在代码层面竟然也遇到了问题。
在⼀次重构⼯作中,遇到了“重复代码(Duplicated Code) ”的坏味道,我直接使⽤“PullUp Method”的⼿法,将重复代码推⼊了超类;⽽同事却认为使⽤继承不如使⽤组合,建议使⽤“Extra Class”,将重复代码抽到⼀个⽆关的类。我俩的⽅法都能解决问题,但是为了说服对⽅花了很多时间。后来想了想,对于两个都能解决问题的⽅案,我是否还要花时间去争论?如果两个⽅案都⾏,拍脑袋决定岂不美哉?
故事四 DDD如何指导设计开发?
随着在项⽬中使⽤DDD,⾃认为积累了不少经验。⼀次在DDD Community的讨论中,涉及了聚合,便有了下⾯的对话:
问题一:聚合和一致性的关系
“……聚合是为了维护模型对象间的固定业务规则⽽存在,所以A、 B和C在同⼀个聚合⾥⾯。 ”
“等等, A和D之间也有关系,这种关系难道不是业务规则吗?为什么要把D排除在聚合边界之外? ”
“这⾥说的‘固定业务规则’是强⼀致性的, A和D之间的业务规则是‘最终⼀致性’的。 ”
“‘最终⼀致性’是不是牵强附会的概念呢?你的意思是, A和D之间的业务规则可以‘不⼀致’,也就是不固定?你问问业务⽅同意吗? ”
“我的意思是它们之间可以有‘短暂的不⼀致’,⽐如发邮件的那⼀刻,并不期望邮件在⼀秒内发出去,它可能在邮件服务的队列⾥等着呢,只要在规定的时间内,⽐如五分钟,发出去即可。所以我们经常把‘邮件系统’作为⼀个独⽴的系统,故⽽不会把‘邮件’和‘发送邮件的对象’作为⼀个聚合。 ”
“听起来有道理。但是我也⻅过业界很多情况下把‘下单’和‘财务’做成两个微服务系统的,所以‘订单’和‘账单’肯定不属于同⼀个聚合。按照上⾯的说法,‘订单’和‘账单’之间肯定没有强⼀致性的业务规则。但是你点外卖的时候,难道不是付款成功之后才会告诉你下单成功吗?它既不会让你等五分钟,也不会没收到钱就给你准备饭菜。这种规则难道不是你所谓的‘强⼀致性’? ”
讨论到这个地方,通常会以“具体问题具体分析”结尾,或者还没结尾就转到了另一个相关话题:
问题二:实体只有一部分需要聚合维护固定规则
“……前⾯说到,聚合维系了内部对象的固定规则,所以操作聚合内的对象要通过聚合根。但是聚合内某些实体的状态更新通过聚合根操作效率太低……”
“不通过聚合根操作会破坏业务规则吧? ”
“打个⽐⽅,在订单这个聚合⾥,直接更改某个订单项的价格可能会破坏订单的业务规则,⽐如‘总价限额’。但是更改订单项的备注并不会破坏任何规则。如果还有更多类似‘更改备注’这样的操作,都要通过聚合根这个‘代理’完成。这跟重构中的坏味道‘中间⼈(Middle Man) ’有点像。 ”
“那能不能把订单项中‘没有固定规则’的部分和‘有固定规则’的部分分开,做成两个实体对象呢? ”
“⾸先,在当前上下⽂⾥,订单项是⼀个⾮常明确的概念,分开后怎样对应业务概念呢?其次,如果真的分开,这个新的实体对象⽣命周期的维护也需要成本。 ”
讨论到此处,又陷入了僵局。但讨论一旦发散起来,根本刹不住车。
问题三:实体只有某个生命周期需要聚合维护固定规则
“上⾯讨论的是‘实体只有⼀部分需要聚合维护规则’。我现在遇到的问题是‘实体只有某个⽣命周期需要聚合维护固定规则’。这种情况下,相当于实体的其他⽣命周期的操作也要受限于聚合。 ”
“说来听听? ”
“还是拿‘订单’举例。在创建订单的时候,所有订单项之间才会维护固定规则,⽐如‘总价限额’。⼀旦创建完毕,业务规定不能更改订单项的价格,也就不再需要聚合维护任何固定规则了。《领域驱动设计》并没有给出这种情况下对应的明确答案,如果按照书中对聚合处理,同样也会遇到前⾯讨论的问题。 ”
诸如此类的讨论还有很多,⼼中也有⾃⼰模糊的答案,但是也产⽣了更多的疑问——聚合是不是⼀个业务概念?它是⼀个令⼈随意打扮的,还是⼀个客观存在的个体?如果“令⼈随意打扮”,那该如何打扮呢?如果客观存在,该如何才能准确地找到它呢?在DDD中有⾮常多的概念,它们⼀直在讨论中存在争议,那么DDD该如何指导设计和开发呢?
设计中的妥协
不知道各位读者是否也遇到过上⾯类似的问题。每当读到⼀个“新”的“设计⽅法”的时候,总免不了“得矣得矣”⽽沾沾⾃喜;⽽在实践中,很难得到⼀个完美的解决⽅案。
我也⼀直在思考,到底什么样的架构才是“完美”的?
后来我在《架构整洁之道》里面找到了答案:“软件架构的终极⽬标是,⽤最⼩的⼈⼒成本来满⾜构建和维护该系统的需求”(以下简称“最⼩⼈⼒成本原则”)。如果我们把这句话当作架构的⽬标,那么很多设计问题都会迎刃⽽解了。
在“故事二”的“问题一”中,新的解决方案可以是“把每种状态的动作处理做成可配置的,配置项通过多个策略模式完成”。这样只拓展新增加的aciton策略,相同的策略只需要相同的配置即可,并且还将state的控制权还给了Context。
但是这种方案是不是“最小人力成本原则”呢?未必!状态模式作为成熟的设计模式,更容易被开发者理解和应用。而且我们还要面对团队开发人员的技术水平问题:当前的开发团队能否理解新的解决方案呢?我们需要花多大代价普及这个方案呢?这个方案是否可以作为一个新的模式在团队推广呢?
在“故事二”的“问题二”中,组合模式也是经过千千万万开发人员采用并验证过的,它是处理树型结构的典型方案。虽然它违背了某些原则,但是如果“妥协”一下,它还是非常好用的工具。相反,如果相似的问题不采用组合模式,新的方案能否满足组合模式的所有优点呢?
在“故事四”的“问题一”中,虽然D和A、B、C之间有业务规则,但是如果放在一个聚合里面维护,会不会因为聚合内过于复杂而无法满足“最小人力成本原则”呢?如果是,我们只能通过“妥协”,把相对联系不太紧密的D排除在聚合之外。其他问题的分析也是同理。
讨论到这里,所有的问题还是没有确切的答案,一切都是依赖具体的“环境”做出的妥协。
如果我们把“最小人力成本原则”来作为设计原则或者方法,那什么是“最小”呢?如何衡量最小呢?难道软件的设计原则真的是“具体问题具体分析”?而且它听起来更像是一个条管理原则,那我们学习SOLID和DDD还有什么用?
原则和模式
三年前,我以DDD专家身份去客户现场,几个同事在讨论具体概念的时候发生了分歧。这时,一位资深的同事问我们:“你认为架构的设计原则是什么?”我思考了一下:“高内聚,低耦合”。虽然当时得到了大佬的认同,但我并没有对自己的答案有多少信心。
如果我们把“高内聚,低耦合”作为原则对前面的问题进行分析,似乎也能得到类似的答案,毕竟“内聚性高,耦合性低”确实能降低构建和维护系统需求的成本。但是它的缺点也同样存在——到底内聚性达到什么程度才算“高内聚”呢?毕竟这两个指标没法量化。如果我们在进行OO建模,此时我们会发现,SOLID原则会告诉你怎样做到“高内聚,低耦合”。单一职责原则要求“一个类或者模块应该有且只有一个改变的原因”,其实就是对“高内聚,低耦合”的一个应用。
所以,我们可以把之前提到的所有“原则”都作为设计的指导,只不过在不同层级,不同粒度上会有不同。
那么,设计模式和“聚合”也是设计原则吗?在我们的讨论中,它们不是原则,而是模式。
模式可以理解为以原则为指导,针对一类问题提出的可复用的解决方案。设计模式很多情况下都是印证了SOLID原则。既然“针对一类问题”,它必然有严格的使用条件。
在“故事三”中,针对“消除重复代码”的原则,提出了多个“模式”——“Pull Up Method”和“Extract Class”。《重构:改善既有代码的设计》也给出了使用条件——如果两个类不相干,那么使用“Extract Class”;如果两个类有很多共性,或者本来就属于同一个继承结构,那么使用“Pull Up Method”。所以当我们搞清楚模式的使用条件时,就不用拍脑袋决定了。
在“故事四”的“问题三”中,聚合模式似乎不能满足“实体只在某个生命周期才需要一个组合结构维护固定规则,而其他生命周期和这个组合结构解耦”的问题。那么聚合模式是错误的吗?并不是,它仍然满足了大多数情况下的设计要求。很显然,我们此时的特殊需求,超越了聚合模式的使用范围。
既然模式不再适用,那我们就通过原则来指导设计。
首先,根据“最小人力成本原则”,使用聚合模式和“疑似中间人(Middle Man)坏味道”两者之间,哪个给团队带来的成本最小呢?如果使用聚合代价更小,那我们欣然接受聚合模式;反之,考虑“聚合”的设计原则。
在《领域驱动设计:软件核心复杂性应对之道》里讨论聚合的时候反复提到“局部和整体”和“一致性”,可以认为这是聚合模式的理论来源,也就是设计聚合的原则。事实上,它把复杂的变化封装在了一起,也符合我们说的“高内聚,低耦合”的原则。如果我们放弃聚合模式,那么就用前面说的原则进行重新设计。这样无论设计出的怎样的模型,他都是符合DDD设计思想的。如果这种模型能解决一类问题,它甚至可以命名为新的模式。所以,一旦分清了原则和模式之间的关系,更利于我们做设计工作。
因此:
软件的设计原则是分层级的:
- 高层原则是抽象的,难以指导设计工作;
- 低层原则是对高层原则在某个方面的细化,但它不能违背高层原则。
模式以原则为指导,针对一类问题提出的可复用的解决方案,并且有明确的使用条件。
做设计时,优先以满足条件的模式为指导,当模式无法满足设计时,以对应层次的原则作为指导。当低层原则无法指导设计时,向高层依次寻找原则。当新的设计方案能解决某一类问题时,它可能就是一种新的模式。
由上面的总结可以看出,模式作为设计的武器,当武器库中的武器都不能满足设计要求时,要么选择“妥协”,找一个最趁手的武器用起来,要么根据“原则”对武器进行升级打造,丰富武器库。(古代著名军事家戚继光在抗倭时,以长矛为基础发明狼筅,以克制倭刀。)
回到“高内聚,低耦合”
如果现在有人问我:“你的设计原则是什么?”我的回答可能是“高内聚,低耦合”,也可能是“封装、继承和多态”,但答案不再唯一。如果我们此刻在讨论“划分限界上下文”的原则,上面的答案就跟下面的问答类似:
问:“为什么铁球会落地?”
答:“因为万有引力。”
在物理界中,所有物理学家的终极梦想是发现一个宇宙通用的公式(就像“万有引力”能够解释“重力”一样),这个公式能够解释一切物理现象。且不论这个公式是否存在,即便存在,应该也是极其复杂的。在我们的设计过程中,不必用最高层的原则指导一切,那样指导具体设计时就会变得模糊。
推荐阅读
- 如何实现系统解耦
- 如何理解SOLID原则?
- 代码的简单设计五原则
- 用DDD指导微服务拆分
- DDD的战略设计和战术设计
- 写了这么多年代码,你真的了解设计模式么?
- 《Head First设计模式》
- 《重构:改善既有代码的设计》
- 《领域驱动设计:软件核心复杂性应对之道》
- 《架构整洁之道》
文/Thoughtworks 王万徳
原文链接:https://insights.thoughtworks.cn/architecture-design-principles-patterns/