了解一些经典的设计原则,并且将其应用到我们的日常开发中,会大大提高代码的优雅性、可扩展性、可维护性。
本文总结了极客时间上的 《设计模式之美》 专栏中的SOLID设计原则的内容,用于学习和使用。
SOLID原则是由5个设计原则组成,SOLID对应每个原则英文字母的开头:
单一职责原则的英文是Single Responsibility Principle,缩写为SRP。
英文描述是:
A class or module shoule have a single responsibility
翻译过来即一个类或模块只负责完成一个职责(功能)。
简单理解就是一个类、或者接口、或者模块只能负责完成一个职责或业务的功能。
举个例子:
订单类如果包含用户的详细信息,可能就会职责不单一;
又如,订单接口只应该提供订单查询、操作等API,如果再提供用户信息查询的操作,那么就可以说这个接口不符合单一职责原则。
上述例子中,订单类含有用户信息,职责可能就不单一。用了可能,就是因为在不同场景下,判定准则就可能不同。如:订单信息OrderDTO和用户地址信息AddressDTO应该属于不同的领域模型。但某个订单可能包含收获地址,那么订单类中就会有地址信息,在此场景下,地址信息可能就属于订单信息,可以看做职责单一。
所以判定类或模块的职责是否单一不可一概而论,需要在特定场景下做出不同的判定。
业务发展地越快,功能越丰富,类的内容就越多,类的职责也会划分的越简单。一般会以以下准则来判断类或模块是否职责单一:
当然,类也不是拆分越细越好,也遵守"高内聚、低耦合"的原则进行拆分,否则会影响代码的可维护性。
开闭原则是开发中最难理解和使用,但又是最有用的设计原则之一,是考量代码扩展性的。
开闭原则英文是:Open Close Principle,简写为OCP。
英文描述是:
Software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。
翻译过来即:
软件实体(模块、类、方法等)应该对"扩展开发,对修改关闭"。
通俗点讲即:
添加一个新的功能应该是在已有代码基础上进行扩展代码(新增模块、类、方法等),而不是修改已有代码(如修改接口定义、类信息、方法参数等)。
实际上,开闭原则在我们开发中使用的比较多,常见的例子就是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设计原则的定义:对修改关闭,对扩展开放。
开闭原则就是应对代码扩展性的问题,在写代码的时候对于未来可能有变更的地方留好扩展点;
对于可变的部分封装起来,并且提供抽象化的不可变接口,给上层系统调用;具体实现发生变化时只需要扩展一个新的实现即可,上游系统几乎不需要修改。
常见用来提高代码扩展性的方法有:
里式替换原则叫做: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):Interface Segregation Principle.
接口隔离原则与单一职责原则有点类似,是指:
如果只有部分接口被部分调用者使用,就需要将这部分接口隔离出来,单独给这部分调用者使用,这样其他依赖者就不会引用到不需要的接口。
ISP设计原则中的接口并不只是我们日常开发中说的接口的概念,一般可以理解为下面3种:
(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去提供。
控制反转(IOC)和依赖注入(DI)应该是我们接触到的最多的两个技巧。
控制反转(IOC):控制是指对程序运行的控制,反转是指程序执行者的反转。控制反转是指程序的运行控制权由程序员反转到框架上;
依赖注入(DI):不通过new的方式构建对象,而是事先构建好对象后通过构造函数或函数等方式传递给类使用。
那么依赖反转原则(DIP)跟控制反转和依赖注入有什么区别呢?
依赖反转原则:Dependency Inversion Principle(DIP)。
含义是:
高层模块不要依赖低层模块.
高层模块和低层模块应该通过抽象来互相依赖.
抽象不要依赖具体实现细节,具体实现细节依赖抽象.
例如,Tomcat容器中运行的Web程序,Tomcat就是高层模块,Web程序就是低层模块。两者都依赖Servlet规范,Servlet规范是个抽象。
业务开发中,高层模块可以直接调用依赖低层模块。