单一职责原则(SRP)
概念
单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP。这个原则的英文描述是这样的:A class or module should have a single responsibility。翻译成中文就是:一个类或者模块只负责完成一个职责(或者功能)。
这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。
关于这两个概念,有两种理解方式:
把模块看作比类更加抽象的概念,类也可以看作模块。
把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。
单一职责原则在应用到这两个描述对象的时候,道理都是相通的。为了方便你理解,接下来我只从“类”设计的角度,来讲解如何应用这个设计原则。对于“模块”来说,你可以自行引申。
eg.一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。
方法论
如何判断一个类的职责是否足够单一?
在一个社交产品中,我们用下面的 UserInfo 类来记录用户的信息。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 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。
不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。
在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构
判断原则
类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
多少行代码才算是行数过多呢?多少个函数、属性才称得上过多呢?这个要根据具体情况而定,这里给一个凑活能用、比较宽泛的、可量化的标准,那就是一个类的代码行数最好不能超过 200 行,函数个数及属性个数都最好不要超过 10 个。一个方法的行数话, 不超过一屏幕也可以,不用上下拖拽去看代码。
类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
类的职责是否设计得越单一越好?
为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。
例如Serialization 类实现了一个简单协议的序列化和反序列功能,具体代码如下:
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 类。拆分后的具体代码如下所示:
public class Serializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Serializer() {
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 class Deserializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;
public Deserializer() {
this.gson = new Gson();
}
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);
}
}
Serializer 类和 Deserializer 类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从 JSON 改为了 XML,那 Serializer 类和 Deserializer 类都需要做相应的修改,代码的内聚性显然没有原来 Serialization 高了。而且,如果我们仅仅对 Serializer 类做了协议修改,而忘记了修改 Deserializer 类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。
不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。
上面这个案例,想让职责更单一的话, 可以把分两个类变成分两个接口, 即Serializer和Deserializer,然后调用方调用自己需要的接口, 实现类只有一个,修改协议的时候也避免了修改两次的情况。
优点
可以降低类的复杂度,实现什么职责都有清晰明确的定义;
提高类的可读性;
提高系统的可维护性;
变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,一个接口修改只对相应地实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
单一职责还有一点好处就是使各个模块、功能、类尽可能的原子化,这样上层可以更容易根据不同的模块相互组合成新的上层模块。
开闭原则(OCP)
概念
开闭原则的英文全称是 Open Closed Principle,简写为 OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。
如果我们详细表述一下,那就是,添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。也就是需要用抽象构建框架, 用实现扩展细节。
对拓展开放是为了应对变化(需求),对修改关闭是为了保证已有代码的稳定性;最终结果是为了让系统更有弹性!
方法论
抽象
开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。
在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。
在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
一个接口或者一组接口一般是一个模块对外的一个契约。
具体
代码的扩展性是代码质量评判的最重要的标准之一。 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。
在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。
比如,我们代码中通过 Kafka 来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将 Kafka 替换成 RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:
// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class KafkaMessageQueue implements MessageQueue { //... }
public class RocketMQMessageQueue implements MessageQueue {//...}
public interface MessageFromatter { //... }
public class JsonMessageFromatter implements MessageFromatter {//...}
public class ProtoBufMessageFromatter implements MessageFromatter {//...}
public class Demo {
private MessageQueue msgQueue; // 基于接口而非实现编程
public Demo(MessageQueue msgQueue) { // 依赖注入
this.msgQueue = msgQueue;
}
// msgFormatter:多态、依赖注入
public void sendNotification(Notification notification, MessageFormatter msgFormatter) {
//...
}
}
写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?
如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。
如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。
不过,有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
最合理的做法是,
对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。
但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。
有些情况下,代码的扩展性会跟可读性相冲突。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。
总之,这里没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。
demo
例如,有一个卖课网站最近在搞活动, 不同类型的课程有不同的折扣,可以通过接口或者继承来实现对扩展开放,对修改关闭。
//定义接口
public interface ICourse {
public String getName();
public Double getPrice();
public Integer getId();
}
//实现接口的基本方法,定义课程的基本信息
public class JavaCourse implements ICourse {
private String name;
private Double price;
private Integer id;
public JavaCourse(String name,Double price,Integer id){
this.name = name;
this.price = price;
this.id = id;
}
public String getName() {
return name;
}
public Double getPrice() {
return price;
}
public Integer getId() {
return id;
}
}
//由于每个课程的具体折扣信息不同,另外定义一个课程类来继承基本的JavaCourse,实现对扩展开放,对修改关闭
public class JavaDiscountCourse extends JavaCourse {
//获取原始价格
public JavaDiscountCourse(String name, Double price, Integer id) {
super(name, price, id);
}
public Double getOriginPrice(){
return super.getPrice();
}
@Override
public Double getPrice() {
return super.getPrice() * 0.8;
}
}
优缺点
优点
提高软件实体的可复用性
使用抽象构建的框架,那么抽象出来的东西具有很大的共同特性,可以直接拿来复用,避免重复定义或编码。
提高软件实体的可拓展性
现有的功能特性不满足未来的需求变化,可基于抽象框架进行拓展新的功能,不修改原来的代码即可完成拓展性。
提供软件实体的可维护性
所有的功能特性都是基于抽象框架拓展而成,各个功能特性独立且相互不影响,后期维护目标明确且方便。
缺点
面向对象的抽象难度大,如果在刚开始使用抽象构建的框架考虑不全,后期已经拓展了很多功能特性,一旦抽象的基础框架发生变动,下面的拓展部分都有可能受到影响;因此需要很强、很系统的抽象能力把基础框架抽象出来,才能减少后期带来的不必要影响。
里式替换原则(LSP)
概念
里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。这个原则最早是在 1986 年由 Barbara Liskov 提出,他是这么描述这条原则的:
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则,英文原话是这样的:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
我们综合两者的描述,将这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
里氏替换其实就是子类完美继承父类的设计初衷,并做了增强。
ps: Liskov是美国历史上第一个女计算机博士,曾获得过图灵奖。 In 1968 she became one of the first women in the United States to be awarded a Ph.D from a computer science department when she was awarded her degree from Stanford University. At Stanford she worked with John McCarthy and was supported to work in artificial intelligence.
方法论
实际上,里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。
看起来比较抽象,进一步解读就是子类在设计的时候,要遵守父类的行为约定(或者叫协议)。
父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
违反里氏替换原则的情况
子类违背父类声明要实现的功能
父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。
子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。
在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。
子类违背父类注释中所罗列的任何特殊说明
父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。
demo
如下代码中,父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息。
public class Transporter {
private HttpClient httpClient;
public Transporter(HttpClient httpClient) {
this.httpClient = httpClient;
}
public Response sendRequest(Request request) {
// ...use httpClient to send request
}
}
public class SecurityTransporter extends Transporter {
private String appId;
private String appToken;
public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
super(httpClient);
this.appId = appId;
this.appToken = appToken;
}
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}
public class Demo {
public void demoFunction(Transporter transporter) {
Reuqest request = new Request();
//...省略设置request中数据值的代码...
Response response = transporter.sendRequest(request);
//...省略其他逻辑...
}
}
// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略参数*/););
在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。
优缺点
通过建立抽象,运行过程中具体实现取代抽象,保证了系统的可拓展性。
合理得用类的继承关系,提高了代码的复用性,但也增强了类与类之间的耦合性。
只要继承父类就拥有父类的全部属性和方法,这样减少了代码重复创建量共享了代码但也约束了子类的行为,降低了系统灵活性。
接口隔离原则(ISP)
概念
接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为 ISP。
Robert Martin 在 SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”
直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API 接口,还可以特指面向对象编程语言中的接口等。
在这条原则中,我们可以把“接口”理解为下面三种东西:
一组 API 接口集合
我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。比如用户服务提供注册登录和查询功能功能, 后续需要再提供删除功能就可以在实现类再加个删除相关的功能接口,确保调用方只调用自己需要调用的功能
单个 API 接口或函数
函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。比如数据统计有个count() 函数,其包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。因为后续很可能有其他功能需要用到部分功能,比如,有的只需要用到 max、min、average 这三类统计信息,有的只需要用到 average、sum。
OOP 中的接口概念
比如 Java 中的 interface。接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。
接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
demo
微服务用户系统提供了一组跟用户相关的 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() 接口就可以了。这个方法可以解决问题,但是也隐藏了一些安全隐患。
删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。
参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 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 {
// ...省略实现代码...
}
由上可知,在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
优点
将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
依赖倒置原则(DIP)
概念
依赖反转原则的英文翻译是 Dependency Inversion Principle,缩写为 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.
翻译成中文,大概意思就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。
实际上,这条原则主要还是用来指导框架层面的设计。我们拿 Tomcat 这个 Servlet 容器作为例子来解释一下。Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Servlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范。
依赖倒转原则是基于这样的设计理念:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建的架构比以细节为基础的架构要稳定的多。在java中,抽象指的是接口或抽象类,细节就是具体的实现类。
demo
用户学习课程的案例
//版本V1
public class Zhi {
public void studyJavaCourse(){
System.out.println("Zhi在学习Java课程");
}
public void studyFECourse(){
System.out.println("Zhi在学习FE课程");
}
public void studyPythonCourse(){
System.out.println("Zhi在学习Python课程");
}
}
/**
* Test是应用层,属于高层模块,而Zhi属于低层模块,而根据依赖倒置原则,
* 高层次模块不应该属于低层次模块,也就是说Test里面的实现依赖于Zhi里面的具体
* 实现,里面实现什么都要去Zhi里扩展补充,然后才能在Test层使用,现在我们使用依赖
* 倒置原则来解决这个问题
*/
public class Test {
public static void main(String[] args) {
Zhi zhi = new Zhi();
zhi.studyFECourse();
zhi.studyJavaCourse();
zhi.studyPythonCourse();
}
}
下面是v2版本, 但是高层还是依赖了具体实现进行传输
//版本V2
public interface ICourse {
void studyCourse();
}
public class JavaCourse implements ICourse {
public void studyCourse() {
System.out.println("Zhi在学习Java课程");
}
}
public class FECourse implements ICourse{
public void studyCourse() {
System.out.println("Zhi在学习FE课程");
}
}
public class Zhi {
//这里具体学习什么课程是由高层模块即Test来决定的,
//而不是交给Zhi这个低层次模块来决定的
public void studyImoocCourse(ICourse iCourse){
iCourse.studyCourse();
}
}
/**
* 如果有其他的课程实现则需要添加与FECourse这些平级的课程就行了,而Zhi这个类是不需要动的,也就是说我们要
* 面向这个ICourse接口编程,我要写的这些方法是通过接口的而不是Zhi类,而具体学啥课程是由Test来选择的。这样就做到
* Zhi和Test是解耦的,而且Zhi和课程的具体实现是解耦的,但是它和ICourse是有耦合的。
*/
public class Test {
public static void main(String[] args) {
Zhi zhi = new Zhi();
zhi.studyImoocCourse(new JavaCourse());
zhi.studyImoocCourse(new FECourse());
}
}
可以把ICourse接口作为属性值进行存储, 如下所示:
//版本V3 有点像Spring的依赖注入
public class Zhi {
private ICourse iCourse;
public Zhi(ICourse iCourse){
this.iCourse = iCourse;
}
//这里具体学习什么课程是由高层模块即Test来决定的,
//而不是交给Zhi这个低层次模块来决定的
public void studyImoocCourse(){
iCourse.studyCourse();
}
}
//不过这个方法每次要学习一个新的课程的时候都要new一个对象出来,显然是不太好的,可以通过添加一个set方法每次学习一个
//课程就set对应的课程并调用studyImoocCourse()方法;
public class Test {
public static void main(String[] args) {
Zhi zhi = new Zhi(new JavaCourse());
zhi.studyImoocCourse();
}
}
每次要新增一次课程的时候直接新写一个课程类并实现ICourse接口,这也符合开闭原则,避免导致其他课程引入新的问题,且不用修改Zhi类和ICourse类,因为Zhi类是依赖于抽象的接口的。依赖倒置还表达出这样的一个事实:相对于细节的多变性,抽象的东西要稳定的多,以抽象为基础搭建出来的架构比以细节为基础搭建出来的架构要稳定的多,那我们抽象的目的其实就是指定好规范和契约,例如说ICourse这个接口,具体的实现交给具体的实现类而高层模块Zhi是不依赖于具体的课程实现,因为现在是这几个种类,说不定到时又出现啥新的课程了,那Zhi这个类不用动,其他课程也不用动,只需要新增实现就可以了,所以核心思想就是面向接口编程,那么理解了依赖注入原则,那么对理解spring的依赖注入和控制反转就比较简单了。
优点
符合我们常说的高内聚低耦合的设计思想,从而使类具有很好的可读性,可拓展性和可维护性