聊聊面向对象的设计(OOD)原则
一、五个原则
单一职责原则(SRP)
开放-封闭原则(OCP)
里式(Liskov)替换原则(LSP)
依赖倒置原则(DIP)
接口隔离原则(ISP)
这五个原则是面向对象设计的精髓,值得我们仔细体会。
1. 单一职责原则(SRP)
1.1 定义
就一个类而言,应该仅有一个引起它变化的原因。
即使我们不了解这个原则,在工作中也听过类和方法代码太多就该拆分的忠告,《重构:改善既有代码的设计》这本经典书籍也详细讲解了类和方法拆分的方法,其实都是SRP的落地实践。
1.2 违反原则的后果
如果一个类承担过多的职责,就等于将这些职责都耦合在一起,任何一个职责的变化都可能造成一个类的变化,也可能引起其他职责的变化。这种耦合导致系统的脆弱性。
1.4 遵守原则的挑战
如何界定一个类的职责
界定一个类的职责并不是一件容易的事情,通常我们不会在订单类中包含用户注册的业务,但是是否要包含产品价格计算和打折促销等业务就需要根据场景考虑。
职责会变化
职责的界定是根据类的试用场景界定的,是一个动态过程,所以场景变化也会导致类的职责的变化。当我们发现这种场景变化时需要对类的职责重新划分。
类不可拆分职责如何处理
总有一些类的职责不应该在一起但是他们共同依赖类的某些属性作为上线文(类似于课程中的Cache类),所以致使他们无法被拆分,这时候我们只能依靠接口隔离原则去处理这类问题。
2. 开放-封闭原则(OCP)
2.1 定义
软件实体(类、模板、函数等等)应该可以扩展的,但是不可以修改。
对扩展开放
对更改关闭
需求会随时随地新增,当新增一个新的需求时,如果我们遵循了OCP,我们无需对已经发布的代码进行任何修改,只用新增代码就可满足新的需求,因为我们没有对原有代码进行任何改动也不会对原有业务造成任何影响,无需额外的回归测试,保证了系统的稳定性。
反之,只要新增功能我们就会修改已有代码,影响范围不固定,每次都需要某些范围的回归测试。
2.2 如何实现
OCP实现的关键在于“抽象”,面向抽象编程。
我们对各种图形的绘制行为进行了抽象,绘图板只依赖于这个抽象行为,这样一旦有新的图形绘制需求,我们直接实现Shape接口即可。
我们将可变化的点进行“抽象”,“抽象”的变化用“具体实现”来体现,这样就可以做到对“抽象”封闭,对“实现”开放。
2.3 遵守原则的挑战
仅做必要的抽象
我们只对当前业务中频繁变化的点进行抽象,我们不能抽象所有业务,否则我们将“寸步难行”。
难以预测变化
要保证未来系统OCP,我们是将有可能变化的地方进行抽象处理的,要对变化进行预测。一旦预测的变化点没有发生就是过度设计(大多数情况)。所以现实中尽量在变化真的发生的时候调整架构可能更加合理。如果我们的Paint只有绘制矩形的需求,我们也没有必要上来就让他支持绘制无线图形的能力,真的有需求的时候在改造也不是未尝不可。大多数情况下亡羊补牢也不差。
3. Liskov替换原则(LSP)
3.1 定义
子类型必须能够替代掉他们的基类型
LSP是OCP成为可能的主要原则之一,正是子类型的可替代性才使得使用基类型的模块在无需改动的情况下就可以扩展。
3.2 遵守原则的挑战
子类一定不能对父类的行为进行限制
基于正方形是特殊矩形假设将正方形定位为矩形的子类的行为就违反了以上原则,正方形设置长和宽行为限制了矩形的行为设置长和宽的行为(正方形长、宽必须相等)
IS-A要考虑应用场景
依然基于上面正方形和矩形的例子,从静态逻辑上似乎可以使用继承,但当考虑动态的场景时(计算面积)
,就发现违反LSP的地方。所以慎用继承,除非考虑到必要的场景。
4. 依赖倒置原则(DIP)
4.1 定义
高层模块不应该依赖于低层模块,二者都应该依赖于抽象
抽象不应该依赖于细节,细节应该依赖于抽象
4.2 倒置了什么?
如图所示,上层模块和低层模块都依赖与抽象。
倒置了上下层的依赖关系
上层模块不在依赖于低层具体实现,两者都依赖与上层抽象接口。
倒置了接口所有权和定义权
是上层根据具体的业务定义接口,低层根据业务实现接口定义。而不是低层现有能力抽象为接口供低层使用。从业务驱动的角度这种方式更加合理。
DIP和OCP类似,两者都是使用策略模式实现的,只是前置强调模块间的依赖关系,后者强调接口扩展性,实质殊途同归。
如果想对DIP进一步了解可以参篇另一篇文章:https://xie.infoq.cn/article/b8e58239859b9800ad8e2b763
4.3 遵守原则的挑战
抽象很关键
DIP的核心在于高层策略的抽象,抽象应该是不随具体细节改变而改变的逻辑,是系统中不变的部分。所以问题转换为将不变的策略抽象出来,变动的部分交给具体实现。分析一个系统当下和未来固定点和变化点是系统架构的核心诉求。
5. 接口隔离原则(ISP)
5.1 定义
ISP承认存在一些对象,他们确实不需要内聚的接口,但是ISP建议客户程序不应该看到他们作为一个单一的类存在。相反,客户程序看到的应该是多个具有内聚接口的抽象基类。
5.2 示例
Cache类中reBuild方法因为依赖Config确实无法在Cache之外实现,所以违反了SRP原则,但是可以用ISP原则保证Client通过CacheOperation抽象使用Cache类,reBuild方法对Client不可见,也就避免了误操作的可能性。
6. 五个原则之间的关系
ISP是SRP的补充,一旦我们不得不违反SPR的时候,至少应该遵守ISP来弥补
OCP和DIP都是借用策略模式实现的,两个原则都需要很好的抽象能力,将固定的可复用逻辑进行抽象是开发可扩展系统的关键。
LSP是其他原则的核心依赖。
二、实践中的一些思考(三层架构场景下)
1. 三层架构场景
前后端分离场景下,只讨论Controller、Service和DAO三层
Bean均是贫血模型,即只有属性没有方法,只用来在层与层之间传递数据,
2. 三层架构场景中的原则的影子
如图所示,从某种意义上来说,三层架构实现了DIP(毕竟我们都用Spring框架开发),也符合OCP规则的,但是我们真的享受到了遵循原则带来的好处了吗,值得商榷
接口定义权倒置了吗?
对于DIP很关键的一点是接口定义由高层模块负责。一个开发人员倒无所谓,反正上下层都有自己定义。但是如果有两个人同时开发,接口定义权应该有上层负责,这样才是面向业务的开发模式。
Service到底是什么的抽象?
其实在实际业务中,我们通常按照领域模型进行划分,会诞生出UserService和OrderService等抽象维度,这个维度过大,包含了一个领域模型涉及到的所有逻辑,都不能称之为抽象,只能一种业务划分模式,所以并没有体现出DIP或OCP的可扩展性的优势。需求变更的时候还是造成联动修改。
这种划分的模式相对简单,但是在这种抽象之外,我们还是要抽象出稳定的业务逻辑,真正享受DIP和OCP带来的便利
DAO层真的需要扩展吗?
对于单纯的数据库处理来说,我们真的需要扩展吗?扩展什么?数据源MySQL到Oracle,更换持久层框架,从Mybatis换成JPA,这些可能不是没有,但是并不大,那为什么还要Service层和DAO层依赖抽象呢?这又算算不算一种过渡设计呢?还是只是一种Spring框架使用习惯呢?
Service层是否违反了SRP?
如果按照粗粒度的划分原则,Service的改动点确实很多。根本原因在于Service层没有进行抽象,只是业务罗列。
Service层如何改造?
个人理解,Service层这种按领域业务划分的方式还是有一定的好处,业务比较清晰,上手也不难。真正按照DIP和OCP原则来改造还是比较困难的。但是在实际的工作我们依然可以尝试做一些高层的抽象,将真正不变的逻辑抽象出来,真正享受原则带给我们的好处。
版权声明: 本文为 InfoQ 作者【Jerry Tse】的原创文章。
原文链接:【http://xie.infoq.cn/article/d3ca8dd45c5e7e47ac33cde22】。文章转载请联系作者。
评论