在程序设计领域, SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由 Robert Martin 在21世纪早期 引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。
接口隔离原则,英文为:interface-segregation principle,简称 ISP
。
ISP 最初是由 Robert C. Martin 在为 Xerox 提供咨询时使用和制定的。Xerox 创建了一个新的打印机系统,可以执行各种任务,如装订和传真。该系统的软件是从头开始创建的。随着软件的增长,修改变得越来越困难,以至于即使是最小的更改也需要一个小时的重新部署周期,这使得开发几乎不可能。
设计问题是几乎所有任务都使用单个 Job 类。每当需要执行打印作业或装订作业时,都会调用 Job 类。这导致了一个“胖”类,其中包含多种特定于各种不同客户端的方法。由于这种设计,装订作业会知道打印作业的所有方法,即使它们没有用处。
Martin 建议的解决方案利用了今天所谓的接口隔离原则。应用于 Xerox 软件,使用依赖倒置原则在 Job 类与其客户端之间添加了一个接口层。不是有一个大的 Job 类,而是创建了 Staple Job 接口或 Print Job 接口,它们分别由 Staple 或 Print 类使用,调用 Job 类的方法。因此,为每种作业类型创建了一个接口,这些接口都是由 Job 类实现的。
如何理解接口隔离原则
Robert Martin 在 SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。” 直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的 “客户端”,可以理解为接口的调用者或者使用者。
理解接口隔离原则,重点在于怎么理解"接口",可以从以下方面出发:
- 一组 API 接口集合
- 单个 API 接口或函数
作为“接口”的一组 API 接口集合
现在我们有一组跟账号相关的接口提供给后台查询账户系统调用,包括:获取账号id、获取账号名、获取账号的金额。具体可以看看下面的例子:
public interface AccountService {
String getAccountId();
String getAccountName();
long getMoney();
}
public class AccountServiceImpl implements AccountService {
public String getAccountId() {...}
public String getAccountName() {...}
public long getMoney() {...}
}
此时,后台管理账户系统需要实现账号金额的增减操作,希望账号系统提供相应接口。
这时,我们需要怎么做呢?你可能会直接在 AccountService 新增 addMoney(long) 和 deductMoney(long) 方法。功能是实现解决了,但是这里会给不细心的小伙伴挖坑的。
这里为什么会有坑呢?账户的金额增减是很重要的操作,我们的预期是金额增减操作只提供给到后台管理账户系统,其他系统不能调用,否则哪一天,小伙伴手一抖,坑就大了。从服务架构的角度出发,可以通过接口鉴权来限制接口调用,防止误操作账号金额。
较佳的实现方案,按照接口隔离原则 ISP ,客户端不应该被强迫依赖它不需要的接口,需要把增减账号金额的接口单独放到另外的接口 LimitedAccountService 类中,接口只限于提供给后台管理账户系统调用,其他系统只提供 AccountService 接口,具体实现:
public interface LimitedAccountService {
void addMoney(long money);
void deductMoney(long money);
}
public class LimitedAccountServiceImpl implements LimitedAccountService {
public void addMoney(long money) {...}
public void deductMoney(long money) {...}
}
在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
作为“接口”的单个 API 接口或函数
我们用另外一种角度去理解"接口",理解为单个 API 接口或函数(方便后续描述,简称为"函数")。对于函数,对于接口的理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。
。接下来,我们去看一个例子:
// 玩家信息统计 BO 类
public class UserStatistics {
private int maxLevel;
private int minLevel;
private int averageScore;
// getter and setter
}
public UserStatistics calculate(List<User> users) {
UserStatistics userStatistics = new UserStatistics();
// 统计玩家信息...
return userStatistics;
}
方法 calculate 包含了最高等级、最小等级、平均积分的计算,职责不够单一。当然,判断函数是否职责单一,这也有主观的因素,也存在场景的因素。试想一下,现在我们项目有一些情况是只需要用到最高等级maxLevel 、最低等级 minLevel,另外的一些情况下则只用到平均积分averageScore。如果我们每次都调用函数 calculate 来计算,会把所有信息都计算一遍,会造成很多无用功,浪费了CPU资源,倘若我们的数据量很大,这会导致系统很大的性能压力。
在此场景下,函数 calculate 的设计就不合理了,我们换一种实现方式:
public int calculateMaxLevel(List<User> users) {...}
public int calculateMinLevel(List<User> users) {...}
public int calculateAverageScore(List<User> users) {...}
在第二种实现方式中,我们将函数 calculate 拆分成多个接口实现,按需调用,解决了上面提到的问题。
在这里,或许会有疑问,接口隔离原则和单一职责怎么那么相似的?其实俩个原则侧重的点不一样,单一职责侧重的是模块、类、接口的设计;而接口隔离原则侧重接口的设计,调用者只使用部分接口或者接口的部分功能,我们都可以认为接口的设计不够职责单一。