今天我们一起来聊聊关于设计原则相关的知识点。

SOLID五大原则是什么

SRP 单一责任原则

单一责任原则,从名字上我们就能比较好的去理解它。这项原则主张一个对象只专注于单个方面的逻辑,强调了职责的专一性。

举个例子:

学生管理系统中,我们需要提交一些学生的基本资料,那么学生信息相关的程序都交给了StudentService负责,如果我们要实现一个保存教师基本资料的功能就应该新建一个TeacherService去处理,而不应该写在StudentService当中。

OCP开放封闭原则

这项原则从我个人的角度去理解,它更加强调的是对于扩展的开放性,例如当我们需要调整某些实现逻辑的时候,尽量不要直接改动到原有的实现点。

但是这里面有几个点容易被人们误解:

第一点

开放封闭原则虽然强调的是不要随意改动代原先代码到逻辑结构,但是并没有要求一定不能对代码进行改动!

第二点

同样是代码改动,如果我们可以从功能,模块的角度去看,实际上代码的改动更多地可以被认作为是一种“扩展”。

关于如何做到开放封闭原则,下文我会专门用一个案例来进行介绍。

LSP里氏替换原则

里氏替换原则强调的是不能破坏一个原有类设计的原始设计体系。强调了子类可以对父类程序进行继承。但是有几个点需要注意下:

如果父类定义的规则最好是最基础,必须遵守的法则。如果子类继承了父类之后,在某个方法的实现上违背了初衷,那么这样的设计就是违背了里氏替换法则。

例如:

父类的设计是希望实现商品库存扣减的功能,但是子类的实现却是实现了库存+1的功能,这就很明显是牛头不对马嘴了。

子类不要违背父类对于入参,出参,异常方面的约定。例如:父类对于异常的抛出指定的是 NullPointException ,但是子类却在实现的时候声明了会出 illegalArgumentException,那么此时就需要注意到设计已经违背了LSP原则。

同样,具体的案例我在下文会列举出来和大家进行代码分享。

ISP接口隔离原则

理解“接口隔离原则”的重点是理解其中的“接口”二字。

这里有三种不同的理解。如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。

如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。

DIP依赖倒置原则

比较经典的例子,例如说Spring框架的IOC控制反转,将bean的管理交给了Spring容器去托管。依赖注入则是指不通过明确的new对象的方式来在类中创建类,而是提前将类创建好,然后通过构造函数,setter函数等方式将对应的类注入到所需使用的对象当中。

DIP的英文解释大致为:

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

解释过来就是,高层次的模块不应该依赖低层次的模块,不同的模块之间应该通过接口来互相访问,而并非直接访问到对方的具体实现。

清楚了这么多理论知识之后,接下来我们通过一些代码实战案例来进行更加深入的了解吧。

单一责任原则案例

我们来看这么一个类,简单的一个用户信息类中,包含了一个叫做home的字段,这个字段主要用于记录用户所居住的位置。

public class UserInfo {

    private String username;

    private short age;

    private short height;

    private String phone;

    private String home;

}

慢慢地随着业务的发展,这个实体类中的home字段开始进行了扩展,UserINfo类变成了以下模式:

public class UserInfo {
private String username;
private short age;
private short height;
private String phone;
private String home;
/**
* 省份
*/
private String province;
/**
* 城市
*/
private String city;
/**
* 地区
*/
private String region;
/**
* 街道
*/
private String street;
}

此时对于这个实体类的设计就会有了新的观点:

这个类中关于居住部分的字段开始渐渐增加,应该将住址部分抽象出来成一个Address字段,拆分后变成如下所示:

public class UserInfo {

    private String username;
private short age;
private short height;
private String phone;
private String home;
/**地址信息**/
private Address address; }

这样的拆分可以确保UserInfo对象的职责单一,类似的扩展还可以蔓延到后续的email,tel相关属性。

举这个例子只是想简单说明,我们在对一些类进行设计的时候,其实就已经使用到了单一责任原则。另外还有可能在以下场景中也有运用到该原则:

类中的属性字段特别多,一个bean中充斥了几十个属性。此时也可以尝试使用单一责任原则,将不同属性的字段归纳为一个bean进行收拢。

一个大对象,例如XXXManager或者XXXContext这种名词定义的对象中,可能引入了一大堆的外部依赖,此时可以按照依赖的类别来进行拆分。

业务代码块中,我们定义了一个UserService类,然后这个类里面写了一坨的用户密码,手机号,身份证号解密加密相关的私有函数,这时候可以不妨尝试将这些私有方法统统抽象成为一个独立的Util当中,从而减少UserService中的代码量。

所以最终你会发现,单一责任原则还是一个比较需要依靠主观意识去拿捏的一项技巧。随着我们实践开发经验的逐渐提升,自然就会明白什么样的代码该进行良好的抽象与优化了。

开放封闭原则案例

关于这条原则我个人感觉要想较好地理解它,需要有具体的实战案例代码,所以接下来我打算用一个自己曾经在工作中遇到的实际场景和你分享:

我做的一款社交小程序应用当中,当一个用户注册完信息之后,需要通知到系统下游,主要是修改某些后台数据,分配对应的员工去跟进这个用户。

所以大体的代码设计可能如下所示:

public class RegisterHandler {
public void postProcessorAfterRegister(long userId){
//通知员工
notifyWorker(userId);
} private void notifyWorker(long userId){
//通知部分的逻辑
}
}
public interface IRegisterHandler {
/**
* 用户注册之后处理函数
*
* @param userId 用户渠道ID
*/
void postProcessorAfterRegister(long userId);
}

但是注册的渠道类型有许多种,例如公众号,小程序二维码传播,小程序的分享链接,其他App渠道等等。所以代码结构需要做部分调整:

首先需要修改一开始设计的接口模型:

public interface IRegisterHandler {
/**
* 用户注册之后处理函数
*
* @param userId 用户ID
* @param sourceId 注册渠道ID
*/
void postProcessorAfterRegister(long userId,int sourceId);
}

然后还需要修改实际的实现规则:

public class RegisterHandlerImpl implements IRegisterHandler {
@Override
public void postProcessorAfterRegister(long userId, int sourceId) {
//通知员工
if (sourceId == 1) {
//doSth
} else if (sourceId == 2) {
//doSth
} else if (sourceId == 3) {
//doSth
} else {
//doSth
}
notifyWorker(userId, sourceId);
} private void notifyWorker(long userId, int sourceId) {
//通知部分的逻辑
}
}

这样的代码扩展就会对原先定义好的结构造成破坏,也就不满足我们所认识的开放封闭原则了。(虽然我在上文中有提及过对于开放封闭原则来说,并不是强制要求不对代码进行修改,但是现在的这种扩展模式已经对内部结构造成了较大的伤害。)

所以我们可以换一种设计思路去实现。

首先我们需要将注册的传入参数定义为一个对象类型,这样在后续新增参数的时候只需调整对象内部的字段即可,不会对原有接口的设计造成影响:

public class RegisterInputParam {

    private long userId;

    private int source;

    public long getUserId() {
return userId;
} public void setUserId(long userId) {
this.userId = userId;
} public int getSource() {
return source;
} public void setSource(int source) {
this.source = source;
}
}

接着可以将注册逻辑拆解为注册处理器和使用注册处理器的service模块:

public interface IRegisterService {
/**
* 用户注册之后处理函数
*
* @param registerInputParam 用户注册之后的传入参数
*/
void postProcessorAfterRegister(RegisterInputParam registerInputParam);
}

注册处理器内部才是真正的核心部分:

public abstract class AbstractRegisterHandler {
/**
* 获取注册渠道ID
*
* @return
*/
public abstract int getSource(); /**
* 注册之后的核心通知模块程序
*
* @param registerInputParam
* @return
*/
public abstract boolean doPostProcessorAfterRegister(RegisterInputParam registerInputParam); }

具体的实现交给了各个Handler组件:

公众号注册渠道的后置处理器

public class GZHRegisterHandler  extends AbstractRegisterHandler {

    @Override
public int getSource() {
return RegisterConstants.RegisterEnum.GZH_CHANNEL.getCode();
} @Override
public boolean doPostProcessorAfterRegister(RegisterInputParam registerInputParam) {
System.out.println("公众号处理逻辑");
return true;
}
}

app注册渠道的后置处理器

public class AppRegisterHandler extends AbstractRegisterHandler {

    @Override
public int getSource() {
return RegisterConstants.RegisterEnum.APP_CHANNEL.getCode();
} @Override
public boolean doPostProcessorAfterRegister(RegisterInputParam registerInputParam) {
System.out.println("app处理逻辑");
return true;
}
}

不同的注册渠道号通过一个枚举来进行管理:

public class RegisterConstants {

    public enum RegisterEnum{

        GZH_CHANNEL(0,"公众号渠道"),
APP_CHANNEL(1,"app渠道"); RegisterEnum(int code, String desc) {
this.code = code;
this.desc = desc;
} int code;
String desc; public int getCode() {
return code;
}
}
}

接下来,对于注册的后置处理服务接口进行实现:

public class RegisterServiceImpl implements IRegisterService {

    private static List registerHandlerList = new ArrayList<>();

    static {
registerHandlerList.add(new GZHRegisterHandler());
registerHandlerList.add(new AppRegisterHandler());
} @Override
public void postProcessorAfterRegister(RegisterInputParam registerInputParam) {
for (AbstractRegisterHandler abstractRegisterHandler : registerHandlerList) {
if(abstractRegisterHandler.getSource()==registerInputParam.getSource()){
abstractRegisterHandler.doPostProcessorAfterRegister(registerInputParam);
return;
}
}
throw new RuntimeException("未知注册渠道号");
} }

最后通过简单的一段测试程序:

public class TestDesignPrinciple {
public static void main(String[] args) {
RegisterInputParam registerInputParam = new RegisterInputParam();
registerInputParam.setUserId(10012);
registerInputParam.setSource(0); IRegisterService registerService = new RegisterServiceImpl();
registerService.postProcessorAfterRegister(registerInputParam); RegisterInputParam registerInputParam2 = new RegisterInputParam();
registerInputParam2.setUserId(10013);
registerInputParam2.setSource(1);
registerService.postProcessorAfterRegister(registerInputParam2); System.out.println("======="); }
}

这样的设计和起初最先前的设计相比有几处不同的完善点:

新增不同注册渠道的时候,只需要关心注册渠道的source参数。

同时对于后续业务的拓展,新增不同的注册渠道的时候,RegisterServiceImpl只需要添加新编写的注册处理器类即可。

再回过头来看,这样的一段代码设计是否满足了开放封闭原则呢?

每次新增不同的注册类型处理逻辑之后,程序中都只需要新增一种Handler处理器,这种处理器对于原先的业务代码并没有过多的修改,从整体设计的角度来看,并没有对原有的代码结构造成影响,而且灵活度相比之前有所提高。这也正好对应了,对扩展开放,对修改关闭。

如果你对设计模式有一定了解的话,可能还会发现大多数常用的设计模式都在遵守这一项原则,例如模版模式,策略模式,责任链模式等等。

里氏替换原则

我认为,里氏替换原则更多是体现在了父子类继承方面,强调的是子类在继承了父类对象的时候不应该破坏这个父类对象的设计初衷。

举个例子来说:

我们定义了一个提款的服务:

public interface DrawMoneyService {
/**
* 提款函数
*
* @param drawMoneyInputParam
*/
void drawMoney(DrawMoneyInputParam drawMoneyInputParam);
}
@Data
@ToString
@EqualsAndHashCode
@Builder
public class DrawMoneyInputParam { private int money;
}

对应的是一个抽象实现父类:

public abstract class AbstractDrawMoneyServiceImpl implements DrawMoneyService{

    /**
* 设计初衷,需要对提现金额进行参数校验
*
* @param drawMoneyInputParam
*/
@Override
public abstract void drawMoney(DrawMoneyInputParam drawMoneyInputParam);
}

正常的子类继承对应父类都应该是对入参进行一个校验判断,如果金额数值小于0,自然就不允许提现了。

public class AppDrawMoneyServiceImpl extends AbstractDrawMoneyServiceImpl{

    @Override
public void drawMoney(DrawMoneyInputParam drawMoneyInputParam) {
if(drawMoneyInputParam.getMoney()>0){
//执行提款程序
}
System.out.println("app提款业务");
}
}

但是如果某个实现的子类当中违背了这一设计原则,例如下边这种:

public class GZHDrawMoneyServiceImpl implements DrawMoneyService {
@Override
public void drawMoney(DrawMoneyInputParam drawMoneyInputParam) {
if(drawMoneyInputParam.getMoney()<0){
//执行提款程序
}
System.out.println("公众号提款业务");
}
}

那么这种情况下,子类的实现就违背了最初父类设计的初衷,此时就违背了里氏替换原则的思想。此时就容易给阅读代码的人感觉,不同的子类虽然都继承了同一个父类,但是在转账的参数校验逻辑上完全是东一套,西一套,没有特定的规矩,逻辑比较乱。

所以较好的做法是在父类中就将需要满足的基本逻辑定义好,保证子类在进行扩展的时候不会轻易造成修改。

另外说说多态和里氏替换原则两个名词:

从案例代码来看,你会发现似乎 多态 和 里氏替换 长得很相似。但是我个人认为这是两个不同领域的东西,前者是代码特有的属性,后者则是一种设计思想,正因为类有了多态的这种特性,人们才会重视在代码设计过程中需要遵守里氏替换原则。这一项原则在设计的过程中保证了代码设计的正确性,它更像是一种思路在指导着开发者如何设计出更加好维护和理解的程序。

接口隔离原则

关于接口隔离原则这部分,我们可以通过一个具体的实战案例来学习。

在和第三方服务进行对接的时候,通常我们需要接入一些密钥之类的相关信息,例如和支付宝的支付接口对接,和微信支付接口做对接,和银联支付做对接等等。

那么我们可以将这些不同场景下关于支付相关的信息的储存放在一个Config相关的对象中,如下所示:

public interface BasePayConfig {
}

然后对每类支付配置都有对应的一个实现方式:

@Data
@ToString
@EqualsAndHashCode
@Builder
public class BankPayConfig implements BasePayConfig { private String secretKey; private String appId; private String randomNumber;
}
@Data
@ToString
@EqualsAndHashCode
@Builder
public class AliPayConfig implements BasePayConfig { private String secretKey; private String appId; private String randomNumber;
}
@Data
@ToString
@EqualsAndHashCode
@Builder
public class WXPayConfig implements BasePayConfig { private String secretKey; private String appId; private String randomNumber;
}

然后呢,实际场景中我们需要将这些配置信息给展示到一个后台管理系统的某个模块当中,所以后续我便在已有的BasePayConfig接口中定义了一个专门展示支付配置的函数:

public interface BasePayConfig {

    /**
* 展示配置
*/
Map<String,Object> showConfig();
}

展示配置之后,需要在各个子类中去对不同的信息进行组装,最后返回一个Map的格式给到调用方。

但是随着业务的变动,某天需要对微信支付的配置信息实现可以替换更新的功能,但是额外的支付宝支付,银联支付不允许对外暴露这一权限。那么此时就需要对代码进行调整了。

调整思路一:

直接在BasePayConfig接口中进行扩展,代码案例如下:

public interface BasePayConfig {

    /**
* 展示配置
*/
Map<String,Object> showConfig(int code); /**
* 更新配置信息
*
* @return
*/
Map<String,Object> updateConfig();
}

然后各个子类依旧是实现这些接口,并且即使不需要实现更新功能的支付宝配置类,银联配置类都必须强制实现。从这样的设计角度来思考就会发现,对于代码实现方面不是太友好,接口内部定义的函数粒度还可以再分细一些。

调整思路二:

将读取配置和更新配置分成两个接口,需要实现更新配置功能的类才需要去实现该接口。代码如下所示:

支付配置展示

public interface BasePayConfigViewer {
/**
* 展示配置
*/
Map<String,Object> showConfig(int code);
}

支付配置更新

public interface BasePayConfigUpdater {

    /**
* 更新配置信息
*
* @return
*/
Map<String,Object> updateConfig();
}

这样的设计能够保证,不同的接口专门负责不同的领域,只有当实现类确实需要使用该功能的时候才去实现该接口。写到这里的时候,你可以不妨再回过头去理解下我在文章上半部分中提及的接口隔离原则,相信你会有新的体会。

或许你也会有所疑惑,接口隔离原则好像和单一责任原则有些类似呀,都是各自专一地负责自己所管理的部分。但是我个人认为,接口隔离原则关注的是接口,而单一责任原则关注的目标可以是对象,接口,类,所涉及的领域更加广阔一些。

依赖反转原则

在介绍依赖反转原则之前,我们先来理解一个相似的名词,控制反转。

单纯的从Java程序来进行理解:

例如我们定义个BeanObject对象:

public interface BeanObject {
void run();
}

然后再定义相关的实现类,如消息发送:

public class MessageNotify implements BeanObject{

    @Override
public void run() {
System.out.println("消息发送");
}
}

最后是一个Context上下文环境:

public class BeanContext {

    private static List<BeanObject> beanObjectList = new ArrayList<>();

    static {
beanObjectList.add(new MessageNotify());
} public static void main(String[] args) {
beanObjectList.get(0).run();
}
}

从代码来看,可以发现对于MessageNotify的调用均是通过一个BeanContext组件调用来实现的,而并不是直接通过new MessageNotify的方式去显示调用。通过封装一个基础骨架容器BeanContext来管控每个BeanObject的run方法执行,这样就将该函数的调用权转交给了BeanContext对象管理。

控制反转现在我们再来理解 控制反转 这个名词,“控制”主要是指对程序执行流程的控制,例如bean的调用方式。“反转”则是指程序调用权限的转变,例如从bean的调用方转变为了基础容器。

依赖注入再来聊下依赖注入这个名词。

依赖注入强调的是将依赖属性不要通过显式的new方式来创建注入,而是将其交给了基础框架去管理。这方面的代表框架除了我们熟悉的Spring之外,其实还有很多,例如Pico Contanier等。

最后再来品味下官方对于依赖反转的介绍:

High-level modules shouldn’t depend on low-level modules.  Both modules should depend on abstractions. In addition,  abstractions shouldn’t depend on details. Details depend on  abstractions.

高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。

最后,希望这篇文章能够对你有所启发。

Java项目中常用的的五大设计原则的更多相关文章

  1. JAVA项目中常用的异常处理情况总结

    JAVA项目中常用的异常知识点总结 1. java.lang.nullpointerexception这个异常大家肯定都经常遇到,异常的解释是"程序遇上了空指针",简单地说就是调用 ...

  2. JAVA项目中常用的异常知识点总结

    JAVA项目中常用的异常知识点总结 1. java.lang.nullpointerexception这个异常大家肯定都经常遇到,异常的解释是"程序遇上了空指针",简单地说就是调用 ...

  3. JAVA项目中常用的异常处理情况

    1.数学运算异常( java.lang.arithmeticexception) 程序中出现了除以零这样的运算就会出这样的异常,对这种异常,大家就要好好检查一下自己程序中涉及到数学运算的地方,公式是不 ...

  4. Day 30:HTML和CSS在Java项目中常用语法

    framSet例子,其中的页面链接地址视情况而定,应为我还不知怎么弄当前文件下呢,例子主要在说明该标签如何使用 <!DOCTYPE html PUBLIC "-//W3C//DTD X ...

  5. 【转载】Java项目中常用的异常处理情况总结

    一,JDK中与异常相关的类 分析: Java中的异常分类: Throwable类有两个直接子类: Exception:出现的问题是可以被捕获的: Error:系统错误,通常由JVM处理. 可捕获的异常 ...

  6. 对java异常的总结及java项目中的常用的异常处理情况

    文章涉及内容来源:黑马程序员自学整理的笔记,网上查阅资料,以及转载名为墨钺的博客大佬,附上博客转载地址:https://www.cnblogs.com/gothic-death/p/9946415.h ...

  7. 在Java项目中整合Scala

    Scala是一个运行在Java JVM上的面向对象的语言.它支持函数编程,在语法上比Java更加灵活,同时通过Akka库,Scala支持强大的基于Actor的多线程编程.具有这些优势,使得我最近很想在 ...

  8. 项目中常用的MySQL 优化

    本文我们来谈谈项目中常用的MySQL优化方法,共19条,具体如下: 一.EXPLAIN 做MySQL优化,我们要善用EXPLAIN查看SQL执行计划. 下面来个简单的示例,标注(1.2.3.4.5)我 ...

  9. Ant在Java项目中的使用(一眼就看会)

    参考:http://www.cnblogs.com/zhengqiang/p/5557155.html Ant是跨平台的构建工具,它可以实现项目的自动构建和部署等功能.在本文中,主要让读者熟悉怎样将A ...

随机推荐

  1. Node.js 应用全链路追踪技术——[全链路信息获取]

    全链路追踪技术的两个核心要素分别是 全链路信息获取 和 全链路信息存储展示. Node.js 应用也不例外,这里将分成两篇文章进行介绍:第一篇介绍 Node.js 应用全链路信息获取, 第二篇介绍 N ...

  2. Java动态代理底层实现

    Java实现源码 上一节我们提到了Java动态代理的使用,接下来我们看一下他的具体实现. HelloInterface proxyHello = (HelloInterface) Proxy.newP ...

  3. 查看所有日志命令:journalctl

    journalctl命令作用:实时查看所有日志(内核日志和应用日志) 语法格式: journalctl [参数] 常用参数:-k 查看内核日志-b 查看系统本次启动的日志-u 查看指定服务的日志-n ...

  4. DPDK应用示例指南简介(汇总)

    DPDK应用示例指南简介 <DPDK示例>系列文章主要是学习.记录.翻译DPDK官方示例文档.为了更好地理解和学习DPDK, 特通过对源码中的经典示例进行整理,供大家学习.交流和讨论. A ...

  5. IPv4掩码与掩码位数的转换

    1. 根据掩码获取掩码的位数 int mask2len(unsigned int mask) { /*eg: 255.255.255.0 255.0.255.255.0*/ int bit=0,len ...

  6. nacos配置中心模块详解

    本文已收录 https://github.com/lkxiaolou/lkxiaolou 欢迎star. 配置中心 业务上的配置,功能开关,服务治理上对弱依赖的降级,甚至数据库的密码等,都可能用到动态 ...

  7. Xilinx约束学习笔记(三)—— 时序概念

    3. 时序概念 发现对于时序基础的介绍这一块,Intel 的文档竟然要比 Xilinx 的详细,因此引用了很多 Intel 的文档内容. 3.1 术语 发送沿(launch edge),指用来发送数据 ...

  8. Spring5(七)——AOP注解

    一.AOP注解 1.介绍 上一节介绍了 AspectJ 框架如何实现 AOP,具体的实现方式是通过 xml 来进行配置的.xml 方式思路清晰,便于理解,但是书写过于麻烦.这一节介绍注解的方式来进行 ...

  9. ESP8266- 使用AT指令获取网络时间

    前言:很早就考虑过用 ESP8266 获取网络时间,以前都是用 ESP8266 刷机智云的 Gagent 固件,但无奈现在手头的 ESP-01 的 Flash 只有 1M,实在无法胜任.经过在网络上的 ...

  10. Linux系列(10) - 命令搜索命令whereis与which

    whereis 只能搜索系统命令,不能搜索自己凭空创建的普通文件 命令格式: whereis [命令名] 选项: -b:只查找可执行文件 -m:只查找帮助文件 which 搜索命令所在路径及别名:不是 ...