【经典设计原则学习】SOLID设计原则

了解一些经典的设计原则,并且将其应用到我们的日常开发中,会大大提高代码的优雅性、可扩展性、可维护性。
本文总结了极客时间上的 《设计模式之美》 专栏中的SOLID设计原则的内容,用于学习和使用。

SOLID原则是由5个设计原则组成,SOLID对应每个原则英文字母的开头:

  • 单一职责原则(Single Responsiblity Principle)
  • 开闭原则(Open Close Principle)
  • 里式替换原则(Liskov Substitution Principle)
  • 接口隔离原则(Interface Segregation Principle)
  • 依赖反转原则(Dependency Inversion Principle)

一、单一职责原则

1、单一职责原则定义(SRP)

单一职责原则的英文是Single Responsibility Principle,缩写为SRP。

英文描述是:

A class or module shoule have a single responsibility

翻译过来即一个类或模块只负责完成一个职责(功能)。

简单理解就是一个类、或者接口、或者模块只能负责完成一个职责或业务的功能。

举个例子:

订单类如果包含用户的详细信息,可能就会职责不单一;

又如,订单接口只应该提供订单查询、操作等API,如果再提供用户信息查询的操作,那么就可以说这个接口不符合单一职责原则。

2、如何判定类或模块的职责是否单一

上述例子中,订单类含有用户信息,职责可能就不单一。用了可能,就是因为在不同场景下,判定准则就可能不同。如:订单信息OrderDTO和用户地址信息AddressDTO应该属于不同的领域模型。但某个订单可能包含收获地址,那么订单类中就会有地址信息,在此场景下,地址信息可能就属于订单信息,可以看做职责单一。

所以判定类或模块的职责是否单一不可一概而论,需要在特定场景下做出不同的判定。

业务发展地越快,功能越丰富,类的内容就越多,类的职责也会划分的越简单。一般会以以下准则来判断类或模块是否职责单一:

  • 类中的代码行数、函数、属性过多(如订单类OrderDTO中的地址信息属性过多时,就可以将地址信息从订单类OrderDTO中拆开,单独做一个类AddressDTO);
  • 类依赖的其他类过多,或者依赖类的其他类过多(依赖相同功能类的可以拆分出来单独形成一个功能类);
  • 私有方法过多(如大量的对时间格式、数字小数点进行操作,可以将私有方法拆分出来放入Util类中,提高复用性);
  • 比较难给类起一个合适的名字;
  • 类中大量的方法都是集中操作类中的某几个属性。

当然,类也不是拆分越细越好,也遵守"高内聚、低耦合"的原则进行拆分,否则会影响代码的可维护性。

二、开闭原则(OCP)

开闭原则是开发中最难理解和使用,但又是最有用的设计原则之一,是考量代码扩展性的。

1、开闭原则定义

开闭原则英文是:Open Close Principle,简写为OCP。

英文描述是:

Software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。

翻译过来即:

软件实体(模块、类、方法等)应该对"扩展开发,对修改关闭"。

通俗点讲即:

添加一个新的功能应该是在已有代码基础上进行扩展代码(新增模块、类、方法等),而不是修改已有代码(如修改接口定义、类信息、方法参数等)。

2、开闭原则在开发中应用

实际上,开闭原则在我们开发中使用的比较多,常见的例子就是handlers的例子。

下面是一个Api告警的demo示例,当项目中接口调用量、QPS超过一定阈值后就会告警:

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 09:55.
 * Description: API 接口监控
 */
public class ApiAlert {
    private AlertRule alertRule;
    private Notification notification;

    private static final String NOTIFY_MSG = "【%s】api:[%s] tps exceed max tps";

    public ApiAlert(AlertRule alertRule, Notification notification) {
        this.alertRule = alertRule;
        this.notification = notification;
    }

    /**
     * 是否需要发送告警
     *
     * @param api               接口名
     * @param requestCount      接口调用量
     * @param errorCount        接口调用失败次数
     * @param durationSeconds   窗口期
     */
    public void check(String api, long requestCount, long errorCount, long durationSeconds) {
        AlertRule alertRule = AlertRule.getMatchedRule(api);
        // calculate tps, to evaluate if need to send URGENCY notify
        long tps = requestCount / durationSeconds;
        if (tps > alertRule.getMaxTps()) {
            String notifyMsg = String.format(NOTIFY_MSG, "URGENCY", api);
            notification.notify(NotificationEmergencyLevelEnum.URGENCY.getCode(), notifyMsg);
        }

        // calculate errorCount, to evaluate if need to send URGENCY notify
        if (errorCount > alertRule.getMaxErrorLimit()) {
            String notifyMsg = String.format(NOTIFY_MSG, "SEVERE", api);
            notification.notify(NotificationEmergencyLevelEnum.SEVERE.getCode(), notifyMsg);
        }

    }
}

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 09:42.
 * Description: 存储告警规则
 */
@Getter
public class AlertRule {
    private long maxTps;
    private long maxErrorLimit;

    public AlertRule(long maxTps, long maxErrorLimit) {
        this.maxTps = maxTps;
        this.maxErrorLimit = maxErrorLimit;
    }

    public static AlertRule getMatchedRule(String api) {
        // 模拟 "getOrder" 接口设置的最大tps和errorLimit, 设置的参数可以放到数据库或缓存
        if ("getOrder".equals(api)) {
            AlertRule orderAlertRule = new AlertRule(1000, 10);
            return orderAlertRule;
        } else if ("getUser".equals(api)) {
            AlertRule userAlertRule = new AlertRule(1500, 15);
            return userAlertRule;
        } else {
            AlertRule commonAlertRule = new AlertRule(500, 20);
            return commonAlertRule;
        }
    }
}

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 09:42.
 * Description: 告警通知类,支持邮件、短信、微信、手机等多种通知渠道
 */
@Slf4j
@Getter
@AllArgsConstructor
public class Notification {

    private String notifyMsg;
    private int notifyType;


    /**
     * 发送消息告警
     *
     * @param notifyType    告警类型
     * @param notifyMsg     告警内容
     */
    public void notify(int notifyType, String notifyMsg) {
        log.info("Receive notifyMsg [{}] to push, type:{}", notifyMsg, notifyType);
    }
}

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 09:48.
 * Description: 告警严重程度
 */
@Getter
public enum NotificationEmergencyLevelEnum {
    SEVERE(0, "严重"),
    URGENCY(1, "紧急"),
    NORMAL(2, "普通"),
    TRIVIAL(3, "无关紧要")

    ;

    private int code;
    private String desc;

    NotificationEmergencyLevelEnum(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }
}

ApiAlert.check()方法中是告警的具体实现,即:当接口QPS超过阈值时,发送对应的告警;

当接口调用error量超过阈值时,发送相应告警。

假如需要统计接口的TPS,又需要修改原有的check()方法,添加新的逻辑。

这就违背了OCP原则,即:添加新的功能不应修改已有代码,而是在原有代码上进行拓展。

如何应用OCP原则呢?下面是应用OCP后的代码示例(由于代码过多,这里只展示核心代码,详细代码见:My github):

(1)首先抽象参数:

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 10:27.
 * Description: API统计信息
 */
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class ApiStatInfo {
    private String api;
    private long requestCount;
    private long errorCount;
    private long durationSeconds;

}

(2)抽象核心方法check(),提供一个入口:

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 09:55.
 * Description: API 接口监控
 */
@Component
public class ApiAlert {

    @Autowired
    private ApiInterceptorChainClient interceptorChainClient;

    /**
     * 是否需要发送告警
     */
    public void check(ApiStatInfo apiStatInfo) {
        interceptorChainClient.processApiStatInfo(apiStatInfo);
    }
}

(3)具体的告警处理的handler实现细节由不同的handler类去实现:ApiTpsAlertInterceptor、ApiErrorAlertInterceptor。当然需要有一个manager类去初始化handler类并触发其执行:

/**
 * Created by wanggenshen
 * Date: on 2019/12/11 19:45.
 * Description: 负责拦截器链的初始化和执行
 */
@Component
public class ApiInterceptorChainClient {

    @Autowired
    private List<ApiAlertInterceptor> apiAlertInterceptors;

    @PostConstruct
    public void loadInterceptors() {
        if (apiAlertInterceptors == null || apiAlertInterceptors.size() <= 0) {
            return;
        }
        apiAlertInterceptors.stream().forEach(interceptor -> resolveInterceptorOrder(interceptor));

        // 按优先级排序, order越小, 优先级越高
        Collections.sort(apiAlertInterceptors, (o1, o2) -> o1.getOrder() - o2.getOrder());

    }

    private void resolveInterceptorOrder(ApiAlertInterceptor interceptor) {
        if (interceptor.getClass().isAnnotationPresent(InterceptorOrder.class)) {
            int order = interceptor.getClass().getAnnotation(InterceptorOrder.class).order();
            interceptor.setOrder(order);
        }
    }

    public void processApiStatInfo(ApiStatInfo apiStatInfo) {
        apiAlertInterceptors.stream().forEach(apiAlertInterceptor -> apiAlertInterceptor.handler(apiStatInfo));
    }
}

这样如果需要新增API TPS的告警处理,只需要在原有代码进行扩展,新增一个handler类,而原有代码几乎不需要任何处理,这样就满足了OCP设计原则的定义:对修改关闭,对扩展开放。

3、如何做到“对扩展开放、修改关闭”

开闭原则就是应对代码扩展性的问题,在写代码的时候对于未来可能有变更的地方留好扩展点;

对于可变的部分封装起来,并且提供抽象化的不可变接口,给上层系统调用;具体实现发生变化时只需要扩展一个新的实现即可,上游系统几乎不需要修改。

常见用来提高代码扩展性的方法有:

  • 多态
  • 依赖注入
  • 基于接口而非实现编程
  • 设计模式(策略、模板、职责链等等)

三、里式替换原则(LSP)

里式替换原则叫做:Liskov Substitution Principle,定义如下:

子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

里式替换是一种设计原则,用来指导继承关系中子类该如何设计。子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

举个简单的例子,什么样的代码属于违背了LSP。

父类A中有个方法calculate()用于计算两个数之和,当两数任意一值为null时返回0.

子类B继承父类A并且重写方法calculate之后,当a或b有任一数为null时抛出异常。

public class A {
    public Integer calculate(Integer a, Integer b) {
        if (a == null || b == null) {
            return 0;
        }
        return a + b;
    }
}

public class B extends A {
    @Override
    public Integer calculate(Integer a, Integer b) {
        if (a == null || b == null) {
            throw new RuntimeException("Null num exception");
        }
        return a + b;
    }
}

引用父类对象调用calculate方法,传入的参数为null时返回的是0;引用子类对象调用calculate方法,传入的参数为null时报异常。这样的写法实际上就违背了LSP原则:子类能够替换父类出现的任何地方。

如何保证在进行子类设计的时候满足LSP原则?

子类在设计的时候,要遵守父类的行为约定。父类定义了函数的行为约定,子类可以改变函数内部的实现逻辑,但是不能改变函数原有的行为约定。

这里的行为约定包括:

(1)函数要声明的功能

如父类提供的sortOrdersByAmount():按照订单金额排序的函数,子类重写的时候却按订单创建日期来排序,修改了父类定义的函数声明的功能,这种子类的设计就违背了LSP.

(2)输入、输出、异常

输入:父类只允许参数为整数,而子类却是任意整数,这就违背了LSP;

输出:父类在运行出错的时候返回空集合;子类在相同情况下返回异常,违反了LSP;

异常:父类在程序运行报错的时候不抛异常或异常A;子类在程序运行报错的情况下抛出异常或异常B,违反了LSP.

(3)子类违背父类中注释声明

比如父类某个函数上的注释定义的功能是两数相加,子类重写的时候却是两数相减,这就违背了LSP.

LSP一般是应用在子类重写父类的时候所要遵循的设计原则,一般只要满足“引用父类对象的地方都可以被替换成子类对象”即可。

四、接口隔离原则(ISP)

接口隔离原则(ISP):Interface Segregation Principle.

接口隔离原则与单一职责原则有点类似,是指:

如果只有部分接口被部分调用者使用,就需要将这部分接口隔离出来,单独给这部分调用者使用,这样其他依赖者就不会引用到不需要的接口。

ISP设计原则中的接口并不只是我们日常开发中说的接口的概念,一般可以理解为下面3种:

  • 一组API接口;
  • 单个API接口或函数;
  • OOP中接口概念

(1)一组API接口

如果是一组API接口,比如UserService提供了一组跟用户信息相关的API接口,同时也提供了一个只给内部系统调用的清除无效用户的接口。

public interface UserService { 
  // 用户登录、查询相关API
  boolean register(String cellphone, String password); 
  boolean login(String cellphone, String password); 
  UserInfo getUserInfoById(long id); 
  UserInfo getUserInfoByCellphone(String cellphone);
  // 清除无效用户
  void cleanUnValidUserInfo(long id)}

当第三方调用UserService查询用户信息的时候,也能调用cleanUnValidUserInfo这个方法,这就违反了ISP原则。正确的做法是将cleanUnValidUserInfo接口单独放到一个类中,不对外暴露,只给内部系统使用。

(2)单个API接口或函数

在单个API或函数中,可能一个方法涉及到多个功能,比如User getUserAddress(long id)获取用户地址信息,但是却返回了用户手机号、订单等信息,这就违背了ISP原则,实际上也是违背了单一职责原则。

(3)OOP中接口概念

在Java开发中,接口用interface关键字来标识。

public interface Config {
   String getConfig();
   // 更新配置信息
   void update();
  // JSON化展示config信息
  String view();
}

public class RedisConfig implements Config {
   public String getConfig() {
     // ...
   }
   // 更新配置信息
   public void update() {
     // ...
   }
  
    // 空实现
   public String view() {
   }
}

public interface KafkaConfig implements Config {
   public String getConfig() {
     // ...
   }
   // 定时拉取最新配置信息
   public String view() {
     // ...
   }
  
  // 空实现
  public void update() {     
   }
}

如图,Config中提供了config信息的获取、更新、json化数据的API。RedisConfig只需要getConfig、

update这两个方法,KafkaConfig只需要getConfig、view两个方法,不需要的均无实现。

这样的写法就违反了ISP设计原则。RedisConfig、KafkaConfig均依赖了不需要的接口:update和view。正确的做法是将update和view分别抽离出来,用单独的API去提供。

五、依赖反转原则(DIP)

控制反转(IOC)和依赖注入(DI)应该是我们接触到的最多的两个技巧。

控制反转(IOC):控制是指对程序运行的控制,反转是指程序执行者的反转。控制反转是指程序的运行控制权由程序员反转到框架上;

依赖注入(DI):不通过new的方式构建对象,而是事先构建好对象后通过构造函数或函数等方式传递给类使用。

那么依赖反转原则(DIP)跟控制反转和依赖注入有什么区别呢?

依赖反转原则:Dependency Inversion Principle(DIP)。

含义是:

高层模块不要依赖低层模块.

高层模块和低层模块应该通过抽象来互相依赖.

抽象不要依赖具体实现细节,具体实现细节依赖抽象.

例如,Tomcat容器中运行的Web程序,Tomcat就是高层模块,Web程序就是低层模块。两者都依赖Servlet规范,Servlet规范是个抽象。

业务开发中,高层模块可以直接调用依赖低层模块。

你可能感兴趣的:(SpringBoot,SOLID设计模式)