目录
设计原则总结
单一职责原则
概念:一个类或者模块只负责完成一个职责(或者功能)
作用:
提高类的内聚性
实现代码的高内聚,低耦合
不满足情况:
类中的代码行数 函数 或者属性过多
类依赖的其他的类过多
私有方法过多
类中的大量方法总是操作类中的几个属性
场景示例:
在一个社交媒体产品中,我们使用 UserInfo 去记录用户的信息,包括如下的属性
请问上面的 UserInfo 类是否满足单一职责原则呢 ?
观点1: 满足,因为记录的都是跟用户相关的信息
观点2: 不满足,因为地址信息应该被拆分出来,单独放到地址表中保存.
正确答案: 根据实际业务场景选择是否拆分
该社交产品的用户信息只是用来展示的,那么这个类这样设计就没有问题
假设后面这个社交产品又添加了电商模块, 那就需要将地址信息提取出来,单独设计一个类
总结: 不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的,最好的方式就是:
我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
开闭原则:所有设计模式的最核心目标
概念:开闭原则规定软件中的对象、类、模块和函数对扩展应该是开放的,但对于修改是封闭的。这意味着应该用抽象定义结构,用具体实现扩展细节,以此确保软件系统开发和维护过程的可靠性,通俗讲就是对扩展开放,对修改关闭。
并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发
作用:
新老逻辑解耦,需求发生改变不会影响老业务的逻辑
改动成本最小,只需要追加新逻辑,不需要改的老逻辑
提供代码的稳定性和扩展性
场景示例:
系统A与系统B之间进行数据传输使用的是427版本的协议,一年以后对427版本的协议进行了修正。
设计时应该考虑的数据传输协议的可变性,抽象出具有报文解译、编制、校验等所有版本协议使用的通用方法,调用方针对接口进行编程即可,如上述示例设计类图如下
调用方依赖于报文接口,报文接口是稳定的,而不针对具体的427协议或427修正协议。利用接口多态技术,实现了开闭原则。
如何做到开闭原则?
顶层思维:扩展、抽象、封装
提高代码扩展性的方式:多态、依赖注入、面向接口编程、合理使用设计模式
里氏替换原则
概念:如果S是T的子类型,对于S类型的任意对象,如果将他们看作是T类型的对象,则对象的行为也理应与期望的行为一致。
子类对象能够替换程序中父类对象出现的任何对方,并且保证程序的逻辑行为不变及正确性不被破坏,即子类可以扩展父类的功能,但不能改变父类原有的功能(子类尽量不要重写父类的方法)。
1 ) 什么是替换 ?
替换的前提是面向对象语言所支持的多态特性,同一个行为具有多个不同表现形式或形态的能力。
以JDK的集合框架为例,
List
接口的定义为有序集合,List
接口有多个派生类,比如大家耳熟能详的ArrayList
,LinkedList
。那当某个方法参数或变量是List
接口类型时,既可以是ArrayList
的实现, 也可以是LinkedList
的实现,这就是替换。2 ) 什么是与期望行为一致的替换?
在不了解派生类的情况下,仅通过接口或基类的方法,即可清楚的知道方法的行为,而不管哪种派生类的实现,都与接口或基类方法的期望行为一致。
不需要关心是哪个类对接口进行了实现,因为不管底层如何实现,最终的结果都会符合接口中关于方法的描述(也就是与接口中方法的期望行为一致).
或者说接口或基类的方法是一种契约,使用方按照这个契约来使用,派生类也按照这个契约来实现。这就是与期望行为一致的替换。
作用:为良好的继承定义了一个规范;提高代码的健壮性,降低程序出错的可能性
场景示例:
里氏替换原则要求我们在编码时使用基类或接口去定义对象变量,使用时可以由具体实现对象进行赋值,实现变化的多样性,完成代码对修改的封闭,扩展的开放。
比如在一个商城项目中, 定义结算接口 Istrategy,该接口有三个具体实现类,分别为 PromotionalStrategy (满减活动,两百以上百八折)、RebateStrategy (打折活动)、 ReduceStrategy(返现活动)
public interface Istrategy { public double realPrice(double consumePrice); } public class PromotionalStrategy implements Istrategy { public double realPrice(double consumePrice) { if (consumePrice > 200) { return 200 + (consumePrice - 200) * 0.8; } else { return consumePrice; } } } public class RebateStrategy implements Istrategy { private final double rate; public RebateStrategy() { this.rate = 0.8; } public double realPrice(double consumePrice) { return consumePrice * this.rate; } } public class ReduceStrategy implements Istrategy { public double realPrice(double consumePrice) { if (consumePrice >= 1000) { return consumePrice - 200; } else { return consumePrice; } } }
调用方为Context,在此类中使用接口定义了一个对象。
public class Context { //使用基类定义对象变量 private Istrategy strategy; // 注入当前活动使用的具体对象 public void setStrategy(Istrategy strategy) { this.strategy = strategy; } // 计算并返回费用 public double cul(double consumePrice) { // 使用具体商品促销策略获得实际消费金额 double realPrice = this.strategy.realPrice(consumePrice); // 格式化保留小数点后1位,即:精确到角 BigDecimal bd = new BigDecimal(realPrice); bd = bd.setScale(1, BigDecimal.ROUND_DOWN); return bd.doubleValue(); } }
Context 中代码使用接口定义对象变量,这个对象变量可以是实现了 lStrategy 接口的 PromotionalStrategy、RebateStrategy 、 ReduceStrategy任意一个。
与多态的区别:
多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现思路。
里氏替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证再替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
接口隔离原则
概念:一个类对另一个类的依赖应该建立在最小的接口上,要为各类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
作用:提高系统的灵活性和可维护性,减少项目工程中的代码冗余
场景示例:
微服务用户系统提供了一组跟用户相关的 API 给其他系统 使用,比如:注册、登录、获取用户信息等。
需求:后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口,应该如何设计这个接口(假设这里我们不去考虑使用鉴权框架).
方案1:直接在 UserService 接口中添加一个删除用户的接口
这个方法可以解决问题,但是也隐藏了一些安全隐患。删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。
方案2:遵照接口隔离原则,为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。
将删除接口单独放到另外 一个接口 RestrictedUserService 中, 然后将 RestrictedUserService 只打包提供给后台管理系统来使用。
遵循接口隔离原则的优势:
将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
与单一职责的区别:
单一职责注重的是职责,而接口隔离则注重的是对接口依赖的隔离
单一职责主要是约束类,它针对的是程序中的实现和细节;而接口隔离主要约束接口,针对抽象和程序整体框架的构建。
依赖倒置原则
概念:高层模块不应该依赖于底层模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象(细节具有多变性,抽象层相对稳定)
作用:
减少类间的耦合性,提高系统的稳定性
降低并行开发引起的风险
提高代码的可读性和可维护性
场景示例:
假设我们现在要组装一台电脑,需要的配件有 cpu,硬盘,内存条。只有这些配置都有了,计算机才能正常的运行。选择cpu有很多选择,如Intel,AMD等,硬盘可以选择希捷,西数等,内存条可以选择金士顿,海盗船等。
反例:
修改:代码我们需要修改Computer类,让Computer类依赖抽象(各个配件的接口),而不是依赖于各个组件具体的实现类。
类图如下:
关于依赖倒置、依赖注入、控制反转这三者之间的区别与联系
-
依赖倒置原则:是一种通用的软件设计原则, 主要用来指导框架层面的设计。
高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。
-
控制反转:控制反转与依赖倒置有一些相似, 它也是一种框架设计常用的模式,但并不是具体的方法。
“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
Spring框架,核心模块IoC容器,就是通过控制反转这一种思想进行设计的
-
依赖注入:是实现控制反转的一个手段,它是一种具体的编码技巧。
我们不通过 new 的方式在类内部创建依赖的对象,而是将依赖的对象在外部创建好之后,通过构造函数等 方式传递(或注入)进来, 给类来使用。
依赖注入真正实现了面向接口编程的愿景,可以很方便地替换同一接口的不同实现,而不会影响到依赖这个接口的客户端。
迪米特法则
概念:指一个类/模块对其他的类/模块优越少的了解越好
(最少知道原则):指一个对象类对于其他对象类来说,知道的越少越好。两个类之间,不要有过多的耦合关系,保持最少的关联性。
作用:如果有两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发调用,其目的是降低类之间的耦合度,提高模块的相对独立性。
场景示例:
明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如和粉丝的见面会,和媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则。
注意:过度使用会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在使用时需要反复权衡,确保高内聚低耦合的同时,保证系统结构清晰。
设计原则总结
评判代码质量的标准,比如可读性、可复用性、可扩展性等,这是从代码的整体质量的角度来评判。而设计原则就是我们要使用到的更加具体的对于代码进行评判的标准,比如, 我们说这段代码的可扩展性比较差,主要原因是违背了开闭原则。
比较常用的三个原则
1 ) 单一职责原则
单一职责原则是类职责划分的重要参考依据,是保证代码”高内聚“的有效手段,是我们在进行面向对象设计时的主要指导原则。
单一职责原则的难点在于,对代码职责是否足够单一的判定。这要根据具体的场景来具体分析。同一个类的设计,在不同的场景下,对职责是否单一的判定,可能是不同的。
2 ) 开闭原则
开闭原则是保证代码可扩展性的重要指导原则,是对代码扩展性的具体解读。很多设计模式诞生的初衷都是为了提高代码的扩展性,都是以满足开闭原则为设计目的的。
开闭原则是所有设计模式的最核心目标,也是最难实现的目标,但是所有的软件设计模式都应该以开闭原则当作标准,才能使软件更加的稳定和健壮。
3 ) 依赖倒置原则
依赖倒置原则主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。
依赖倒置原则其实也是实现开闭原则的重要途径之一,它降低了类之间的耦合,提高了系统的稳定性和可维护性,同时这样的代码一般更易读,且便于传承。