接口隔离原则带来的复杂性

ISP

什么是ISP,之前总结过,详细内容可回顾《SOLID之ISP》

简单总结:多餐少吃,不要大接口,使用职责单一的小接口。

just so easy!

不就是把大接口拆成小接口嘛!

然而,最近在review之前的代码时,发现了点问题。

简单介绍下背景业务知识,项目是处理发票业务,在公司报销过的人都了解,我们团建、出差,公办支出都会让商家开具一张发票,作为报销凭证。

那么一张发票在被上传到报销软件,行为分为几个部分:

1、上传识别:从一张发票图片,被OCR,识别出一份结构化数据

2、修改:修改发票信息,包括删除、编辑识别出的发票内容,甚至手工填写一张发票信息

3、验真:会调用国税接口,验证一下发票的真伪

4、查询:查看发票详情

每一部分都会有几个方法,为了避免胖接口,自然会拆分成职责更专注的小接口

使用IDEA绘制出类结构:

InvoiceVerifyService:表示发票验真职责

InvoiceDiscernService:表示发票识别职责

InoviceService:表示发票查询、编辑等职责

思路清晰,结构中正。

可在项目中却出现了一段这样的代码:

1
2
3
if(invoiceService instanceof InvoiceVerifyService){
InvoiceVerifyService verifyService = (InvoiceVerifyService)invoiceService;
}

看着instanceof关键字,就倍感别扭。要么抽象得不对,要么结构不对。

如果没有拆分成三个接口,肯定不需要这样的判断。

所以还得重新审视一下ISP。

ISP:接口隔离原则,里面两个关键词:“接口”和“隔离”;“隔离”相对比较简单,从单一职责角度,把职责不相关的行为拆分开。而“接口”则需要重新审视一下。

接口

其实每个人对接口的理解是不一样的,从分类上讲,大该两类,一是狭义:常被理解为像Java语言中的interface,或者模块内部的使用;二是广义:系统间交互契约。

Martin Fowler给了两种类型接口:RoleInterface和HeaderInterface

A role interface is defined by looking at a specific interaction between suppliers and consumers. A supplier component will usually implement several role interfaces, one for each of these patterns of interaction. This contrasts to a HeaderInterface, where the supplier will only have a single interface

大致也是这个意思。

广义

主要是系统间交互的契约。类似于一个系统的facade对外提供的交互方式。

就算你不设计接口,并不代表没有接口。不局限于语言层面的interface,而是一种契约。

最重要的原则是KISS原则,最小依赖原则或者叫最少知识原则,让人望文知义。

追求简单自然,符合惯例。

比如一个微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。

还包含了后台管理系统需要的删除用户功能,如果接口不作隔离,具体代码如下所示:

1
2
3
4
5
6
7
8
9
10

public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);

boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}

然而,删除操作只限于管理后台操作,对其他系统来讲,不仅是多余功能,还有危险性。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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 {
// ...省略实现代码...
}

狭义

狭义常被理解为像Java语言中的interface,或者模块内部的使用。

单纯某一个接口,与单一职责一样,希望接口的职责单一,不要是胖接口、万能接口。

模块内部设计时,不管是模块调用模块,还是模块调用第三方组件。

我们一般有两种选择:

一、是直接依赖所基于的模块或组件;

二、是将所依赖的组件所有方法抽象成一个接口,让模块依赖于接口而不是实现。

其实这在之前对面向对象反思的文章中,提到过,打开我们90%的项目,所有的service都有对应的service接口和serivceImpl实现,整齐划一,美其名曰,面向接口编程。

然而,到项目生命周期结束,一个service都不会有两种实现。

所以,建议还是直接依赖实现,不要去抽象。如无必要,勿增实体

如果我们大量抽象依赖的组件,意味着我们系统的可配置性更好,但复杂性也激增。

什么时候考虑抽象呢?

1、在需要提供多种选择的时候。比如经典的Logger组件。把选择权交给使用方。

这儿也有过度设计的情况,比如数据库访问,抽象对数据库的依赖,以便在MySQL和MongoDB之间切换,在绝大数情况下,这种属于过度设计。毕竟切换数据库本身就是件小概率事件。

2、需要解除一个庞大的外部依赖。有时我们并不是需要多个选择,而是某个依赖过重。我们在测试或其它场景会选择mock一个,以便降低测试系统的依赖

3、在依赖的外部系统为可选组件时。这个时候可以实现一个mock的组件,并在系统初始化时,设置为mock组件。这样的好处,除非用户关心,否则就当不存在一样,降低学习门槛。



回到文章篇头的问题,每个接口职责都是单一明确的,为什么还需要instanceof来判别类型?其实是更上层混合使用了

类似于:

1
Map<String,InvoiceService> invoiceServiceMap = SpringUtils.getBeans();

客户端使用时,得拆分开:

1
2
3
Map<String,InvoiceVerifyService> invoiceServiceMap = SpringUtils.getBeans();
Map<String,InvoiceService> invoiceServiceMap = SpringUtils.getBeans();
Map<String,InvoiceDiscernService> discernServiceMap = SpringUtils.getBeans();

当需要具体能力时,可以从对应的集合中获取对应的Service。而不是通过instanceof去判断。通过空间的换取逻辑的明确性。

VS SRP

接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。

单一职责原则针对的是模块、类、接口的设计。

而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。

它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

总结

表达原则的文字都很简单,但在实践时又会陷入落地时的困境。

这些原则的背后,也体现了架构之道,虚实结合之道。从实悟虚,从虚就实。

公众号:码农戏码
欢迎关注微信公众号『码农戏码』