了解一些经典的设计原则,并且将其应用到我们的日常开发中,会大大提高代码的优雅性、可扩展性、可维护性。
本文总结了极客时间上的 ​​​《设计模式之美》​​ 专栏中的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规范是个抽象。

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