设计模式(06)——设计原则(1)
设计原则
设计原则,是设计模式的内功心法,基本所有的设计模式都是基于设计原则进行的具体化,如果说设计模式是如何操作的话,那么设计原则就是为何这么做的基石,因此,只要我们能充分理解设计原则,那么在此基础上,对设计模式就能更好的理解,甚至能自己设计出一种设计模式来。
单一职责原则
定义
一个类或模块,只需要完成一个范围的功能,而不要搞得大而全。
场景
例如我们设计一个社交网站,现在要存储用户信息,类设计如下:
public class UserInfo {
private String name;
private String like;
private String location;
}
现在,我们想想该类的设计是否符合单一职责原则?
答案是可能符合,也可能不符合。那么判断依据是什么呢?
原因就是类或模块职责的判断是根据业务来定的,并没有一个普遍认同的规则。例如,如果该需要要的网站还提供卖东西的功能,那么用户的地址信息,就是一个十分关键的信息,且该块功能就需要抽离出来作为一个单独的模块,此时,地址信息放在这里就不合适了,违反了单一职责原则。
但如果地址信息,只是一个值对象,也就是说其只是一个展示属性,那么放在这里就是合适的。
综上所述,可以看到单一职责原则并不是设计出来就一成不变的,其需要结合业务发展的具体情况来判断。因此我们在设计之初,可以考虑一个大而全的类,但随着业务的发展需要,需要持续不断的进行优化(也就是持续重构的思想)。
用处
单一职责,因为类的设计比较小而精,因此可以极大提高代码的可维护性和可读性。
此外因为每个类或模块只涉及自己的功能部分,因此,也做到了高内聚。
其他
但类的设计也不是越单一越好,因为如果拆分的过细的话,可能上层一个类需要修改,会导致下层所有依赖其的类都要修改,又影响了代码的可维护性,因此还是要根据业务需要来合理评估,重点是感觉要对。
开闭原则
定义
字面意思,一个类的设计,应该要对拓展开放,对修改关闭。因此这里的重点就是以下定义该如何判断:
- 什么样的代码修改是拓展;
- 什么样的代码修改是修改;
- 修改代码就一定是违反了该原则吗
场景
public static void main(String[] args) {
Demo demo = new Demo();
demo.consume(1);
}
// 根据传递过来的级别来进行不同的会员逻辑判断
public void consume(int type) {
if (type == 1) {
Console.log("您好,1级会员!");
}
if (type == 2) {
Console.log("您好,2级会员!");
}
}
现在,又提出一个新的需求,还需要根据对应的会员等级进行对应的金额扣除,如果是上述的设计方式,那么修改的方式则是下面这样:
public void consume(int type, int price) {
if (type == 1) {
Console.log("您好,1级会员,扣除金额{}", price);
}
if (type == 2) {
Console.log("您好,2级会员,扣除金额{}", price);
}
}
很明显,这样的方式有问题,如果还要再传递一个字段,例如优惠比例,那么依照该方案,则还需要修改接口定义,这就意味着调用方都需要修改,测试用例也需要对应的修改。
那么如果按照开闭原则的话,该如何设计呢?
首先我们对代码进行下重构
// 将所有相关属性封装起来
public class Vip {
private int type;
private int price;
private int radio;
}
针对每种处理方式,根据他们的公有行为抽象出一个抽象层:
public interface VipHandler {
void consume(Vip vip);
}
每种特殊处理方式实现对应的抽象:
public class FirstVipHandler implements VipHandler {
@Override
public void consume(Vip vip) {
if (vip.getType() == 1) {
Console.log("您好,1级会员,扣除金额{}", vip.getPrice() * vip.getRadio());
}
}
}
public class SecondVipHandler implements VipHandler {
@Override
public void consume(Vip vip) {
if (vip.getType() == 2) {
Console.log("您好,2级会员,扣除金额{}", vip.getPrice() * vip.getRadio());
}
}
}
通过这样的处理方式,在每次接到新的任务后,就不需要重新修改原有的逻辑方法,可以直接进行拓展即可:
// 根据传递过来的级别来进行不同的会员逻辑判断
public void consume(Vip vip, VipHandler vipHandler) {
vipHandler.consume(vip);
}
其他
可以看到即使是上述的方式来拓展代码,仍旧会修改原有代码,那么这种方式是违反了开闭原则吗?
在这里,我们判断其并符合了开闭原则,因为我们判断是修改还是拓展,并不能只是简单的根据看是否修改了原有代码,真正核心的关键问题应该是:
- 改动没有破坏原有代码的正常运行;
- 改动没有破坏原有单元测试
**
在上述的修改后,我们如果加一种特殊的情况,并没有修改到原先的处理逻辑类,这也就意味着原先的代码不会引入一些可能的 bug,针对原始代码的测试用例也还是可以照常的进行编写,而不用再根据新的改动而进行改动。
用途
开闭原则的关键点是代码的可拓展性,即如何快速的拥抱变化,当每次新的任务来后,不必修改原始代码,而直接在原有的基础上进行拓展即可。
关闭修改是保持原有代码的稳定性。
里氏替换原则
定义
子类对象可以代替父类对象出现的任何地方,并保证原来程序逻辑行为不被破坏。
因为要保证子类对象不能破坏原有程序逻辑行为,因此该方式跟多态的区别是:如果子类进行了重写,并在重写的逻辑中加入了跟父类对应方法不同的逻辑,那么该方式可以称之为多态,但就不符合里氏替换原则了。
用途
该原则最重要的作用是指导子类的设计,保证在替换父类的时候,不改变原有程序的逻辑行为。
在这里,重点是逻辑行为的不改变,这就意味着,我们可以对实现的细节进行修改,只要保证业务含义不变,那么就是符合里氏替换原则的。
因此,针对这种情况,有一种用途是可以 改进 原有的实现,例如原先采用的排序算法比较低效,那么可以设计一个子类,然后重写对应排序算法,保证逻辑不发生变化。重点是,我们做的是改进,不管如何改,排序的业务含义是不变的。
实现方式,则是按照协议进行编程,关键是子类重写过程中,要保证不破坏原有函数声明的输入、输出、异常以及注释中罗列的任何特殊情况声明。
接口隔离原则
定义
首先,我们要对接口进行定义,明确其特殊含义,对于接口来说,我们将其分为两种类型的表现形式。
- 一种语法定义,其代表了一组方法的集合;
- 向外暴露的单个API接口或函数实现
下面,我们分别对其进行介绍。
场景:一组API聚合
public interface UserInfoService {
boolean login();
void getUserInfoByPhone();
void deleteUserByPhone();
}
看上述这个接口定义是否符合接口隔离原则???
其实这跟单一职责原则一样,也是要看业务发展的。例如,如果该接口是提供给后台管理系统来使用的,那么没有问题,作为一个后台系统的 admin 权限人员当然可以有很多操作的能力。
但如果该接口是给第三方用户来使用的话,就不是很合适了。因为删除操作是一个高权限能力,作为用户来说,一般是没有权限做的,那么在设计时,对应实现类就不应该实现全部接口内定义的方法,这就是接口隔离原则中所说的,不强制依赖接口中的所有方法。
**
而是,根据具体的定义,将其进行拆分,对应权限的实现类实现对应的权限行为。
场景:单个API接口或函数实现
public class Statistics {
private int max;
private int min;
private int sum;
//......
public Statistics count(List<Integer> data){
Statistics statistics = new Statistics();
// 计算 Statistics 中的每个值
return statistics;
}
}
首先,我们来看上述方法定义是否符合接口隔离原则???
在这里,我们还是要结合具体的业务场景才能做出结论,如果使用该函数的调用者,在大部分场景下都需要用到其中的大部分字段,那么该设计就是可以的。
但是如果每次只用到其中的几个,那么该设计就不合理了,其会浪费大量的无效计算能力,影响性能。在该场景下,就需要进行拆分。
public int max(List<Integer> data) {
return data.stream().max(Statistics::compare).get();
}
public int min(List<Integer> data) {
return data.stream().min(Statistics::compare).get();
}
依赖翻转原则
对于依赖翻转原则来说,有很多看着很像的定义,我们分别对其进行介绍,看看其都是什么含义,他们之间又有什么关联。
控制翻转(IOC)
控制翻转,针对是在原有的程序设计流程中,整个程序的运行流程是直接交由程序员来控制的,但是如果使用控制翻转的思想,则是在一个架子中,已经定义好了执行的流程,而只是预先定义好了拓展点,后续程序员所能修改的只有拓展点,开发人员在拓展点里添加相关业务逻辑即可。
public abstract class VipProcess {
public abstract boolean isVip();
public void consume() {
if (isVip()) {
Console.log("vip hello");
} else {
Console.log("get out");
}
}
}
public class Vip extends VipProcess {
@Override
public boolean isVip() {
return true;
}
public static void main(String[] args) {
VipProcess vip = new Vip();
vip.consume();
}
}
public class CommonPeople extends VipProcess {
@Override
public boolean isVip() {
return false;
}
public static void main(String[] args) {
VipProcess vip = new CommonPeople();
vip.consume();
}
}
上述方式,就是通过模板方法来实现的控制翻转,提供一个拓展的 isVip() 逻辑来交给程序员来实现,而框架根据实际实现的方法返回来决定下面的程序流转。
依赖注入(DI)
依赖注入更好理解,一句话概括:不用程序员显式通过 new
来创建对象,而是通过构造函数,函数传递的方式来传递对象。
即A类如果需要依赖B类,不是通过在 A 中 new 一个 B 出来,而是在外面创建好 B 后,传递给 A。通过这样的方式,可以在需求改变中,灵活的替换传递参数(B 实现 C 接口的话)。
而更进一步,现在一些框架都提供了 DI 的功能,只需要简单的配置一下相关类对象,所需参数等,框架就会自动接管对象的创建流程已经生命周期等。(AutoWired)
依赖翻转(DIP)
定义:高层模块不依赖于底层模块,而是通过一个抽象层来解耦。
该定义其实在我们的平常业务开发中,不怎么会用到,因为我们平常就是高层依赖着底层,例如 Controller 依赖 Service,Service 依赖 Repository,该原则的重点还是指导框架层面的开发。
例如 Tomcat ,我们知道 Tomcat 的运行是我们将程序写完后,打成 war 包扔到对应目录就可以启动了,而 Tomcat 和应用程序就是通过一个共同的抽象 **Servlet **来关联的。
Tomcat 不直接依赖于底层实现:Web 程序,而是跟 Web 都依赖于 Servlet,而 Servlet 不依赖于具体的Tomcat 实现的 Web 的具体细节。
通过该实现方式,在编码中,可以灵活的进行替换,比如我们还是一个 web 程序,但是运行的容器不使用 tomcat 了,也可以无缝的进行切换,只要保证要替换的容器,还是依赖于 Servlet 规范即可。