编写JAVA优雅代码,强烈建议你学会SOLID
优雅的代码让人赏心悦目,low 的代码被一批又一批后来人吐槽。
如何才能写出优雅的代码,今天我们就来聊一个特别有效的软件设计原则:SOLID。
本文大纲
一、SOLID
SOLID 来自 “架构整洁之道” 这本经典的书籍,它实际上是五个设计原则首字母的缩写,分别是:
- 单一职责原则(Single responsibility principle, SRP)
- 开放封闭原则(Open–closed principle, OCP)
- Liskov 替换原则(Liskov substitution principle, LSP)
- 接口隔离原则(Interface segregation principle, ISP)
- 依赖倒置原则(Dependency inversion principle, DIP)
二、单一职责原则
2.1 什么是单一职责原则?
单一职责原则,英文是:Single responsibility principle,简称:SRP。这个名字非常容易让我们望文生义,理解成一个类只干一件事。既然 SOLID 原则是由 Robert C. Martin 提出和完善的,那么可以先看看作者对单一职责原则的描述:单一职责原则 (SRP) 指出,任何一个软件模块都应该有一个且只有一个修改的理由。
但是,软件设计是一门关注长期变化的学问,变化是软件中经常遇到的问题,在现实环境中,软件系统为了满足用户和所有者的要求,势必会作出各种修改,而系统的用户或者所有者就是该设计原则所指的”被修改的原因”。因此单一职责被作者重新描述为:任何一个软件模块都应该只对一个用户或者系统利益相关者负责。
该定义中提及了 用户、系统利益者,那么是一个还是多个用户和利益相关者呢?是不是只要希望对系统进行修改的都可以统一为行为者,因此单一职责最终被作者描述为:任何一个软件模块都应该只对某一类行为者负责
2.2 软件模块
上文一直提及的”软件模块”到底是什么呢?大部分情况下,其简单的定义就是指一个源代码文件,比如java中的类,接口, 方法等,也可以是一组紧密相关的函数和数据结构。
任何一个软件模块都应该只对某一类行为者负责,怎么理解呢?我们来看一个例子:
假如设计一个 Employee员工类并且包含3个方法:
刚看上去,这个类设计得还挺符合实际业务,员工有计算薪酬、保存数据、发布促销等行为,但是这3个方法对应三类不同的行为者,违反了单一职责原则。Employee类将三类行为耦合在一起,假如一个普通员工不小心调用了calculatePay()方法,把每个员工的薪酬计算成了实际工资的2位,那可想而知这是一个灾难性的问题。另外,加入没过多久,新的需求来了,要求员工能够导出报表,于是得增加了一个新的方法:
接着需求又一个一个的过来,Employee类就得一次一次的变动,这会导致什么结果呢? 一方面,Employee类会不断的膨胀; 另一方面,内部的实现会越来越复杂。需求完全不同,但是却要改动同一个类,合理吗?
想想你的工作是否也有这样的设计,把很多不同行为都耦合到一个类中,然后随着业务的增加,该类急剧膨胀,最后无法维护。
2.3 解决方案
我们可以有很多不同的方法来解决上述的问题,特定的行为只能由特定的行为者来操作,因此我们可以把Employee拆解成3类行为者,将因相同原因而发生变化的事物聚集在一起,将那些因不同原因而改变的事物分开。这行为就是定义内聚和耦合的一种方式:增加因相同原因而变化的事物之间的内聚,减少因不同原因而变化的事物之间的耦合。
2.4 总结
- 单一职责原则是 SOLID 中最简单的一个;
- 单一职责原则本质上就是要理解分离关注点;
- 单一职责原则可以应用于不同的层次,小到一个函数,大到一个系统,我们都可以用它来衡量我们的设计;
从Robert C. Martin对单一职责描述的变更也可以看出,软件设计也不可能一成不变。映射到实际的工作经验,我们可以把一个系统模块作为一个单一职责,比如:订单系统只关注订单相关的行为,交易系统只关注交易相关的行为;我们可以把类作为一个单一职责,比如:订单类,把订单相关的CRUD聚合在一起。
三、开闭原则
3.1 什么是开闭原则
开放封闭原则,英文是:Open–closed principle, 简称 OCP,开闭原则是 Bertrand Meyer 在1988年提出的,原则指出:
Software entities should be open for extension, but closed for modification.
软件实体应该对扩展开放,对修改关闭。
3.2 如何实现开闭原则?
“对扩展开放,对修改关闭”,如何理解呢?
我们先看一个案例,如下图,给出了某公司电商业务中库存变更的简易模型图,库存系统接收外部系统库存变更事件,然后对数据库中的库存进行修改。
面对这个业务需求,很多人的代码会写出这样:
这时,新的需求来了:WMS仓储系统内部会产生盘点事件(盘盈/盘亏),这些事件会导致变更库存。于是,代码就会发展成下面这样:
很显然,上述代码的实现,每来一个需求,就需要修改一次代码,在 updateStock() 方法中增加一个 else if 分支,因此 Stock 类就一直处于变更中,不稳定。
有没有什么好的办法,可以使得这个代码不用被修改,但是又能够灵活的扩展,满足业务需求呢?
这个时候我们就要搬出java的三大法宝:继承,实现,多态。
我们发现整个业务模型是:事件导致库存变更。所以,能不能把事件抽离出来?于是,可以把事件模型抽象成一个接口,代码如下:
每种事件对应一种库存变更,抽象成一个具体的实现类,代码如下
最后,Stock 类中 updateStock() 库存变更逻辑就可以抽象成下面这样:
经过我们的抽象、分离和改造之后,Stock.updateStock() 类就稳定下来了,再也不需要增加一个事件然后增加一个 else if 分支处理。这种抽象带来的好处也是很明显的:每次有新的库存变更事件,只需要增加一个实现类,其他的逻辑都不需要更改,当库存事件无效时只需要把实现类删除即可。
3.3 总结
通过上面的案例,我们演示了开闭原则的整个抽象和实现过程。业务或许有点简单,但是代表意义很强。
开闭原则的核心是对扩展开放,对修改关闭。因此,当你的业务需求一直需要修改同一段代码时,你就得谨慎观察业务是不是和上述案例有类似的问题,多思考代码修改的理由是什么?它们之间是不是有一定的共同性?能不能把这些变更点分离出来,通过扩展来实现而不是修改代码?
其实在业务开发中还有很多类似的场景,比如:电商系统中的会员系统,需要根据用户不同的等级计算不同的费用;机票系统,根据用户不同的等级(普通,白金用户,黄金用户…)提供不同的售票机制;网关系统中,根据不同的粒度(接口,ip,服务,集群)来实现限流;
四、里式替换原则
4.1 什么是 Liskov替换原则
Liskov替换原则(里式替换原则),英文为:Liskov substitution principle, 简称:LSP,它是以作者 Barbara Liskov(一位美国女性计算机科学家,对编程语言和分布式计算做出了开创性的贡献,于2008年获得图灵奖)的名字命名的,Barbara Liskov 曾在1987年的会议主题演讲“数据抽象”中描述了子类型:
Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.
设Φ(x)是关于T类型对象x的可证明性质。那么对于S类型的对象y,Φ(y)应该为真,其中S是T的子类型。
因为这种科学的定义过于抽象,于是在实际软件开发中的 Liskov 替换原则是这样的:
The principle defines that objects of a superclass shall be replaceable with objects of its subclasses without breaking the application. That requires the objects of your subclasses to behave in the same way as the objects of your superclass.
该原则定义了在不破坏应用程序的前提下,超类的对象应该可以被其子类的对象替换,这就要求子类对象的行为方式与您的超类对象相同。
Robert C. Martin 对SLP的描述更加直接:
Subtypes must be substitutable for their base types.
子类型必须可以替代它们的基本类型。
通过上面几个描述,我们可以把LSP通俗的表达成:子类型必须能够替换其父类型。它在java领域描述的是继承关系。
4.2 如何实现 Liskov替换原则
说起Liskov替换原则的实现,就不得不先看一个著名的违反 LSP设计案例:正方形/长方形问题。尽管这个case已经有点老掉牙,但是为了帮助理解,我们还是炒一次剩饭。
我们的数学知识告诉我们:正方形是一种特殊的长方形,认同吗?因此用java代码可以分别定义 Rectangle(长方形) 和 Square(正方形)两个类,并且 Square extends Rectangle,整体代码如下:
假设现在的需求是计算几何图形的面积,因此面积计算代码会如下实现:
按照我们的数学知识,上面的逻辑是否都没有毛病, 但是用代码实现面积的计算,就会出现歧义:如果 Rectangle r = new Rectangle(); assertThat断言是正确的,如果 Rectangle r = new Square(); assertThat断言就错误的。显然 正方形是一种特殊的长方形,在用代码计算面积时违背了LSP,两个类不能相互替换。这就不禁让我们产生怀疑:Square is a Rectangle?
如何解决这个bad case呢?
可以定义一个几何图形的接口,设定一个计算面积的方法,然后长方形、正方形…都实现这个接口,实现各自的面积计算逻辑,整体思路如下:
我们再来看一个 LSP 使用的例子:
假设有一个股票交易的场景,而且需要支持债券、股票和期权等不同证券类型的多种交易类型,我们就可以考虑使用LSP来解决这个问题
首先,我们定义一个交易的基类,在基类中我们定义了买入和卖出两个方法实现
接着,我们定义了股票交易子类,股票交易和交易基类具有相同的行为:买入和卖出。所以只需要重写基类的方法,实现子类特定的实现就ok了。
同样,我们还可以定义了基金交易子类,基金交易和交易基类具有相同的行为:买入和卖出。所以只需要重写基类的方法,实现子类特定的实现就ok了。
同样,我们还可以定义了债券交易子类,债券交易和交易基类具有相同的行为:买入和卖出。所以只需要重写基类的方法,实现子类特定的实现就ok了。
上述交易的案例,股票交易和基金交易子类替换基类之后,并没有破坏基类的买入卖出行为。更具体地说,替换的子类实例仍提供buy()和sell(),可以以相同方式调用的功能。这个符合LSP。
经过我们的抽象、分离和改造之后,Stock.updateStock()类就稳定下来了,再也不需要增加一个事件然后增加一个else if分支处理。这种抽象带来的好处也是很明显的:每次有新的库存变更事件,只需要增加一个实现类,其他的逻辑都不需要更改,当库存事件无效时只需要把实现类删除即可。
4.3 总结
Liskov替换原则扩展了OCP开闭原则,它描述的子类型必须能够替换其父类型,而不会破坏应用程序。因此,子类需要遵循以下规则:
- 不要对输入参数实施比父类实施更严格的验证规则。
- 至少对父类应用的所有输出参数应用相同的规则。
Liskov替换原则 相对前面的单一职责和开闭原则 稍微晦涩一些,因此在开发中容易误用,实际工作中特别要注意类之间是否存在继承关系。LSP不仅可以用在类关系上,也可以应用在接口设计中。
五、接口隔离原则
5.1 什么是接口隔离原则
接口隔离原则,英文为:Interface segregation principle, 简称:ISP。在java中,我们一直都强调要面向接口编程,足以看出接口在java中的重要性。其实, 与单一职责原则类似,接口隔离原则的目标是通过将软件拆分为多个独立的部分来减少所需更改的副作用和频率。
接下来就看看作者 Robert C. Martin 对接口隔离原则是怎么定义的:
Clients should not be forced to depend upon interfaces that they do not use.
不应强迫客户依赖他们不使用的接口。
不应强迫,这不是很明显的事情吗?顾客是上帝,我们怎么能强迫上帝干不喜欢干的事情呢?
或许是我们误解了”不应强迫”?它会不会有其他含义呢?通常来讲”不应强迫” 有2种理解:
- 第一种理解是用户不能被强迫使用整个接口。
- 第二种理解是用户只使用接口中的部分方法,其余的方法不能被强迫使用。
显然,第二种理解比较合理,所以接口隔离原则可以更直白一点的表达成:在接口中,不要在接口中使用不需要的方法。
站在接口使用者的角度,这样的设计更加人性化,为什么要增加一些我不需要的依赖负担呢?
5.2 如何实现接口隔离原则
首先,让我们来看一个反例,假如有一个交通工具的Transportation类,类中包含设置价格,颜色,以及启停等行为方法:
汽车属于一种交通工具,因此我们可以定义一个 Car类
正如在上面代码的实现可以看出,Car不能飞行却要实现 fly() 方法,为什么?显然fly()这个方法是Car这种交通工具不需要关注,这就违反了接口隔离原则。
如何解决这个问题呢?
解决方案:将交通工具接口分成多个角色接口,每个角色接口用于特定的行为。在这里我们可以分成 BasicFeature、 Movable、Flyable 三类行为接口。
Car 只关注 BasicFeature 和 Movable 行为:
Airplane 需要关注BasicFeature、Movable 行为、Flyble行为:
通过上面的拆解,我们可以看到每种交通工具只需要关注自己需要的接口就好了,自己不需要的接口就不会被强迫关注,更加不会造成 Car 能 fly() 这样的误解。
5.3 总结
- 接口隔离原则要求在接口中,不要在接口使用不需要的方法;
- 做接口设计时,需要关注不同的使用者,不能只关注设计者;
- 大而全的接口不一定是好接口;
六、依赖倒置原则
6.1 什么是依赖倒置原则
依赖倒置原则,英文为:Dependency inversion principle, 简称:DIP。
依赖,顾名思义,就是A代码里面使用了B,A就依赖B,这个是我们最能理解的方式。倒置,莫非是A依赖B,要转成B依赖A?
接下来就看看作者 Robert C. Martin 对接口依赖倒置原则是怎么定义的:
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details. Details should depend upon abstractions.
依赖倒置原则(DIP)指出高层模块不应该依赖低层模块;两者都应该依赖于抽象。抽象不应该依赖于细节。细节应该取决于抽象。
高层模块不应该依赖低层模块;抽象不应该依赖于细节;如何理解呢?先看几个示例,帮助我们理解。
6.2 如何实现依赖倒置原则
高层依赖低层的反例:实现一个通知系统,当用户账户余额不足时通知用户充值,通知的方式有邮箱,短信等,最常规的实现如下:
该代码违背了高层依赖于低层原则,每次低层的实现变更,高层都需要关注,同时该代码也违背了开闭原则。
如何解决?
在计算机领域有个经典的理论:可以通过引入一个中间层来解决依赖。
因此上面的问题,我们可以增加一个中间层,比如:消息中间件
通过引入中间层,高层只需要把信息发送给消息中间件,再也不用依赖于下面的通知实现细节。
抽象依赖实现的反例 在实际开发中,如果使用dubbo框架实现服务之间的 rpc调用,假如订单系统需要从用户系统获取用户的信息。
通常的做法是:订单系统会依赖用户系统的 API,然后通过 API调用用户系统,代码实现如下:
这段代码看起来没有毛病,假如用户系统 API升级了(User类中的name改成了username),如果订单系统需要升级用户系统API,那么只要订单系统里面使用到 User name 的地方,都要修改成username,这个入侵太可怕。本来订单系统只是一个抽象的接口调用用户系统,但是因为直接依赖了结果值里面的字段,导致用户实现细节的变更直接影响订单系统,如何解决?
可以引进一层防腐层
通过上面的代码改造我们可以看出,用户所用的变更都可以在防腐层屏蔽。
SPI机制也是经典的依赖倒置思维, 比如:在 java中 数据库驱动加载机制。
6.3 总结
- 依赖倒置是 SOLID 中最难理解的一种原则;
- 依赖倒置原则(DIP)指出高层模块不应该依赖低层模块;两者都应该依赖于抽象。抽象不应该依赖于细节。细节应该取决于抽象;
七、总结
本文使用实例分别讲解了 SOLID 中各个设计原则:
- 单一职责原则:任何一个软件模块都应该只对某一类行为者负责;
- 开放封闭原则:软件实体应该对扩展开放,对修改关闭;
- Liskov替换原则:子类型必须能够替换其父类型;
- 接口隔离原则:不应强迫客户依赖他们不使用的接口;
- 依赖倒置原则:高层模块不应该依赖低层模块;抽象不应该依赖于细节;
在实际工作中很多人总抱怨业务开发技术成长慢,特别是对于初级程序员,但是大部门人的起点都是业务的CRUD,如果你能在业务 CRUD 过程中想办法”挖掘”和”套用”某些 设计模式,通过这种长期的刻意练习,量变产生质变,慢慢的你就能领会这些经典设计原则的奥妙,无形中,你就和身边的小伙伴拉拉开了举例,终有一天你也能写出让人赏心悦目的代码。
建议在日常的设计、编写和重构代码时多对标这些原则,以便代码更加干净、可扩展和可测试。
八、学习交流
文章总结不易,看到这里的小伙伴,感谢帮忙点赞,收藏,或者转发给更多的好友,我们将为你呈现更多的干货。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!