文章目录
- 前言
- 接口隔离原则
- 1、角色的合理划分
- 2、定制服务
- 3、接口污染
- 胖接口减肥
- 总结
前言
在前面几篇文章中中,我们讲的设计原则基本上都是关于如何设计一个类。SRP 告诉我们,一个类的变化来源应该是单一的;OCP 说,不要随意修改一个类;LSP 则教导我们应该设计好类的继承关系。
而在面向对象的设计中,接口设计也是一个非常重要的组成部分。我们一直都在强调面向接口编程,想实现 OCP 也好,或者是下一讲要讲的 DIP 也罢,都是要依赖于接口实现的。
也许你会说,接口不就是一个语法吗?把需要的方法都放到接口里面,接口不就出来了吗?顶多是 Java 用 interface,C++ 都声明成纯虚函数。这种对于接口的理解,显然还停留在语法的层面上。这样设计出来的只能算作是有了一个接口,但想要设计出好的接口,还要有在设计维度上的思考。
那什么样的接口算是一个好接口呢?这就需要我们了解接口隔离原则。
接口隔离原则
不应强迫使用者依赖于它们不用的方法。
No client should be forced to depend on methods it does not use.
这个表述看上去很容易理解,就是指在接口中,不要放置使用者用不到的方法。站在使用者的角度,这简直再合理不过了。每个人都会觉得,我怎么会依赖于我不用的方法呢?相信作为设计者,你也会同意这种观点。然而,真正在设计的时候,却不是人人都能记住这一点的。
那么到底该怎么去理解这个接口隔离原则呢?我觉得可以从三个方面去理解这个事情。
1、角色的合理划分
将“接口”理解为一个类所提供的所有的方法的特征集合,也就是一种在逻辑上才存在的概念,这样的话,接口的划分其实就是直接在类型上的划分。
其实可以这么想,一个接口就相当于剧本中的一个角色,而这个角色在表演的过程中,决定由哪一个演员来进行表演就相当于是接口的实现,因此,一个接口代表的应当是一个角色而不是多个角色,如果系统涉及到多个角色的话,那么每一个角色都应当由一个特定的接口代表才对。
而为了避免我们产山混淆的想法,这时候我们就可以把接口隔离原则理解成角色隔离原则。
2、定制服务
将接口理解成我们开发中狭义的JAVA接口的话,这样子,接口隔离原则讲的就是为同一个角色提供宽窄不同的接口,来应对不同的客户端内容,我画一个简单的图示,大家就完全能明白了。
上面这个办法其实就可以称之为定制服务,在上面的图中有一个角色service以及三个不同的客户端,这三个Client需要的服务是不一样的,所以我给他分成了是三个接口,也就是Service1,Service2和Service3,显而易见,每一个JAVA接口,都仅仅是将Cilent需要的行为暴露给Client,而没有将不需要的方法暴露出去。
其实了解设计模式的很容易就想到这是适配器模式的一个应用场景,我不细聊适配器模式,设计模式我们在知识星球中会进行讲解。
3、接口污染
这句话的意思就是过于臃肿的接口就是对接口的污染。
由于每一个接口都代表一个角色,实现一个接口对象,在他的整个生命周期中,都扮演着这个角色,因此将角色分清就是系统设计的一个重要的工作。因此一个符合逻辑的判断,不应该是将几个不同的角色都交给一个接口,而是应该交给不同的接口来进行处理。
准确而恰当的划分角色以及角色所对应的接口,就是我们面向对象设计中的一个重要的组成部分,如果将没有关系或者关系不大的接口整合到一起去的话,那就是对角色和接口的污染。
胖接口减肥
假设有一个银行的系统,对外提供存款、取款和转账的能力。它通过一个接口向外部系统暴露了它的这些能力,而不同能力的差异要通过请求的内容来区分。所以,我们在这里设计了一个表示业务请求的对象,像下面这样:
class TransactionRequest {
// 获取操作类型
TransactionType getType() {
...
}
// 获取存款金额
double getDepositAmount() {
...
}
// 获取取款金额
double getWithdrawAmount() {
...
}
// 获取转账金额
double getTransferAmount() {
...
}
}
每种操作类型都对应着一个业务处理的模块,它们会根据自己的需要,去获取所需的信息,像下面这样:
interface TransactionHandler {
void handle(TransactionRequest request);
}
class DepositHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getDepositAmount();
...
}
}
class WithdrawHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getWithdrawAmount();
...
}
}
class TransferHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getTransferAmount();
...
}
}
这样一来,我们只要在收到请求之后,做一个业务分发就好了:
TransactionHandler handler = handlers.get(request.getType());
if (handler != null) {
handler.handle(request);
}
一切看上去都很好,不少人在实际工作中也会写出类似的代码。然而,在这个实现里,有一个接口就太’‘胖’'了,它就是 TransactionRequest。
TransactionRequest 这个类包含了相关的请求内容,虽然这是无可厚非的。但是在这里,我们容易直觉地把它作为参数传给 TransactionHandler。于是,它作为一个请求对象,摇身一变,变成了业务处理接口的一部分。
正如我在前面所说的,虽然你没有设计特定的接口,但具体类可以变成接口。不过,作为业务处理中的接口,TransactionRequest 就显得“胖”了:
- getDepositAmount 方法只在 DepositHandler 里使用;
- getWithdrawAmount 方法只在 WithdrawHandler 里使用;
- getTransferAmount 只在 TransferHandler 使用。
然而,传给它们的 TransactionRequest 却包含所有这些方法。
也许你会想,这有什么问题吗?问题就在于,一个“胖”接口常常是不稳定的。比如说,现在要增加一个生活缴费的功能,TransactionRequest 就要增加一个获取生活缴费金额的方法:
class TransactionRequest {
...
// 获取生活缴费金额
double getLivingPaymentAmount() {
...
}
}
相应地,还需要增加业务处理的方法:
class LivingPaymentHandler implements TransactionHandler {
void handle(final TransactionRequest request) {
double amount = request.getLivingPaymentAmount();
...
}
}
虽然这种做法看上去还挺符合 OCP 的,但实际上,由于 TransactionRequest 的修改,前面几个写好的业务处理类:DepositHandler、WithdrawHandler、TransferHandler 都会受到影响。为什么这么说呢?
如果我们用的是一些现代的程序设计语言,你的感觉可能不明显。假如这段代码是用 C/C++ 这些需要编译链接的语言写成的,TransactionRequest 的修改势必会导致其它几个业务处理类重新编译,因为它们都引用了 TransactionRequest。
实际上,C/C++ 的程序在编译链接上常常需要花很多时间,除了语言本身的特点之外,因为设计没做好,造成本来不需要重新编译的文件也要重新编译的现象几乎是随处可见的。
你可以理解为,如果一个接口修改了,依赖它的所有代码全部会受到影响,而这些代码往往也有依赖于它们实现的代码,这样一来,一个修改的影响就传播出去了。用这种角度去评估,你就会发现,不稳定的“胖”接口影响面是非常之广的,所以,我们说“胖”接口不好。
怎样修改这段代码呢?既然这个接口是由于“胖”造成的,给它减肥就好了。根据 ISP,只给每个使用者提供它们关心的方法。所以,我们可以引入一些“瘦”接口:
interface TransactionRequest {
}
interface DepositRequest extends TransactionRequest {
double getDepositAmount();
}
interface WithdrawRequest extends TransactionRequest {
double getWithdrawAmount();
}
interface TransferRequest extends TransactionRequest {
double getTransferAmount();
}
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest {
...
}
这里,我们把 TransactionRequest 变成了一个接口,目的是给后面的业务处理进行统一接口,而 ActualTransactionRequest 则对应着原来的实现类。我们引入了 DepositRequest、WithdrawRequest、TransferRequest 等几个“瘦”接口,它们就是分别供不同的业务处理方法使用的接口。
有了这个基础,我们也可以改造对应的业务处理方法了:
interface TransactionHandler<T extends TransactionRequest> {
void handle(T request);
}
class DepositHandler implements TransactionHandler<DepositRequest> {
void handle(final DepositRequest request) {
double amount = request.getDepositAmount();
...
}
}
class WithdrawHandler implements TransactionHandler<WithdrawRequest> {
void handle(final WithdrawRequest request) {
double amount = request.getWithdrawAmount();
...
}
}
class TransferHandler implements TransactionHandler<TransferRequest> {
void handle(final TransferRequest request) {
double amount = request.getTransferAmount();
...
}
}
经过这个改造,每个业务处理方法就只关心自己相关的业务请求。那么,新增生活缴费该如何处理呢?你可能已经很清楚了,就是再增加一个新的接口:
interface LivingPaymentRequest extends TransactionRequest {
double getLivingPaymentAmount();
}
class ActualTransactionRequest implements DepositRequest, WithdrawRequest, TransferRequest, LivingPaymentRequest {
}
然后,再增加一个新的业务处理方法:
class LivingPaymentHandler implements TransactionHandler<LivingPaymentRequest> {
void handle(final LivingPaymentRequest request) {
double amount = request.getLivingPaymentAmount();
...
}
}
我们可以对比一下两个设计,只有 ActualTransactionRequest 做了修改,而因为这个类表示的是实际的请求对象,在现在的结构之下,它是无论如何都要修改的。而其他的部分因为不存在依赖关系,所以,并不会受到这次需求增加的影响。相对于原来的做法,新设计改动的影响面变得更小了。
总结
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
其实接口隔离原则其实也算是“看人下菜碟”,它的意思就是要看客人是谁,在提供不同档次的饭菜。
从接口隔离原则的角度出发的话,要根据客户不同的需求,去指定不同的服务,这就是接口隔离原则中推荐的方式。