You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

170 lines
10 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 15丨软件设计的接口隔离原则如何对类的调用者隐藏类的公有方法
我在阿里巴巴工作期间曾经负责开发一个统一缓存服务。这个服务要求能够根据远程配置中心的配置信息在运行期动态更改缓存的配置可能是将本地缓存更改为远程缓存也可能是更改远程缓存服务器集群的IP地址列表进而改变应用程序使用的缓存服务。
这就要求缓存服务的客户端SDK必须支持运行期配置更新而配置更新又会直接影响缓存数据的操作于是就设计出这样一个缓存服务Client类。
![](https://static001.geekbang.org/resource/image/1a/15/1a9c02c2cea284dad584de9fd61c1f15.png)
这个缓存服务Client类的方法主要包含两个部分一部分是缓存服务方法get()、put()、delete()这些这些方法是面向调用者的另一部分是配置更新方法reBuild(),这个方法主要是给远程配置中心调用的。
但是问题是Cache类的调用者如果看到reBuild()方法并错误地调用了该方法就可能导致Cache连接被错误重置导致无法正常使用Cache服务。所以必须要将reBuild()方法向缓存服务的调用者隐藏,而只对远程配置中心的本地代理开放这个方法。
但是reBuild()方法是一个public方法**如何对类的调用者隐藏类的公有方法**
## 接口隔离原则
我们可以使用接口隔离原则解决这个问题。接口隔离原则说:**不应该强迫用户依赖他们不需要的方法**。
那么如果强迫用户依赖他们不需要的方法,会导致什么后果呢?
一来用户可以看到这些他们不需要也不理解的方法这样无疑会增加他们使用的难度如果错误地调用了这些方法就会产生bug。二来当这些方法如果因为某种原因需要更改的时候虽然不需要但是依赖这些方法的用户程序也必须做出更改这是一种不必要的耦合。
但是如果一个类的几个方法之间本来就是互相关联的就像我开头举的那个缓存Client SDK的例子reBuild()方法必须要在Cache类里这种情况下 如何做到不强迫用户依赖他们不需要的方法呢?
我们先看一个简单的例子Modem类定义了4个主要方法拨号dail()挂断hangup()发送send()和接受recv()。这四个方法互相存在关联,需要定义在一个类里。
```
class Modem {
void dial(String pno);
void hangup();
void send(char c);
void recv();
}
```
但是对调用者而言某些方法可能完全不需要也不应该看到。比如拨号dail()和挂断hangup()这两个方式是属于专门的网络连接程序的通过网络连接程序进行拨号上网或者挂断网络。而一般的使用网络的程序比如网络游戏或者上网浏览器只需要调用send()和recv()发送和接收数据就可以了。
强迫只需要上网的程序依赖他们不需要的拨号与挂断方法只会导致不必要的耦合带来潜在的系统异常。比如在上网浏览器中不小心调用hangup()方法,就会导致整个机器断网,其他程序都不能连接网络。这显然不是系统想要的。
这种问题的解决方法就是通过接口进行方法隔离Modem类实现两个接口DataChannel接口和Connection接口。
DataChannel接口对外暴露send()和recv()方法这个接口只负责网络数据的发送和接收网络游戏或者网络浏览器只依赖这个接口进行网络数据传输。这些应用程序不需要依赖它们不需要的dail()和hangup()方法对应用开发者更加友好也不会导致因错误的调用而引发的程序bug。
而网络管理程序则可以依赖Connection接口提供显式的UI让用户拨号上网或者挂断网络进行网络连接管理。
![](https://static001.geekbang.org/resource/image/10/61/10abb120ea68d4406276ee24ccecf961.png)
通过使用**接口隔离原则,我们可以将一个实现类的不同方法包装在不同的接口中对外暴露**。应用程序只需要依赖它们需要的方法,而不会看到不需要的方法。
## 一个使用接口隔离原则优化的例子
我们再看一个使用接口隔离原则优化设计的例子。假设我们有个门Door对象这个Door对象可以锁上可以解锁还可以判断门是否打开。
```
class Door {
void lock();
void unlock();
boolean isDoorOpen();
}
```
现在我们需要一个TimedDoor一个有定时功能的门如果门开着的时间超过预定时间就会自动锁门。
我们已经有一个类Timer和一个接口TimerClient
```
class Timer {
void register(int timeout, TimerClient client);
}
interface TimerClient {
void timeout();
}
```
TimerClient可以向Timer注册调用register()方法设置超时时间。当超时时间到就会调用TimerClient的timeout()方法。
那么我们如何利用现有的Timer和TimerClient将Door改造成一个具有超时自动锁门的TimedDoor
比较容易且直观的办法就是修改Door类Door实现TimerClient接口这样Door就有了timeout()方法直接将Door注册给Timer当超时的时候Timer调用Door的timeout()方法在Door的timeout()方法里调用lock()方法,就可以实现超时自动锁门的操作。
```
class Door implements TimerClient {
void lock();
void unlock();
boolean isDoorOpen();
void timeout(){
lock();
}
}
```
这个方法简单直接也能实现需求但是问题在于使Door多了一个timeout()方法。如果这个Door类想要复用到其他地方那么所有使用Door的程序都不得不依赖一个它们可能根本用不着的方法。同时Door的职责也变得复杂违反了单一职责原则维护会变得更加困难。这样的设计显然是有问题的。
要想解决这些问题就应该遵循接口隔离原则。事实上这里有两个互相独立的接口一个接口是TimerClient用来供Timer进行超时控制一个接口是Door用来控制门的操作。虽然超时锁门的操作是一个完整的动作但是我们依然可以使用接口使其隔离。
一种方法是通过委托进行接口隔离具体方式就是增加一个适配器DoorTimerAdapter这个适配器继承TimerClient接口实现timeout()方法并将自己注册给Timer。适配器在自己的timeout()方法中调用Door的方法实现超时锁门的操作。
![](https://static001.geekbang.org/resource/image/19/bd/19e4617b2b08eb6f1741274ece852bbd.png)
这种场合使用的适配器可能会比较重业务逻辑比较多如果超时的时候需要执行较多的逻辑操作那么适配器的timeout()方法就会包含很多业务逻辑超出了适配器的职责范围。而如果这些逻辑操作还需要使用Door的内部状态可能还需要迫使Door做出一些修改。
接口隔离更典型的做法是使用多重继承跟前面Modem的例子一样TimedDoor同时实现TimerClient接口和继承Door类在TimedDoor中实现timeout()方法并注册到Timer定时器中。
![](https://static001.geekbang.org/resource/image/cc/26/cc1d170a4be10f3dfb125291faed6126.png)
这样使用Door的程序就不需要被迫依赖timeout()方法Timer也不会看到Door的方法程序更加整洁易于复用。
## 接口隔离原则在迭代器设计模式中的应用
Java的数据结构容器类可以通过for循环直接进行遍历比如
```
List<String> ls = new ArrayList<String>();
ls.add("a");
ls.add("b");
for(String s: ls) {
System.out.println(s);
}
```
事实上这种for语法结构并不是标准的Java for语法标准的for语法在实现上述遍历时应该是这样的
```
for(Iterator<String> itr=ls.iterator();itr.hasNext();) {
System.out.println(itr.next());
}
```
之所以可以写成上面那种简单的形式就是因为Java提供的语法糖。Java5以后版本对所有实现了Iterable接口的类都可以使用这种简化的for循环进行遍历。而我们上面例子的ArrayList也实现了这个接口。
Iterable接口定义如下主要就是构造Iterator迭代器。
```
public interface Iterable<T> {
Iterator<T> iterator();
}
```
在Java5以前每种容器的遍历方法都不相同在Java5以后可以统一使用这种简化的遍历语法实现对容器的遍历。而实现这一特性主要就在于Java5通过Iterable接口将容器的遍历访问从容器的其他操作中隔离出来使Java可以针对这个接口进行优化提供更加便利、简洁、统一的语法。
## 小结
我们再回到开头那个例子,如何让缓存类的使用者看不到缓存重构的方法,以避免不必要的依赖和方法的误用。答案就是使用接口隔离原则,通过多重继承的方式进行接口隔离。
Cache实现类BazaCacheBaza是当时开发的统一缓存服务的产品名同时实现Cache接口和CacheManageable接口其中Cache接口提供标准的Cache服务方法应用程序只需要依赖该接口。而CacheManageable接口则对外暴露reBuild()方法,使远程配置服务可以通过自己的本地代理调用这个方法,在运行期远程调整缓存服务的配置,使系统无需重新部署就可以热更新。
最后的缓存服务SDK核心类设计如下
![](https://static001.geekbang.org/resource/image/63/9f/6318de0ad618153d520adff4959b169f.jpg)
当一个类比较大的时候如果该类的不同调用者被迫依赖类的所有方法就可能产生不必要的耦合。对这个类的改动也可能会影响到它的不同调用者引起误用导致对象被破坏引发bug。
使用接口隔离原则,就是定义多个接口,不同调用者依赖不同的接口,只看到自己需要的方法。而实现类则实现这些接口,通过多个接口将类内部不同的方法隔离开来。
## 思考题
在你的开发实践中,你看到过哪些地方使用了接口隔离原则?你自己开发的代码,哪些地方可以用接口隔离原则优化?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。