天天看点

[5+1]接口隔离原则(二)

前言

面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。

[5+1]接口隔离原则(二)

↑ 五个,再加一个,就是5+1个。哈哈哈。↑

这六个设计原则的位置有点不上不下。论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。

所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。

[5+1] 接口隔离原则(一)

上一部分在这里。

[5+1] 接口隔离原则(二)

接口隔离与面向对象

我记得,项目管理中有一项“干系人管理”。在干系人管理中,我们需要识别出与项目存在利益关系的各方,然后确定各自的关注点,最后根据不同的关注点做不同的沟通协作、资源协调、期望管理、结果与过程汇报等。

[5+1]接口隔离原则(二)

项目干系人管理

在干系人管理中,我们需要注意一点:不同关系人的关注点大多不一样。用户关注能不能满足需求;客户关注能不能赚到钱;boss大佬关注结果,项目经理关注过程;产品经理关注功能,技术经理关注质量;对接系统的开发关注接口文档,系统内部开发关注流程、类和库表设计……

在实践中,我们常常会从一套基础数据中提取不同内容,以满足不同干系人的不同关注点。例如,一份详细设计文档就可以满足产品经理、技术经理、对接开发和内部开发的关注点;一份分工排期表既可以让大佬知道什么时候有结果,也可以让项目经理知道过程中需要注意哪些人、把控哪些点。

尽管有很多不同数据都来自同一个源头,但我们一般不会把基础数据直接分发给不同的干系人。项目经理把进度日报发给boss,boss也许眉头一皱嫌他太啰嗦然后把他开掉了。游戏策划把发给客户的抽卡/氪金分析数据捅给用户,用户也许眉头一皱游戏太垃圾然后就退游保肝了。

[5+1]接口隔离原则(二)

说难听点就是“见人说人话见鬼说鬼话”

面向对象中也有类似的设计思路。有时候,尽管底层使用的是同一个类,但是,面向不同调用方时,我们会提供不同的接口。典型的例子就是LinkedList:

LinkedList类实现了List<E>(从而实现了Collection<E>)、Deque<E>(从而实现了Queue<E>)等接口。因而,当需要使用有序集合、并且随机写入数据时,我们就可以通过List<E>接口来操作它。如果只需要从表头写入、从表尾读取时,我们也可以只用Queue<E>接口来操作它:

类似的还有new ConcurrentHashmap().keySet()——明明是ConcurrentHashMap,生被用成了ConcurrentHashSet。

我们的业务系统中也有这种类,最典型的就是数据库操作类。一般来说,数据库操作的增删改查都会放在同一个Dao或者Mapper类中。其中的读操作还好说,写操作必须严密封锁起来,以确保只能在业务操作、业务事务中以一致性方式写入数据。否则的话,“你也说聊斋,我也说聊斋”,大家乱涂乱画起来,岂不要把婴宁嫁给宁采臣了?

[5+1]接口隔离原则(二)

聂小倩第一个不答应

封锁写操作的最简单方式,就是接口隔离:读操作和写操作定义成不同的接口。读接口可以任意使用;写接口只允许业务操作使用,其它操作想要写入数据库,必须调用业务操作接口。这样,就可以避免完整业务数据被部分写入、进而违反业务一致性的问题了。

从不同的方面描述同一件事情,无论在管理上还是在面向对象设计上,都是一种很高效而且很必要的工作方式。在管理上,我们把这种工作方式叫做“见人说人话,见鬼说鬼话”;在面向对象设计上,我们把它叫做“接口隔离原则”。

接口隔离与抽象

很多时候,我们一提到抽象,就会直接把它与接口划上等号。所以很自然的,谈到接口隔离与抽象,我们也会直接地想到把“接口隔离”与“更小的抽象”划上等号。

这个观点倒也没有什么大问题。尤其是当接口隔离原则被简化为“把庞大而臃肿的接口拆分成更小、更具体的接口”时,它与抽象之间的关系自然就只能是“把庞大而臃肿的抽象拆分为更小、更具体的抽象”了。

例如,有时我们会在Dao层之上,增加一个DbService层,将其用作数据库操作的更高层抽象:

这个数据库操作抽象看起来不错,而且蛮通用的。不过,在业务中,我们可以允许任一功能模块都来读数据,但只能允许在特定的业务流程中写数据。因此,读操作和写操作应当区别对待。

然而,DbService所定义的抽象却把读、写两个操作同时暴露了出来:只要可以读数据,就可以写数据。例如:

实际上这个类中只需要查询数据。但是注入DbService接口之后,这个类也具备了写数据的能力。也就是说,“写操作”被泄露到了限定的业务流程之外。虽然大多数情况下,泄露出去的“写操作”都是可控的;然而对“我不想卖、你不能卖”的抽象设计来说,这就是一个设计上的问题。

要改正这个问题,其实也很简单:把读写操作拆分到两个接口中就可以了:

这种接口拆分,不正是接口隔离原则所要求的吗?

接口隔离原则与抽象之间的关系,不仅仅是这样的接口拆分。如果说设计抽象的目的是“我不想卖、你不能买”,那么接口隔离原则的要求就是“我不想买、你不能卖”——对,合起来就是“不能强买强卖”。严格的说,同时符合了这两个要求的抽象,才是合格的设计。

[5+1]接口隔离原则(二)

旅游和购物也应该“隔离”开

就像前面列举的一些例子一样:调用者并不关心doSth()方法的步骤,接口就不应该提供诸如step1()/step2()这样的方法;调用者只需要approve()方法,接口就不应该提供queryUser()方法。虽然从抽象设计的角度来看,它们的确是服务方自愿提供的方法。但从接口隔离原则的角度来看,这些方法可不是调用方想要的东西。

这就像去理发店理发时,Tony老师推销给你一张五折会员卡并说服你预存一千块钱一样——看起来是他让顾客得到了实惠,实际上是他“绑架”了顾客下一次的消费行为。对于消费者来说,换一家理发店的成本也许不高,何况原先的卡还可以挂咸鱼卖掉;但是对系统来说,一次重构调整的成本可就不好说了。如果能把重构范围约束在抽象内部,那大概就花个工本费;如果重构范围包括了接口的所有调用方——尤其是分布式环境下的接口调用方——那简直就是地狱难度了。

接口隔离与高内聚低耦合

其实前面已经把接口隔离与高内聚低耦合之间的关系表述得很清楚了:适当地遵循接口隔离原则,有助于创建高内聚低耦合的抽象和模块。

例如,把SomeService接口中的step1()/step2()等方法删掉,只保留doSth()方法,不仅遵循了接口隔离原则,也降低了服务调用者与提供者之间的耦合度。结合《细说几种耦合》来看,这个改造至少可以避免双方产生内容耦合。

而把FlowService中关于用户的功能拆分到UserService中,则可以有效地提高对应模块的内聚性:用户相关功能和流程相关功能都放到各自的模块中,内聚性至少可以从偶然内聚提高到过程内聚甚至顺序内聚(参考《细说几种内聚》)。内聚性提高了,自然地,用户模块和流程模块之间的耦合性也降低了。

虽然遵循接口隔离原则有助于提高内聚性、降低耦合性,但是“过犹不及”,过于强调接口隔离,有时反而会降低内聚、增加耦合。

例如,Java中的Iterator接口中,就有这样两个方法:

大多数情况下,hasNext()方法和next()都是配套使用的。可以说,这两个方法在一起,才能构成一个完整的迭代器抽象。如果我们机械地套用接口隔离原则,把它俩硬生生地拆分到两个不同的接口中,反而降低了这个抽象设计的内聚性。

实际上,前面所讨论的把DbService接口拆分成DbReader和DbWriter的例子,也可能产生类似的问题。如果DbService不是供业务逻辑使用、而是仅仅提供资源服务,那么增删改查操作就没有必要拆开了。

[5+1]接口隔离原则(二)

好好的家伙事儿拆得稀碎也不行

接口隔离与封装继承多态

接口隔离原则与封装的关系非常容易理解;相比之下,它与继承、多态之间的关系就不那么清晰了。

在面向对象中,接口是实现封装特性的最有力也最常见的手段。与接口密切相关的接口隔离原则,自然也与封装特性有着密切的关系。

相信我们很多人都被“过度包装”恶心过:实际的商品重不到三两、大不过拳头,非要左一层“精美包装”、右一层“豪华包装”。结果呢?买的人花一笔冤枉钱买了个不痛快,用的人拆一大堆空盒子用得不痛快。哪怕是用来收礼,如果知道这“礼物”的90%是包装盒,送礼的人恐怕也会觉得脸面无光吧!

[5+1]接口隔离原则(二)

过度包装

冗长的接口和过度包装的问题一样,都是自以为是地把一大堆用户不需要的、深恶痛绝的东西强加给用户。这种“强加于人”,在市场营销中叫“捆绑销售”,在面向对象中就叫“不当封装”:该“封”起来的没有做好密封,不该“装”进来的一股脑地装到了一起。

可见,恰当的接口隔离可以保证我们的类拥有更好的封装性。同样的,做好接口隔离,也能在类的继承方面给我们提供便利。

在Java中,由于接口方法都只有方法签名、没有方法体,因此,实现类只有两个选择:将自身声明为抽象类,或者实现接口中的所有方法。虽然Java8允许接口方法定义方法体、以提供一个默认实现,但这个默认实现的功能非常弱,基本只能用来向下兼容,真要实现业务功能,还得靠实现类来重写方法。总之,我们仍可以认为:接口中声明的方法,最终都要被实现类重写。由此可以推断:一个只有三个方法的接口,和一个包含了十三个方法的接口相比,显然是前者对实现类更友好。

当然,我们也可以采用接口-基类-实现类的层次结构,来减少实现大接口时的开发量。例如下面这样:

这样做,确实可以解决每次实现接口都需要重写所有方法的问题。不过,此时我们又要面对另一个可能更严重的问题:如果我们的SubService只需要提供method5()这一个方法,而不需要提供BaseService中的其它功能,也就是说前者与后者并不满足继承所要求的“is-a”关系,此时我们让SubService继承BaseService,真的不是捡起芝麻丢了西瓜吗?

诚然,并不是所有的大接口都会有这样的问题;但几乎所有的小接口都没有这种问题。反过来说,使用小接口时,我们几乎不用担心出现过度继承问题;而使用大接口时,我们至少应该认真思考一下这个接口及其继承层次是否合理。这也就是此前提到的接口隔离原则的定位:它并不是绝对不可打破的禁忌,而是潜在问题、系统风险的一种指示剂。

相比封装与继承,接口隔离原则与多态之间的关系更加直观些。如果不使用多态,那么我们一定会违反接口隔离原则:当一个接口下只有一个实现类时,增加新的逻辑是都难免要增加接口方法,久而久之,这个接口就会变成一个巨无霸,接口隔离原则自然就无从谈起了。反过来说,使用多态特性,我们就应该遵守接口隔离原则、应该定义和使用“小而美”的接口。否则的话——设想一个声明了十多个方法的接口,每次借助多态特性来增加新的实现类时,我们都不得不把所有方法都重写一遍,那得多么费劲!

当然,我们仍然可以借助接口-基类-实现类的层次结构避免这个问题。但此时,我们又回到了前面提到的那个问题上:“前者与后者并不满足继承所要求的‘is-a’关系,此时我们让SubService继承BaseService,真的不是捡起芝麻丢了西瓜吗?”

接口隔离与其它设计原则

接口隔离与单一职责

接口隔离原则与单一职责原则之间的关系是显而易见的:违反接口隔离原则,就一定会违反单一职责原则。

无论我们把接口隔离原则定义为“客户端只需要依赖他们需要的接口”、还是定义为“把大接口拆分成小接口”,只要违反了这一原则,接口内就势必会出现不应出现的方法声明。例如前面示例中反复提到的接口实现步骤、其它模块功能等。而接口方法一般都是抽象方法,必须由实现类重写。在两者的叠加影响下,实现类中一定会出现原本不应出现的方法实现。即使我们使用了接口-基类-实现类的层次结构,或者为接口方法提供了默认方法体,也无法解决这一问题:基类中已实现的方法,以及接口中的默认方法,都会被实现类继承下来,成为它自己的功能。这样一来,实现类想要保持单一职责,就只能是个奢望了。

接口隔离原则与单一职责原则之间的这种关系,归根结底的说,是接口与实现类之间的关系决定的:接口对外声明了“我能做什么”,实现类则为接口提供了“怎么做”的功能支撑。这就有点像产品和开发一样:产品提需求,定义“这个产品能做什么”;开发出设计、写代码,解决“怎么做”的问题。

质量低下的产品需求是开发的一大痛苦之源;类似的,质量低下的接口定义也会给开发带来无尽的痛苦。应付糟糕的产品需求已经让人心力交瘁了,开发又何苦为难自己呢?还是认认真真遵守接口隔离原则、定义简单清晰的接口吧!

接口隔离与开闭

在面向对象思想中,开闭原则的核心在于合理、高效地利用继承和多态特性来“增加”新的实现类、而不是“修改”原有的实现类。因此,接口隔离原则与开闭原则之间的关系,需要继承和多态来理解:明白了接口隔离原则与继承、多态之间的关系,也就很容易理解它与开闭原则的关系了。

接口隔离与里氏替换

接口隔离原则主要讨论接口的设计,而里氏替换原则则“下沉”到了继承层次中,主要讨论子类继承父类时的问题。因此,二者的关系与接口隔离和开闭之间的关系一样,也需要绕道继承和多态。

往期索引

《面向对象是什么》

从具体的语言和实现中抽离出来,面向对象思想究竟是什么?公众号:景昕的花园面向对象是什么

《抽象》

抽象这个东西,说起来很抽象,其实很简单。 花园的景昕,公众号:景昕的花园抽象

《高内聚与低耦合》

《细说几种内聚》

《细说几种耦合》

"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。 花园的景昕,公众号:景昕的花园高内聚与低耦合

《封装》

《继承》

《多态》

——“面向对象的三大特性是什么?”——“封装、继承、多态。”

《[5+1]单一职责原则》

单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。花园的景昕,公众号:景昕的花园[5+1]单一职责原则
什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。花园的景昕,公众号:景昕的花园[5+1]开闭原则(一)
里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则 花园的景昕,公众号:景昕的花园[5+1]里氏替换原则(一)
一般我们会说,接口隔离原则是指:把庞大而臃肿的接口拆分成更小、更具体的接口。不过,这并不是接口隔离原则的定义。实际上,接口隔离原则的定义其实是这样的……客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。 花园的景昕,公众号:景昕的花园[5+1]接口隔离原则(一)
[5+1]接口隔离原则(二)