[读书笔记]SOLID原则
SOLID原则是用来做什么的?
solid原则主要是告诉我们如何把数据和函数组织成为类,以及如何把这些类连接起来成为程序。
PS.这里的类不一定是面向对象的,只是表示数据和函数的一种分组。
solid原则核心是描述软件的中层结构目标,致力于实现:
- 是软件更容易被扩展。
- 使软件更容易被理解。
- 使软件更容易被复用。
PS.还流传这一些其他版本的SOLID原则,比如加入了迪米特法则。
SOLID原则指的是什么?
SOLID原则是指五个设计原则,每个设计原则的首字母拼起来,刚好是SOLID这个单词。
这些设计原则主要有。
- SRP: 单一职责原则。每个软件模块有且只有一个需要被改变的理由。
- OCP: 开闭原则。软件系统应该允许通过新增代码来修改原有系统行为,而不是通过修改现有代码。
- LSP: 里氏替换原则。实现某些接口的组件,必须同时遵守同一个约定,以便让这些组件可以相互替换。
- ISP: 接口隔离原则。只依赖自己需要的部分。
- DIP: 依赖倒置原则。调用方不应该依赖于被调用方的实现,而应该依赖于接口。
原则实践
SRP:单一职责原则
每个软件模块有且只有一个需要被改变的理由。
指南:不要强行复用代码。
这种逻辑在我们的代码里面应该很常见,就是说我们计算员工薪资需要用到员工的工作时长数据,另一方面,企业报表系统也需要一份企业员工的工作时长数据,这两份数据也许一开始的计算逻辑是相同的。但是这样的复用非常危险,因为背后这两个逻辑服务的对象是不同的,他们很有可能提出不同的计算方法,比如薪酬部门提出每天晚上10点以后的工作时间不需要计算在内。
因此,我们在面临这种需求时需要谨慎,考虑清楚。
这个逻辑变化发生的时候变化的原因(需求方)是否相同。
并不是说所有的复用都是不对的,是来自不同需求方的相同逻辑不应该被复用,好比某一个角度相同的两个物体其实不是等价的。
OCP: 开闭原则
软件系统应该允许通过新增代码来修改原有系统行为,而不是通过修改现有代码。
开闭原则是一个大的指导方针。不包含具体的实现指导,所以这里不单独讲了。
LSP: 里氏替换原则
实现某些接口的组件,必须同时遵守同一个约定,以便让这些组件可以相互替换。
指南:不要破坏接口语义约定
这个原则理解起来其实就是不要破坏约定,已经广泛应用在软件架构领域,不局限在单独的接口层面了。典型的例子是正方形/长方形问题。
Rectangle r = ...
r.SetW(5)
r.SetH(2)
assert(r.area() == 10);
长方形继承自正方形会打破这个测试用例,但是正方形又“伪装”成长方形,这是不对的。正确的做法应该是:正方形可以通过组合而的方式把长方形适配成一个正方形,User也不应该类似使用长方形一样来使用正方形。
- ISP: 接口隔离原则。
只依赖自己需要的部分。
指南:尽量使用小接口
尽量使用小的接口,而不是大接口,在这方面go语言是很好的践行者,golang鼓励使用小的接口通过组合的方式来构建大的接口。我们可以看到官方包里面存在大量的小接口。
//database/sql/driver包
type ConnBeginTx interface {
BeginTx(ctx context.Context, opts TxOptions) (Tx, error)
}
type ConnPrepareContext interface {
PrepareContext(ctx context.Context, query string) (Stmt, error)
}
上面这两个接口都是数据库的Conn需要实现的接口(如果支持事物的话),但是官方定义采用了将接口拆分开来的策略。
下图是stdlib、k8s和docker里面的interface定义,画出了下面这幅接口个数与接口中method个数关系的折线图:
小接口方法少,职责单一;易于实现和测试,通用性强(如:io.Reader和Writer),易于组合(如:io.Reader)。
不过要想在业务领域定义出合适的小接口,还是需要对问题域有着透彻的理解的。往往无法定义出小接口,都是由于对领域的理解还不到位,没法抽象到很高的程度所致。
延伸一下,我们业务系统架构的时候,Repository应该是一个大的接口么?
type Repository interface {
SaveOrder(order Order)
SearchOrder(title string) []Order
}
这种设计并不是一个好的设计,SaveOrder和SearchOrder很有可能用的是不同的存储方式(假如是mysql和ES)。这时候我们Repository的实现就需要同时依赖Mysql和ES组件。
这里面其实违反了单一职责原则和接口隔离原则。
- SaveOrderUseCase 仅仅用到了Repository里面的SaveOrder方法,但是却同时依赖了SearchOrder接口,这违反了接口隔离原则。
- 搜索用户的逻辑变更需要修改RepositoryImpl,可能会影响到SaveOrder的逻辑,这违反了单一职责原则。
优势:后置的接口隔离
go语言在接口隔离原则上有一个很大的优势,就是可以不依赖Repository的设计。
SaveOrderUseCase完全可以自己定义一个SaveOrderRepository,里面只有SaveOrder的方法,这时候RepositoryImpl依然是满足SaveOrderRepository的,可以直接拿来注入到SaveOrderUseCase中,如下图。
- DIP: 依赖倒置原则。
调用方不应该依赖于被调用方的实现,而应该依赖于接口。
指南: 依赖接口而不是具体的实现。
依赖倒置不是依赖注入,更不是自动注入。这里稍微解释一下
- 依赖倒置: 应该依赖接口而不是具体实现。
- 依赖注入: 依赖了接口以后,在运行以前必须的注入这个接口的具体实现。
- 自动注入: 根据接口类型,名字等,通过技术手段找到合适的对象注入。
在上面的接口隔离的例子中就是遵守了依赖倒置的,我们可以看到usecase里面没有依赖具体的RepositoryImpl,而是依赖了Repository接口。
go优势:可以倒置没有接口的实现
加入Repository是一个外部的包,而且这个包同时实现了SaveOrder和SearchOrder两个方法,但是没有定义任何接口,这时候go语音依然是可以拿来使用的,如下图。只需要依赖方自己定义一个SaveOrderRepository方法即可。实时上,通过自己在包内定义接口的方式,可以保持非常好的“正交性”,让每个包直接减少任何依赖,包括接口层面的依赖。