软件开发:软件设计的基本原则

用户头像
NORTH
关注
发布于: 2020 年 06 月 15 日
软件开发:软件设计的基本原则

软件设计的最终目的是让软件达到「高内聚,低耦合」,学习了许多设计原则来指导软件设计,也学习了许多设计模式来指导软件开发,但最终呈现出来的作品仍与高内聚和低耦合相去甚远,这到底是为什么呢?



对于软件系统来说,变化是唯一主题,软件的架构也需要根据业务的发展不断地升级迭代,因此,在架构设计之初只要满足当前或者未来一段时间内的业务需求即可。如果在初期就照搬业界公开方案或投入大量时间和资源来设计,要么投入巨大,要么无法落地,最终的结果可能都不甚令人满意。



架构要不断地在实际应用过程中迭代,保留优秀设计、修正错误设计、去掉无用设计,使得架构逐渐完善。如果你维护的系统在开发过程中出现下列的问题时,也就意味着,你需要考虑通过重构的方式来改善设计:

  • 僵化:单一的改动会导致依赖关系的模块中的连锁改动

  • 脆弱:对系统的改动会导致和改动无关的其他地方出现问题

  • 牢固:对系统中有用的部分,很难分离出来作为重用组件

  • 粘滞:做正确的事比做错误的事要困难,即面临一个改动时,有多种方法,有的方法会保持原来的设计,有的会破环,但破坏设计的方法要比保持设计的方法简单很多

  • 不必要的复杂:设计中包含当前没有用处的部分,大多的是开发人员错误的预测需求,在软件中写了大量处理潜在变化的代码

  • 不必要的重复:设计中包含重复的结构,但却没有进行抽象

  • 晦涩:阅读困难,难以理解



如果在开发中发现上述的一条或者多条问题,就应该考虑通过各种设计模式来重构系统。本周学习了挺多设计原则和设计模式,这里不会把这些一一列出,也没有这个必要,仅说几个自认为需要注意的点。

DIP原则与好莱坞原则

依赖倒置 ( DIP ) 原则即高层模块不能依赖低层模块,二者都应当依赖于抽象。在Java中,抽象指的就是接口或者抽象类,而具体的实现类则是细节。使用接口或者抽象类的目的是制定好契约和规范,而不去涉及任何具体的操作,把细节交给具体的实现类去完成。



因此,依赖倒置的核心思想是面向接口编程。比如,类A直接依赖于类B,如果要将A的依赖改为类C,则必须通过修改类A的代码来达成。这种场景下,一般类A为高层模块,负责业务逻辑;类B和C是低层模块,负责基本的原子操作;如果修改类A会给程序带来不必要的风险,那么如何在不修改类A的前提下,实现依赖的更改呢?



解决方法就是,将类A修改为依赖接口I,类B和C均实现接口I,类A通过接口I,间接与B和C发生联系,这样就会大大降低修改类A的几率。举个简单的例子,比如母亲该孩子讲故事,只要给她一本书,她就可以照着书给孩子讲故事:



class Book{
public String getContent(){
return "故事发生在很久很久以前...";
}
}
class Mother{
public void narrate(Book book){
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
}
}



假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的新闻,报纸的代码如下:



class Newspaper{
public String getContent(){
return "COVID-19";
}
}



这位母亲暂时办不到,因为她只会读书,如果要将书换成报纸,需要改Mother类才能办到。假如换成杂志,网页呢?需要不断的修改Mother类,这显然是不好的设计。原因就是Mother类与Book类耦合太高,这时只需要引入一个接口IReader即可。



interface IReader{
public String getContent();
}
class Newspaper implements IReader {
public String getContent(){
return "COVID-19";
}
}
class Book implements IReader{
public String getContent(){
return "故事发生在很久很久以前...";
}
}
class Mother{
public void narrate(IReader reader){
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.narrate(new Book());
mother.narrate(new Newspaper());
}
}



这样修改以后,无论怎样扩展Client类,都不需要在修改Mother类。采用依赖倒置原则可以有效降低类之间的耦合性,提高系统的稳定性和可扩展性。



而好莱坞原则:Don't call us, We will call you,即:别调用(打电话给)我们,我们会调用(打电话给)你。



准确的说,我一直不能理解为什么要把DIP与好莱坞法则划等号,拿DIP来说,上层模块直接依赖于下层模块,还是通过接口间接依赖于下层模块,跟好莱坞法则有什么关系吗?如果上层模块对下层模块说:Don't call us, We will call you,非常符合这句话的字面意思,但这不是正确的废话吗?你看到过谁在Service中调用Controller了吗?



因此,从字面意义上讲,回调其实更符合好莱坞原则,当上层组件想完成某项任务的时候,通知下层组件,下层组件在完成任务后,再回调上层组件,也不就说,你不要调用我的逻辑,待我完成任务后,再回调你吗?



依赖倒置原则教我们尽量避免使用具体的类,更多的使用抽象。而好莱坞原则是用在创建框架或者组件上的一种技巧,让低层组件能够以某种方式倒挂进上层组件中,而又不让上层组件依赖于低层组件。两者的目的都是为了解耦,但依赖倒置更加注重如何在设计中避免依赖。这样看,Spring中的IoC其实更符合好莱坞法则的指导思想,尽管在很多时候,大多数人都把这两者划等号。



这里简单说说Spring的IoC,通常来说,如果我们依赖于某个类或者服务,最简单且有效的方式就是直接在类中声明依赖类并在构造方法中创建依赖类的对象。这就好比用餐,通常可以自己下厨房,也可以点外卖,但不管使用哪种方式,有一个共同点需要我们关注,那就是,我们需要自己主动的获取依赖的对象。



可仔细想想,是否真的有必要每次用到什么依赖对象都要主动去获取?我们最终需要的,只不过是直接调用依赖对象所提供的服务而已。如果我们用到某个对象的时候,它能够准备就绪,我们完全可以不用管这个对象是自己创建的还是别人送过来的。就如果前面说到的用餐,只要我想吃的时候,饭菜已经在桌上就可以了,我根本无需关心它们是自己做的,还是外卖送来的。这就是Spring的IoC,它让你从原来的事必躬亲,转变为现在的享受服务。



总结起来,依赖倒置更多的指导我们日常的设计开发,而好莱坞原则则更倾向于指导我们创建组件或者框架。

OCP:开/闭原则

OCP(Open/Closed Principle)原则即对扩展开放,对修改封闭,简单来说,在不修改现有代码的情况下,就可以实现软件功能的扩展,这样的软件就具有弹性,可以应对改变。OCP是面向对象设计中最基础的设计原则,也是定义最为模糊的原则,那怎么把这个原则应用到实际开发中?



举个简单的例子,书店里有很多书籍,每本书均有价格、名称、作者等属性,其大致的设计如下图所示。





前期系统运行良好,但某天,书店想搞促销活动,对所有50元以上的书籍8折销售,其他书籍9折销售。那我们如何应对这样的一个需求?先看如下两种方法:



  • 修改接口:IBook上新增一个getDiscountPrice()方法,专门用于获取打折后的价格

  • 修改实现类:在每个类的getPrice()方法中,增加打折处理的逻辑



前者因为修改的接口,其所有实现类也需要做相应的修改,其客户端逻辑也需要一同修改,至少以前使用getPrice()的地方要换成getDiscountPrice()。因此,这并不是一个好办法,IBook作为抽象,其应当保持稳定且可靠,不应当经常发生变化。试想,如果某一天不打折了,恢复原价,那所有代码又得改回去了。



后者可能是我们最常使用的方法,算是一种不错的方案,但仍然有缺陷。最明显的就是,所有人看到的价格都是打折后的价格,比如采购人员想查看书籍的实际价格,就没有办法满足。



来看第三种方法,即通过扩展实现,增加一个子类DiscountNovel,覆写getPrice方法,高层次模块通过这个子类生成新对象,完成新的需求。





可能大家会觉得,对每个类都增加子类,且上层次的业务逻辑需要同步更新,这代码量不比前面两种方法少吧。



是的,但大家要避免的一个误区是以代码量来评判一个设计的好坏。虽然第三种方案修改了挺多代码,但我们并没有修改原有的模块代码,IBook接口没有改变,Novel类没有改变,这属于已有的业务代码,我们保持了历史的稳定性。而上层次的代码属于业务逻辑的封装,在业务规则更改情况下,修改这部分代码以适应新业务,没什么可说的。



因为这里的例子很简单,第三种设计相较于前两种的优点没有太突出,但大家可以稍微联系下实际的场景,就比如第二种方法,你真的不知道系统有多少地方在调用getPrice()方法,如果你轻易的改变其中的逻辑,也许会对系统造成极大的影响,这也极大的增加了变化风险的扩散。



开闭原则的核心是:用抽象构建框架,用实现扩展细节。抽象作为一种契约,应当尽量保持稳定。一种有效的方法是尽量与现实世界同步。比如,现实世界一本书可能就有多种价格(进货价、销售价、贴牌价),那么在抽象时就应当尽量反映这种现实。而想要达到用实现扩展细节,一种简单的办法就是放弃修改已有代码的想法

过度设计

单一职责原则告诉我们实现类的职责要单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;而开闭原则作为总纲,告诉我们要对扩展开放,对修改关闭。



那我们在设计或者开发时,一定要遵守这些规则吗?



没有必要,比如,需求是想要一个加法计算器,你设计出一个科学计算器,强大且满足未来一切计算需求,但真没必要。



我其实看到挺多代码,在设计之初想要满足未来一段时间的需求,各种设计模式都给堆上去,各种框架特性也都用上去,但每当有新需求,还是需要从上到下修改一遍。有时候你很难区分这是过度设计还是没设计好,但我在工作中一直遵循的一条原则是:可以通过我的代码来理解需求,但最坏的情况是,你了解需求后,不需要我的任何讲解,就可以看懂我的代码

最后

一个良好的设计,并不是要用多少设计模式,也并不是要遵循多少设计原则,这些都是用来指导而不是束缚我们的。



极客大学架构师训练营第二周学习总结

参考资料



设计模式六大原则:开闭原则

大话设计模式:第6章-开闭原则

发布于: 2020 年 06 月 15 日 阅读数: 125
用户头像

NORTH

关注

Because, I love. 2017.10.16 加入

这里本来应该有简介的,但我还没想好 ( 另外,所有文章会同步更新到公众号:时光虚度指南,欢迎关注 ) 。

评论 (2 条评论)

发布
用户头像
这个作业做得太认真了吧。
2020 年 06 月 15 日 11:50
回复
没怎么总结课堂内容,只是写了些个人感想罢了,也不一定对
2020 年 06 月 15 日 15:54
回复
没有更多了
软件开发:软件设计的基本原则