一文带你看懂接口隔离原则

74 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第15天,点击查看活动详情

接口隔离原则

1 概述

interface-segregation principles,ISP,指明客户(client)不应被迫使用对其而言无用的方法或功能。

接口隔离原则(ISP)拆分非常庞大臃肿的接口成为更小的和更具体的接口,这样客户将会只需要知道他们感兴趣的方法。这种缩小的接口也被称为角色接口(role interfaces)。

接口隔离原则(ISP)的目的是系统解开耦合,从而容易重构,更改和重新部署。接口隔离原则是在SOLID中五个面向对象设计(OOD)的原则之一,类似于在GRASP中的高内聚性。

在面向对象设计中,接口(interface)提供了便于代码在概念上解释的抽象层,并创建了避免依赖的一个屏障。

2 统一缓存服务开发

开发一个统一缓存服务,要求能根据远程配置中心的配置信息,在运行期动态更改缓存配置:

  • 可能将本地缓存更改为远程缓存
  • 也可能更改远程缓存服务器集群的IP地址列表,进而改变应用程序使用的缓存服务

这就要求缓存服务的客户端SDK必须支持运行期配置更新,而配置更新又直接影响缓存数据的操作,于是就设计出这样一个缓存服务Client类:

该缓存服务Client类的方法主要包含:

  • 缓存服务方法,get()、put()、delete()这些,这些方法是面向调用者的
  • 配置更新方法reBuild(),这个方法主要是给远程配置中心调用的

但问题是,Cache类的调用者如果看到reBuild()方法,并错误地调用了该方法,就可能导致Cache连接被错误重置,导致无法正常使用Cache服务。所以必须要将reBuild()方法向缓存服务的调用者隐藏,而只对远程配置中心的本地代理开放。

但是reBuild()方法是一个public方法,如何对类的调用者隐藏类的公有方法

接口隔离原则:不应该强迫用户依赖他们不需要的方法。若强迫用户依赖他们不需要的方法:

  • 用户可以看到这些他们不需要,也不理解的方法,这样无疑会增加他们使用的难度,错误调用这些方法,就会bug
  • 当这些方法如果因为某种原因需要更改的时候,虽然不需要但是依赖这些方法的用户程序也必须做出更改,这是不必要的耦合

但若一个类的几个方法之间本来就是互相关联,就像我开头举的那个缓存Client SDK的例子,reBuild()方法必须要在Cache类里,这时如何做到不强迫用户依赖他们不需要的方法?

3 Modem案例

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接口

    对外暴露send()和recv(),只负责网络数据的发送和接收,网络游戏或者网络浏览器只依赖这个接口进行网络数据传输。这些应用程序不需要依赖它们不需要的dail()和hangup()方法,对应用开发者更加友好,也不会导致因错误的调用而引发的程序bug

  • Connection接口

    网络管理程序依赖,提供显式的UI让用户拨号上网或者挂断网络,进行网络连接管理

通过使用接口隔离原则,将一个实现类的不同方法包装在不同接口中对外暴露。应用程序只依赖它们需要的方法,而不会看到不需要的方法。

4 接口隔离原则优化案例

门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类,让其实现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职责也变复杂,违反SIP,维护更困难。

应遵循接口隔离原则,这里有两个互相独立的接口:

  • TimerClient,供Timer进行超时控制
  • Door,控制门的操作

虽然超时锁门是个完整动作,但依然能使用接口使其隔离:

  • 通过委托进行接口隔离:增加一个适配器DoorTimerAdapter,继承TimerClient接口实现timeout()方法,并将自己注册给Timer。适配器在自己的timeout()方法,调用Door的方法实现超时锁门。

这种场合使用的适配器可能较重,业务逻辑较多,若超时时需要执行较多逻辑操作,则适配器的timeout()方法就会包含很多业务逻辑,超出适配器职责范围。而若这些逻辑操作还需要使用Door内部状态,可能还需要迫使Door修改。

接口隔离更典型的做法是使用多重继承,类似Modem,TimedDoor同时实现TimerClient接口和继承Door类,在TimedDoor中实现timeout()方法,并注册到Timer定时器中。

这样,使用Door的程序无需被迫依赖timeout()方法,Timer也不会看到Door的方法,程序更加整洁,易于复用。

5 接口隔离原则在迭代器设计模式中的应用

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可以针对该接口优化,提供更便利、简洁、统一语法。

6 总结

如何让缓存类的使用者看不到缓存重构的方法,以避免不必要的依赖和方法的误用?使用接口隔离原则,通过多继承实现接口隔离。

Cache实现类XCache(X是开发的统一缓存服务的产品名)同时实现:

  • Cache接口,提供标准Cache服务方法,应用程序只需依赖该接口
  • 而CacheManageable接口则对外暴露reBuild()方法,使远程配置服务可以通过自己的本地代理调用这个方法,在运行期远程调整缓存服务的配置,使系统无需重新部署就可以热更新

最后的缓存服务SDK核心类设计:

当一个类较大时,若该类的不同调用者被迫依赖类的所有方法,就可能产生不必要耦合。对该类的改动也可能影响到它的不同调用者,引起误用,导致对象被破坏,引发bug。

使用接口隔离原则:定义多个接口,不同调用者依赖不同接口,只看到自己需要的方法。而实现类则实现这些接口,通过多个接口将类内部不同的方法隔离。