15丨软件设计的接口隔离原则:如何对类的调用者隐藏类的公有方法?
下载APP
关闭
渠道合作
推荐作者
15丨软件设计的接口隔离原则:如何对类的调用者隐藏类的公有方法?
2019-12-25 李智慧 来自北京
《后端技术面试 38 讲》
课程介绍
讲述:李智慧
时长09:49大小8.99M
我在阿里巴巴工作期间,曾经负责开发一个统一缓存服务。这个服务要求能够根据远程配置中心的配置信息,在运行期动态更改缓存的配置,可能是将本地缓存更改为远程缓存,也可能是更改远程缓存服务器集群的 IP 地址列表,进而改变应用程序使用的缓存服务。
这就要求缓存服务的客户端 SDK 必须支持运行期配置更新,而配置更新又会直接影响缓存数据的操作,于是就设计出这样一个缓存服务 Client 类。
这个缓存服务 Client 类的方法主要包含两个部分:一部分是缓存服务方法,get()、put()、delete() 这些,这些方法是面向调用者的;另一部分是配置更新方法 reBuild(),这个方法主要是给远程配置中心调用的。
但是问题是,Cache 类的调用者如果看到 reBuild() 方法,并错误地调用了该方法,就可能导致 Cache 连接被错误重置,导致无法正常使用 Cache 服务。所以必须要将 reBuild() 方法向缓存服务的调用者隐藏,而只对远程配置中心的本地代理开放这个方法。
但是 reBuild() 方法是一个 public 方法,如何对类的调用者隐藏类的公有方法?
接口隔离原则
我们可以使用接口隔离原则解决这个问题。接口隔离原则说:不应该强迫用户依赖他们不需要的方法。
那么如果强迫用户依赖他们不需要的方法,会导致什么后果呢?
一来,用户可以看到这些他们不需要,也不理解的方法,这样无疑会增加他们使用的难度,如果错误地调用了这些方法,就会产生 bug。二来,当这些方法如果因为某种原因需要更改的时候,虽然不需要但是依赖这些方法的用户程序也必须做出更改,这是一种不必要的耦合。
但是如果一个类的几个方法之间本来就是互相关联的,就像我开头举的那个缓存 Client SDK 的例子,reBuild() 方法必须要在 Cache 类里,这种情况下, 如何做到不强迫用户依赖他们不需要的方法呢?
我们先看一个简单的例子,Modem 类定义了 4 个主要方法,拨号 dail(),挂断 hangup(),发送 send() 和接受 recv()。这四个方法互相存在关联,需要定义在一个类里。
但是对调用者而言,某些方法可能完全不需要,也不应该看到。比如拨号 dail() 和挂断 hangup(),这两个方式是属于专门的网络连接程序的,通过网络连接程序进行拨号上网或者挂断网络。而一般的使用网络的程序,比如网络游戏或者上网浏览器,只需要调用 send() 和 recv() 发送和接收数据就可以了。
强迫只需要上网的程序依赖他们不需要的拨号与挂断方法,只会导致不必要的耦合,带来潜在的系统异常。比如在上网浏览器中不小心调用 hangup() 方法,就会导致整个机器断网,其他程序都不能连接网络。这显然不是系统想要的。
这种问题的解决方法就是通过接口进行方法隔离,Modem 类实现两个接口,DataChannel 接口和 Connection 接口。
DataChannel 接口对外暴露 send() 和 recv() 方法,这个接口只负责网络数据的发送和接收,网络游戏或者网络浏览器只依赖这个接口进行网络数据传输。这些应用程序不需要依赖它们不需要的 dail() 和 hangup() 方法,对应用开发者更加友好,也不会导致因错误的调用而引发的程序 bug。
而网络管理程序则可以依赖 Connection 接口,提供显式的 UI 让用户拨号上网或者挂断网络,进行网络连接管理。
通过使用接口隔离原则,我们可以将一个实现类的不同方法包装在不同的接口中对外暴露。应用程序只需要依赖它们需要的方法,而不会看到不需要的方法。
一个使用接口隔离原则优化的例子
我们再看一个使用接口隔离原则优化设计的例子。假设我们有个门 Door 对象,这个 Door 对象可以锁上,可以解锁,还可以判断门是否打开。
现在我们需要一个 TimedDoor,一个有定时功能的门,如果门开着的时间超过预定时间,就会自动锁门。
我们已经有一个类 Timer,和一个接口 TimerClient:
TimerClient 可以向 Timer 注册,调用 register() 方法,设置超时时间。当超时时间到,就会调用 TimerClient 的 timeout() 方法。
那么,我们如何利用现有的 Timer 和 TimerClient 将 Door 改造成一个具有超时自动锁门的 TimedDoor?
比较容易,且直观的办法就是,修改 Door 类,Door 实现 TimerClient 接口,这样 Door 就有了 timeout() 方法,直接将 Door 注册给 Timer,当超时的时候,Timer 调用 Door 的 timeout() 方法,在 Door 的 timeout() 方法里调用 lock() 方法,就可以实现超时自动锁门的操作。
这个方法简单直接,也能实现需求,但是问题在于使 Door 多了一个 timeout() 方法。如果这个 Door 类想要复用到其他地方,那么所有使用 Door 的程序都不得不依赖一个它们可能根本用不着的方法。同时,Door 的职责也变得复杂,违反了单一职责原则,维护会变得更加困难。这样的设计显然是有问题的。
要想解决这些问题,就应该遵循接口隔离原则。事实上,这里有两个互相独立的接口,一个接口是 TimerClient,用来供 Timer 进行超时控制;一个接口是 Door,用来控制门的操作。虽然超时锁门的操作是一个完整的动作,但是我们依然可以使用接口使其隔离。
一种方法是通过委托进行接口隔离,具体方式就是增加一个适配器 DoorTimerAdapter,这个适配器继承 TimerClient 接口实现 timeout() 方法,并将自己注册给 Timer。适配器在自己的 timeout() 方法中,调用 Door 的方法实现超时锁门的操作。
这种场合使用的适配器可能会比较重,业务逻辑比较多,如果超时的时候需要执行较多的逻辑操作,那么适配器的 timeout() 方法就会包含很多业务逻辑,超出了适配器的职责范围。而如果这些逻辑操作还需要使用 Door 的内部状态,可能还需要迫使 Door 做出一些修改。
接口隔离更典型的做法是使用多重继承,跟前面 Modem 的例子一样,TimedDoor 同时实现 TimerClient 接口和继承 Door 类,在 TimedDoor 中实现 timeout() 方法,并注册到 Timer 定时器中。
这样,使用 Door 的程序就不需要被迫依赖 timeout() 方法,Timer 也不会看到 Door 的方法,程序更加整洁,易于复用。
接口隔离原则在迭代器设计模式中的应用
Java 的数据结构容器类可以通过 for 循环直接进行遍历,比如:
事实上,这种 for 语法结构并不是标准的 Java for 语法,标准的 for 语法在实现上述遍历时应该是这样的:
之所以可以写成上面那种简单的形式,就是因为 Java 提供的语法糖。Java5 以后版本对所有实现了 Iterable 接口的类都可以使用这种简化的 for 循环进行遍历。而我们上面例子的 ArrayList 也实现了这个接口。
Iterable 接口定义如下,主要就是构造 Iterator 迭代器。
在 Java5 以前,每种容器的遍历方法都不相同,在 Java5 以后,可以统一使用这种简化的遍历语法实现对容器的遍历。而实现这一特性,主要就在于 Java5 通过 Iterable 接口,将容器的遍历访问从容器的其他操作中隔离出来,使 Java 可以针对这个接口进行优化,提供更加便利、简洁、统一的语法。
小结
我们再回到开头那个例子,如何让缓存类的使用者看不到缓存重构的方法,以避免不必要的依赖和方法的误用。答案就是使用接口隔离原则,通过多重继承的方式进行接口隔离。
Cache 实现类 BazaCache(Baza 是当时开发的统一缓存服务的产品名)同时实现 Cache 接口和 CacheManageable 接口,其中 Cache 接口提供标准的 Cache 服务方法,应用程序只需要依赖该接口。而 CacheManageable 接口则对外暴露 reBuild() 方法,使远程配置服务可以通过自己的本地代理调用这个方法,在运行期远程调整缓存服务的配置,使系统无需重新部署就可以热更新。
最后的缓存服务 SDK 核心类设计如下:
当一个类比较大的时候,如果该类的不同调用者被迫依赖类的所有方法,就可能产生不必要的耦合。对这个类的改动也可能会影响到它的不同调用者,引起误用,导致对象被破坏,引发 bug。
使用接口隔离原则,就是定义多个接口,不同调用者依赖不同的接口,只看到自己需要的方法。而实现类则实现这些接口,通过多个接口将类内部不同的方法隔离开来。
思考题
在你的开发实践中,你看到过哪些地方使用了接口隔离原则?你自己开发的代码,哪些地方可以用接口隔离原则优化?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 10
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
14 | 软件设计的单一职责原则:为什么说一个类文件打开最好不要超过一屏?
下一篇
16 | 设计模式基础:不会灵活应用设计模式,你就没有掌握面向对象编程
精选留言(19)
- lordrobert2021-04-06慧哥,请教下,接口隔离原则,和上一节说的单一职责原则是否有点“冲突”。需要进行接口隔离的类,感觉必然是有多个引起变化的原因,如果这样,单一职责运用下,设计成两个类,就不需要接口隔离了。既然有这两个原则,问题出在哪里?
作者回复: 是冲突的。 但是文中举的几个例子,都很难实现单一职责原则,因为类的几组方法(也就是几个职责)之间强耦合,难以拆分。 原则是指导我们设计的,但是设计的时候又必须面对现实的困难做出权衡选择,所以就会有“冲突”的感觉。 后面组件设计原则一篇,这种“冲突”感更加强烈。 这也是有时候称软件设计为“艺术”的原因,因为软件设计师并不能机械地遵循什么原则、方法就能设计出好的软件,软件设计是软件设计师在理想与现实、现在与未来、审美与实用、代价与收益各种冲突中权衡、妥协、选择的产物。 软件设计是一个非常依赖人的直觉的工作,技术学习与实践的过程就是不断培养、优化自己直觉的过程。
共 2 条评论14 - 任鑫2020-01-22设计一个类大致和现实中设计一个职能部门差不多,需要考虑两方面需求: (一)是优化资源调度、使用效率,提高效率,减少重复劳动。 (二)是便于对外提供功能服务,使功能的使用者能够清晰明确的使用到自己所需的功能,而不必纠缠其他不必要的功能。 为了满足第一条,软件设计上要注意代码的“高内聚”,就是将关联密切的功能放在一个类里面,以提高重用性;现实世界中,政府部门和企业内部需要将功能类似的组织合并,具体可以参照建国以来国务院下属部委的调整,当然这些调整的原因是多方面的,但其中有一些部门的合并确实考虑到了第一条,例如卫生健康委员会的设立和农业农村部的设立。 为了满足第二条,我们国家设置了“一个单位,两块牌子”的机构,例如今年频频亮剑的“中纪委国家监委”;在软件设计上,功能的调用者不直接通过依赖实现类的方式完成功能调用,而是依赖功能清晰,调用明确的接口,而多个功能接口可以由一个功能内聚的类提供实现。展开
作者回复: 🐂
8 - alex2019-12-25老师能加个代码么?自动锁门那块7
- 旅途2020-01-29老师 Iterable 为什么是属于接口隔离原则这个没太理解 能再详细讲一下吗
作者回复: 每种数据容器实现类的内部都是不同的,但是它们都可以通过Iterable遍历,Client无需关注容器类的实现就能用统一的方式遍历容器。而Iterable接口也看不到容器类的内部实现,所以是一种接口隔离。
6 - 不记年2020-02-01接口隔离和单一职责是一体两面的,一个面向调用者,一个面向实现者5
- 美美2020-01-03想起了,宝玉所说的道,术和器的概念 感觉"道"就是面向对象的的几个特性,封装,继承和多态 "术"就是以道为根基,为理论依据 发展而来的 各种模式, 用来解决软件开发中中的各种违背软件设计原则的方法. 所有的变换皆是"道"而引起的,没有道,就没有术比如少了多态这个道,各种设计模式将会减少大半甚至消亡.展开
作者回复: 👍
5 - 编程爱好者2020-10-19多思多想,代码设计里面有很多细节,建筑领域设计师与施工人员跟架构师跟码工有很多相似的工作内容,李老师在架构领域真的内功深厚,如果从哲学角度再进行一步抽象,那就是大师了2
- escray2020-09-25接口隔离原则看上去比较简单,但是比较依赖 Java 语言 interface 多重继承的特性。 试着写一下 TimerDoor 的代码,适配器模式: class DoorTimerAdapter implements TimerClient { Door door; Timer timer; int timeout; public DoorTimerAdapter(Door door, Timer timer, int timeout) { this.door = door; this.timer = timer; this.timeout = timeout; timer.register(timeout, this); } public void timeout() { door.lock(); } } 多重继承: class Timer { public Timer() { } void register(int timeout, TimerClient client) { } } interface TimerClient { public void timeout(); } class Door { public Door() { } void lock() { } void unlock() { } boolean isDoorOpen() { return false; } } class TimedDoor extends Door implements TimerClient { private int timeout; private Timer timer; public TimedDoor() { this.timer = new Timer(); this.timeout = 10; timer.register(timeout, this); } public void timeout() { lock(); } } 不知道写的对不对,抛砖引玉。 两次都把 timer.register() 写在了构造函数里面,有点别扭。展开共 1 条评论2
- FreezeSoul2020-03-31timer适配器采用组合,而接口隔离采用集成,在门的这个例子里是不是违背了组合优于继承的原则1
- 旅途2020-01-29问一下老师 最后的小结 如果使用者 调用CacheManageable这个接口的reBuild 不还是被访问到了吗
作者回复: CacheManageable这个接口不对使用者开放,所以无法访问。
共 3 条评论1 - 虢國技醬2020-01-21感觉go中的无侵入式的接口方式在这方面更显得自然和简单1
- Zend2019-12-27没想过,说实话 在写代码的时候太过于赶进度,没有对代码进行重构,更没有考虑到自己的设计的这个类如果方法都集中在一起不方便同事调用,让同事产生困惑。1
- niuniu2019-12-25我觉得关键是合理的定义接口的粒度,实践中不同的场景可能同时需要用到同一个类的多个接口,还是要进行强转,让调用方很不爽。1
- java小霸王2022-06-29使用接口隔离最主要得目的还是对调用者友好(一个是暴露的api,一个是,抽取统一方式,相同的行为),否则优先考虑组合。看到说和单一职责有点违背,这里的理解是,被隔离的接口确实是这个对象的行为,实属无奈之举?
- pinteressante2021-01-08请问如何理解 timeDoor 继承了door 但是他没有大于等于door, 也就是正方形继承长方形的问题.
- InfoQ_e077cb3035192020-01-19接口隔离原则,一个类中有不同的业务类型的处理方法,有的场景只需要其中一种业务类型的处理方法,为了在调用时隔离其他方法,可以通过接口定义不同业务类型抽象方法,然后调用方通过该接口进行多态引用
- QQ怪2019-12-30又加深理解了,优秀
- Paul Shan2019-12-29接口隔离原则感觉比较简单,依赖什么就只定义需要的接口,代价是相似的接口被定义好几份,可以用接口间的继承一定程度上消除重复代码。
- 山猫2019-12-25接口隔离原则好是好,就是写着写着就发现接口越来越多,越来越多,甚至会超过本身类的数量,而且每个接口会只使用一次,这样不如直接用外观模式的IDE自动完成了。 我现在用接口主要用于一些模型的规范性和方法参数规范性。如果需要文章中的功能,会拆开为两个类来写,而不是用两个接口加两个类。 不知道我这种设计是否更好一些,或者这个真的和使用场景或项目大小吧。展开