Files
CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:设计原则/18 | 理论四:接口隔离原则有哪三种应用?原则中的“接口”该如何理解?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

400 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="18 | 理论四:接口隔离原则有哪三种应用?原则中的“接口”该如何理解?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/05/e60f6e929d047f92e7877bb5aea82805.mp3"></audio>
上几节课中我们学习了SOLID原则中的单一职责原则、开闭原则和里式替换原则今天我们学习第四个原则接口隔离原则。它对应SOLID中的英文字母“I”。对于这个原则最关键就是理解其中“接口”的含义。那针对“接口”不同的理解方式对应在原则上也有不同的解读方式。除此之外接口隔离原则跟我们之前讲到的单一职责原则还有点儿类似所以今天我也会具体讲一下它们之间的区别和联系。
话不多说,现在就让我们正式开始今天的学习吧!
## 如何理解“接口隔离原则”?
接口隔离原则的英文翻译是“ Interface Segregation Principle”缩写为ISP。Robert Martin在SOLID原则中是这样定义它的“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是客户端不应该被强迫依赖它不需要的接口。其中的“客户端”可以理解为接口的调用者或者使用者。
实际上“接口”这个名词可以用在很多场合中。生活中我们可以用它来指插座接口等。在软件开发中我们既可以把它看作一组抽象的约定也可以具体指系统与系统之间的API接口还可以特指面向对象编程语言中的接口等。
前面我提到,理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西:
- 一组API接口集合
- 单个API接口或函数
- OOP中的接口概念
接下来,我就按照这三种理解方式来详细讲一下,在不同的场景下,这条原则具体是如何解读和应用的。
## 把“接口”理解为一组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 {
// ...省略实现代码...
}
```
在刚刚的这个例子中,我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。
## 把“接口”理解为单个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&lt;Long&gt; dataSet) {
Statistics statistics = new Statistics();
//...省略计算逻辑...
return statistics;
}
```
在上面的代码中count()函数的功能不够单一包含很多不同的统计功能比如求最大值、最小值、平均值等等。按照接口隔离原则我们应该把count()函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:
```
public Long max(Collection&lt;Long&gt; dataSet) { //... }
public Long min(Collection&lt;Long&gt; dataSet) { //... }
public Long average(Colletion&lt;Long&gt; dataSet) { //... }
// ...省略其他统计函数...
```
不过你可能会说在某种意义上讲count()函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。我们在讲单一职责原则的时候,也提到过类似的问题。实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。
如果在项目中对每个统计需求Statistics定义的那几个统计信息都有涉及那count()函数的设计就是合理的。相反如果每个统计需求只涉及Statistics罗列的统计信息中一部分比如有的只需要用到max、min、average这三类统计信息有的只需要用到average、sum。而count()函数每次都会把所有的统计信息计算一遍就会做很多无用功势必影响代码的性能特别是在需要统计的数据量很大的时候。所以在这个应用场景下count()函数的设计就有点不合理了,我们应该按照第二种设计思路,将其拆分成粒度更细的多个统计函数。
不过,你应该已经发现,接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
## 把“接口”理解为OOP中的接口概念
除了刚讲过的两种理解方式我们还可以把“接口”理解为OOP中的接口概念比如Java中的interface。我还是通过一个例子来给你解释。
假设我们的项目中用到了三个外部系统Redis、MySQL、Kafka。每个系统都对应一系列配置信息比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息供项目中的其他模块来使用我们分别设计实现了三个Configuration类RedisConfig、MysqlConfig、KafkaConfig。具体的代码实现如下所示。注意这里我只给出了RedisConfig的代码实现另外两个都是类似的我这里就不贴了。
```
public class RedisConfig {
private ConfigSource configSource; //配置中心比如zookeeper
private String address;
private int timeout;
private int maxTotal;
//省略其他配置: maxWaitMillis,maxIdle,minIdle...
public RedisConfig(ConfigSource configSource) {
this.configSource = configSource;
}
public String getAddress() {
return this.address;
}
//...省略其他get()、init()方法...
public void update() {
//从configSource加载配置到address/timeout/maxTotal...
}
}
public class KafkaConfig { //...省略... }
public class MysqlConfig { //...省略... }
```
现在我们有一个新的功能需求希望支持Redis和Kafka配置信息的热更新。所谓“热更新hot update”就是如果在配置中心中更改了配置信息我们希望在不用重启系统的情况下能将最新的配置信息加载到内存中也就是RedisConfig、KafkaConfig类中。但是因为某些原因我们并不希望对MySQL的配置信息进行热更新。
为了实现这样一个功能需求我们设计实现了一个ScheduledUpdater类以固定时间频率periodInSeconds来调用RedisConfig、KafkaConfig的update()方法更新配置信息。具体的代码实现如下所示:
```
public interface Updater {
void update();
}
public class RedisConfig implemets Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig { //...省略其他属性和方法... }
public class ScheduledUpdater {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();;
private long initialDelayInSeconds;
private long periodInSeconds;
private Updater updater;
public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
this.updater = updater;
this.initialDelayInSeconds = initialDelayInSeconds;
this.periodInSeconds = periodInSeconds;
}
public void run() {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
updater.update();
}
}, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
}
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource(/*省略参数*/);
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
kafkaConfigUpdater.run();
}
}
```
刚刚的热更新的需求我们已经搞定了。现在我们又有了一个新的监控功能需求。通过命令行来查看Zookeeper中的配置信息是比较麻烦的。所以我们希望能有一种更加方便的配置信息查看方式。
我们可以在项目中开发一个内嵌的SimpleHttpServer输出项目的配置信息到一个固定的HTTP地址比如[http://127.0.0.1:2389/config](http://127.0.0.1:2389/config) 。我们只需要在浏览器中输入这个地址就可以显示出系统的配置信息。不过出于某些原因我们只想暴露MySQL和Redis的配置信息不想暴露Kafka的配置信息。
为了实现这样一个功能,我们还需要对上面的代码做进一步改造。改造之后的代码如下所示:
```
public interface Updater {
void update();
}
public interface Viewer {
String outputInPlainText();
Map&lt;String, String&gt; output();
}
public class RedisConfig implemets Updater, Viewer {
//...省略其他属性和方法...
@Override
public void update() { //... }
@Override
public String outputInPlainText() { //... }
@Override
public Map&lt;String, String&gt; output() { //...}
}
public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}
public class MysqlConfig implements Viewer {
//...省略其他属性和方法...
@Override
public String outputInPlainText() { //... }
@Override
public Map&lt;String, String&gt; output() { //...}
}
public class SimpleHttpServer {
private String host;
private int port;
private Map&lt;String, List&lt;Viewer&gt;&gt; viewers = new HashMap&lt;&gt;();
public SimpleHttpServer(String host, int port) {//...}
public void addViewers(String urlDirectory, Viewer viewer) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList&lt;Viewer&gt;());
}
this.viewers.get(urlDirectory).add(viewer);
}
public void run() { //... }
}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);
public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater =
new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();
ScheduledUpdater kafkaConfigUpdater =
new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer(&quot;/config&quot;, redisConfig);
simpleHttpServer.addViewer(&quot;/config&quot;, mysqlConfig);
simpleHttpServer.run();
}
}
```
至此,热更新和监控的需求我们就都实现了。我们来回顾一下这个例子的设计思想。
我们设计了两个功能非常单一的接口Updater和Viewer。ScheduledUpdater只依赖Updater这个跟热更新相关的接口不需要被强迫去依赖不需要的Viewer接口满足接口隔离原则。同理SimpleHttpServer只依赖跟查看信息相关的Viewer接口不依赖不需要的Updater接口也满足接口隔离原则。
你可能会说如果我们不遵守接口隔离原则不设计Updater和Viewer两个小接口而是设计一个大而全的Config接口让RedisConfig、KafkaConfig、MysqlConfig都实现这个Config接口并且将原来传递给ScheduledUpdater的Updater和传递给SimpleHttpServer的Viewer都替换为Config那会有什么问题呢我们先来看一下按照这个思路来实现的代码是什么样的。
```
public interface Config {
void update();
String outputInPlainText();
Map&lt;String, String&gt; output();
}
public class RedisConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}
public class KafkaConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}
public class MysqlConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}
public class ScheduledUpdater {
//...省略其他属性和方法..
private Config config;
public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {
this.config = config;
//...
}
//...
}
public class SimpleHttpServer {
private String host;
private int port;
private Map&lt;String, List&lt;Config&gt;&gt; viewers = new HashMap&lt;&gt;();
public SimpleHttpServer(String host, int port) {//...}
public void addViewer(String urlDirectory, Config config) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList&lt;Config&gt;());
}
viewers.get(urlDirectory).add(config);
}
public void run() { //... }
}
```
这样的设计思路也是能工作的,但是对比前后两个设计思路,在同样的代码量、实现复杂度、同等可读性的情况下,第一种设计思路显然要比第二种好很多。为什么这么说呢?主要有两点原因。
**首先,第一种设计思路更加灵活、易扩展、易复用。**因为Updater、Viewer职责更加单一单一就意味了通用、复用性好。比如我们现在又有一个新的需求开发一个Metrics性能统计模块并且希望将Metrics也通过SimpleHttpServer显示在网页上以方便查看。这个时候尽管Metrics跟RedisConfig等没有任何关系但我们仍然可以让Metrics类实现非常通用的Viewer接口复用SimpleHttpServer的代码实现。具体的代码如下所示
```
public class ApiMetrics implements Viewer {//...}
public class DbMetrics implements Viewer {//...}
public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mySqlConfig = new MySqlConfig(configSource);
public static final ApiMetrics apiMetrics = new ApiMetrics();
public static final DbMetrics dbMetrics = new DbMetrics();
public static void main(String[] args) {
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer(&quot;/config&quot;, redisConfig);
simpleHttpServer.addViewer(&quot;/config&quot;, mySqlConfig);
simpleHttpServer.addViewer(&quot;/metrics&quot;, apiMetrics);
simpleHttpServer.addViewer(&quot;/metrics&quot;, dbMetrics);
simpleHttpServer.run();
}
}
```
**其次,第二种设计思路在代码实现上做了一些无用功。**因为Config接口中包含两类不相关的接口一类是update()一类是output()和outputInPlainText()。理论上KafkaConfig只需要实现update()接口并不需要实现output()相关的接口。同理MysqlConfig只需要实现output()相关接口并需要实现update()接口。但第二种设计思路要求RedisConfig、KafkaConfig、MySqlConfig必须同时实现Config的所有接口函数update、output、outputInPlainText。除此之外如果我们要往Config中继续添加一个新的接口那所有的实现类都要改动。相反如果我们的接口粒度比较小那涉及改动的类就比较少。
## 重点回顾
今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。
**1.如何理解“接口隔离原则”?**
理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
如果把“接口”理解为单个API接口或函数部分调用者只需要函数中的部分功能那我们就需要把函数拆分成粒度更细的多个函数让调用者只依赖它需要的那个细粒度函数。
如果把“接口”理解为OOP中的接口也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一不要让接口的实现类和调用者依赖不需要的接口函数。
**2.接口隔离原则与单一职责原则的区别**
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
## 课堂讨论
今天课堂讨论的话题是这样的:
java.util.concurrent并发包提供了AtomicInteger这样一个原子类其中有一个函数getAndIncrement()是这样定义的:给整数增加一,并且返回未増之前的值。我的问题是,这个函数的设计是否符合单一职责原则和接口隔离原则?为什么?
```
/**
* Atomically increments by one the current value.
* @return the previous value
*/
public final int getAndIncrement() {//...}
```
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。