本文主要是介绍面向对象的经典设计原则——SOLID,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
SOLID 原则并非单纯的 1 个原则,而是由 5 个设计原则组成的,它们分别是:
- 单一职责原则、
- 开放闭和原则、
- 里式替换原则、
- 接口隔离原则、
- 依赖反转原则、
目录
一、单一职责原则
类的职责是否设计得越单一越好?
二、开闭原则
三、里氏替换原则
违背里氏替换原则的情况:
1、子类违背父类声明要实现的功能
2、子类违背父类对输入、输出、异常的约定
3、子类违背父类注释中所罗列的任何特殊说明
四、接口隔离原则
1、把“接口”理解为一组 API 接口集合:
2、把“接口”理解为单个 API 接口或函数:
3、把“接口”理解为 OOP 中的接口概念:
五、依赖反转原则
一、单一职责原则
设计模式之美 - 15
定义 :一个类或者模块只负责完成一个职责(或者功能)。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
单一职责原则通过避免设计大而全的类,避免将 不相关的功能 耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。如果一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。
以下UserInfo 类的设计是否满足单一职责原则呢?
public class UserInfo {private long userId;private String username;private String email;private String telephone;private long createTime;private long lastLoginTime;private String avatarUrl;private String provinceOfAddress; // 省private String cityOfAddress; // 市private String regionOfAddress; // 区 private String detailedAddress; // 详细地址// ...省略其他属性和方法...
}
对于这个问题,有两种不同的观点:
- 一种观点是,UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;
- 另一种观点是,地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress 类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一。
哪种观点更对呢?
实际上,要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。我们再进一步延伸一下。如果做这个社交产品的公司发展得越来越好,公司内部又开发出了很多其他产品(可以理解为其他 App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候,我们就需要继续对 UserInfo 进行拆分,将跟身份认证相关的信息(比如,email、telephone 等)抽取成独立的类。
从这个例子,我们可以总结出,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。除此之外,从不同的业务层面去看待同一个类的设计,对类是否职责单一,也会有不同的认识。比如,例子中的 UserInfo 类。如果我们从“用户”这个业务层面来看,UserInfo 包含的信息都属于用户,满足职责单一原则。如果我们从更加细分的“用户展示信息”“地址信息”“登录认证信息”等等这些更细粒度的业务层面来看,那 UserInfo 就应该继续拆分。
评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准。但可以提供几条判断条件:
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
- 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
类的职责是否设计得越单一越好?
并不是,对于必须一起出现的几个职责,就不要划分到多个类。拆分的时候需要考虑代码的内聚性。比如:
/*** Protocol format: identifier-string;{gson string}* For example: UEUEUE;{"a":"A","b":"B"}*/
public class Serialization {private static final String IDENTIFIER_STRING = "UEUEUE;";private Gson gson;public Serialization() {this.gson = new Gson();}public String serialize(Map<String, String> object) {StringBuilder textBuilder = new StringBuilder();textBuilder.append(IDENTIFIER_STRING);textBuilder.append(gson.toJson(object));return textBuilder.toString();}public Map<String, String> deserialize(String text) {if (!text.startsWith(IDENTIFIER_STRING)) {return Collections.emptyMap();}String gsonStr = text.substring(IDENTIFIER_STRING.length());return gson.fromJson(gsonStr, Map.class);}
}
如果我们想让类的职责更加单一,我们对 Serialization 类进一步拆分,拆分成一个只负责序列化工作的 Serializer 类和另一个只负责反序列化工作的 Deserializer 类。虽然经过拆分之后,Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。
二、开闭原则
设计模式之美 - 16
软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。当添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
修改 会带来的问题:
- 一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。
- 另一方面,修改了某方法的入参,相应的单元测试都需要修改。
扩展 的目的就是为了使这个改动 没有破坏原有的代码的正常运行,没有破坏原有的单元测试。
关于以上定义,我们有两点要注意:
- 第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
- 第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
(例如下面例子中对于check()方法的入参ApiStatInfo的增加属性操作。对于粗粒度ApiStatInfo类来讲是修改。但是对于细粒度ApiStatInfo的属性来讲就是扩展)
例子:
这是一段 API 接口监控告警的代码。其中,AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。
public class Alert {private AlertRule rule;private Notification notification;public Alert(AlertRule rule, Notification notification) {this.rule = rule;this.notification = notification;}public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {long tps = requestCount / durationOfSeconds;if (tps > rule.getMatchedRule(api).getMaxTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, "...");}if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, "...");}}
}
上面这段代码非常简单,业务逻辑主要集中在 check() 函数中。
现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。
这个时候,我们该如何改动代码呢?主要的改动有两处:
- 第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;
- 第二处是在 check() 函数中添加新的告警逻辑。具体的代码改动如下所示:
public class Alert {// ...省略AlertRule/Notification属性和构造函数...// 改动一:添加参数timeoutCountpublic void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {long tps = requestCount / durationOfSeconds;if (tps > rule.getMatchedRule(api).getMaxTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, "...");}if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, "...");}// 改动二:添加接口超时处理逻辑long timeoutTps = timeoutCount / durationOfSeconds;if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, "...");}}
}
这就属于对于代码的修改,而不是扩展。我们可以看出这种改动带来的问题。
- 一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。
- 另一方面,修改了 check() 方法,就需要对check()方法重新进行测试。
我们先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类;第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。
public class Alert {private List<AlertHandler> alertHandlers = new ArrayList<>();public void addAlertHandler(AlertHandler alertHandler) {this.alertHandlers.add(alertHandler);}public void check(ApiStatInfo apiStatInfo) {for (AlertHandler handler : alertHandlers) {handler.check(apiStatInfo);}}
}public class ApiStatInfo {//省略constructor/getter/setter方法private String api;private long requestCount;private long errorCount;private long durationOfSeconds;
}public abstract class AlertHandler {protected AlertRule rule;protected Notification notification;public AlertHandler(AlertRule rule, Notification notification) {this.rule = rule;this.notification = notification;}public abstract void check(ApiStatInfo apiStatInfo);
}public class TpsAlertHandler extends AlertHandler {public TpsAlertHandler(AlertRule rule, Notification notification) {super(rule, notification);}@Overridepublic void check(ApiStatInfo apiStatInfo) {long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {notification.notify(NotificationEmergencyLevel.URGENCY, "...");}}
}public class ErrorAlertHandler extends AlertHandler {public ErrorAlertHandler(AlertRule rule, Notification notification){super(rule, notification);}@Overridepublic void check(ApiStatInfo apiStatInfo) {if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {notification.notify(NotificationEmergencyLevel.SEVERE, "...");}}
}
public class ApplicationContext {private AlertRule alertRule;private Notification notification;private Alert alert;public void initializeBeans() {alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码notification = new Notification(/*.省略参数.*/); //省略一些初始化代码alert = new Alert();alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));}public Alert getAlert() { return alert; }// 饿汉式单例private static final ApplicationContext instance = new ApplicationContext();private ApplicationContext() {initializeBeans();}public static ApplicationContext getInstance() {return instance;}
}public class Demo {public static void main(String[] args) {ApiStatInfo apiStatInfo = new ApiStatInfo();// ...省略设置apiStatInfo数据值的代码ApplicationContext.getInstance().getAlert().check(apiStatInfo);}
}
ApplicationContext 是一个单例类,负责 Alert 的创建、组装(alertRule 和 notification 的依赖注入)、初始化(添加 handlers)工作。
基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?
主要的改动有下面四处。
- 第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。
- 第二处改动是:添加新的 TimeoutAlertHander 类。
- 第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。
- 第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。
public class Alert { // 代码未改动... }
public class ApiStatInfo {//省略constructor/getter/setter方法private String api;private long requestCount;private long errorCount;private long durationOfSeconds;private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler { //代码未改动... }
public class TpsAlertHandler extends AlertHandler {//代码未改动...}
public class ErrorAlertHandler extends AlertHandler {//代码未改动...}
// 改动二:添加新的handler
public class TimeoutAlertHandler extends AlertHandler {//省略代码...}public class ApplicationContext {private AlertRule alertRule;private Notification notification;private Alert alert;public void initializeBeans() {alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码notification = new Notification(/*.省略参数.*/); //省略一些初始化代码alert = new Alert();alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));// 改动三:注册handleralert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));}//...省略其他未改动代码...
}public class Demo {public static void main(String[] args) {ApiStatInfo apiStatInfo = new ApiStatInfo();// ...省略apiStatInfo的set字段代码apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。
三、里氏替换原则
设计模式之美 - 17
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
里氏替换原则 是用来指导 继承关系中 子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
违背里氏替换原则的情况:
1、子类违背父类声明要实现的功能
父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。
2、子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。
3、子类违背父类注释中所罗列的任何特殊说明
父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额。
四、接口隔离原则
设计模式之美 - 18
客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西:
- 一组 API 接口集合
- 单个 API 接口或函数
- OOP 中的接口概念
因此分以上三种情况分别介绍三种情况下的隔离原则:
1、把“接口”理解为一组 API 接口集合:
这种情况下,需要注意如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
例如:微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。具体代码如下所示:
public interface UserService {boolean register(String cellphone, String password);boolean login(String cellphone, String password);UserInfo getUserInfoById(long id);UserInfo getUserInfoByCellphone(String cellphone);
}public class UserServiceImpl implements UserService {//...
}
当用户系统提供一个删除用户的接口。这个时候我们该如何来做呢?在 UserService 中新添加一个 deleteUserByCellphone() 或 deleteUserById() 接口?这个方法可以解决问题,但是也隐藏了一些安全隐患。删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。具体的代码实现如下所示:
public interface UserService {boolean register(String cellphone, String password);boolean login(String cellphone, String password);UserInfo getUserInfoById(long id);UserInfo getUserInfoByCellphone(String cellphone);
}public interface RestrictedUserService {boolean deleteUserByCellphone(String cellphone);boolean deleteUserById(long id);
}public class UserServiceImpl implements UserService, RestrictedUserService {// ...省略实现代码...
}
2、把“接口”理解为单个 API 接口或函数:
这种情况下,接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。
例如:
public class Statistics {private Long max;private Long min;private Long average;private Long sum;private Long percentile99;private Long percentile999;//...省略constructor/getter/setter等方法...
}public Statistics count(Collection<Long> dataSet) {Statistics statistics = new Statistics();//...省略计算逻辑...return statistics;
}
在上面的代码中,count() 函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:
public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... }
public Long average(Colletion<Long> dataSet) { //... }
// ...省略其他统计函数...
以上两种设计哪一个更为合理其实需要分情况来看:
- 如果在项目中,对每个统计需求,Statistics 定义的那几个统计信息都有涉及,那 count() 函数的设计就是合理的。
- 如果每个统计需求只涉及 Statistics 罗列的统计信息中一部分,比如,有的只需要用到 max、min、average 这三类统计信息,有的只需要用到 average、sum。而 count() 函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count() 函数的设计就有点不合理了,我们应该按照第二种设计思路,将其拆分成粒度更细的多个统计函数。
这种情况下,单一职责原则和接口隔离原则有点类似,但由存在一些区别。
与单一职责原则的区别和联系:
单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
3、把“接口”理解为 OOP 中的接口概念:
把“接口”理解为 OOP 中的接口概念,比如 Java 中的 interface。
设计两个功能非常单一的接口:Updater 和 Viewer。可以只依赖 Updater 这个跟更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。同理,也可以只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则。
五、依赖反转原则
设计模式之美 - 19
高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。
在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。
这条原则主要还是用来指导框架层面的设计。
控制反轉:这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。
依賴注入:依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后。
这篇关于面向对象的经典设计原则——SOLID的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!