单一职责原则的英文名称是 Single Responsibility Principle,简称是 SRP。
这个原则因为它对职责的定义,什么是类的职责,以及怎么划分类的职责,而备受争议。
只要做过项目,肯定要接触到用户、机构、角色管理这些模块,基本上使用的都是 RBAC 模型(Role-Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予与取消,使动作主体(用户)和资源的行为(权限)分离),确实是一个很好的解决办法。
来看一个类图:
这个类图,很容易可以看出问题所在:用户的属性和用户的行为没有分开,这是一个特别严重的错误。
按照把用户的信息抽取成一个 BO(Business Object,业务对象),把用户的行为抽取成一个 Biz(Business Logic,业务逻辑)的思路对上面的类图进行修正,得如下类图:
重新拆分成两个接口,IUserBO 负责用户的属性(set/get),简单地说,IUserBO 的职责就是收集和反馈用户的属性信息;IUserBiz 负责用户的行为,完成用户信息的维护和变更。
Java 中接口是允许多继承的,那么,IUserInfo 就拥有 IUserBO 和 IUserBiz 的所有抽象方法,还包括自己的抽象方法,而 Java 多态还能让我们通过强制类型转换,根据具体的情景将同一个 IUserInfo 的对象转换成 IUserBO 或者 IUserBiz 对象进行操作:
1 ... ... 2 IUserInfo userInfo = new UserInfo(); 3 // 当我们要对它进行赋值操作时,就视它为一个纯粹的 BO 对象 4 IUserBO userBO = (IUserBO) userInfo(); 5 userBO.setPassword("123456"); 6 // 当我们要对它进行业务行为时,就视它为一个纯粹的 Biz 对象 7 IUserBiz userBiz = (IUserBiz) userInfo(); 8 userBiz.deleteUser(); 9 ... ...
确实可以如此,问题解决了,让我们来分析一下刚才的操作,为什么要把一个接口拆分成两个呢?其实,在实际的使用中,我们更加倾向于使用两个不同的类或接口:一个是 IUserBO,另一个是 IUserBiz,各自有各自的实现类 UserBO 和 UserBiz,如图:
以上我们把一个接口分成两个接口的动作,就是依赖了单一职责原则,单一职责的定义是:应该有且仅有一个原因引起类的变更。
SRP 的原话是:There should never be more than one reason for a class to change.
这句话初中生都能看懂,不多说,看懂是一回事,做出来就是另外一回事了,上面的例子很好理解,在实际项目中大家都已经这么做了,那我们再来看看下面这个例子怎么理解:
电话这玩意儿,如今每个人都离不开,电话通话的时候有 4 个过程发生:拨号、通话、回应和挂机,我们写一个接口,类图如下:
接下来模拟一个打电话的过程:
public interface IPhone{ // 拨通电话 public void dial(String phoneNumber); // 通话 public void chat(Object o); // 挂断电话 public void hangup(); }
这个接口看上去“近乎”完美,但仔细看并分析,发现它并没有遵守 SRP 原则,这个接口的职责并不单一!
上面的 IPhone 接口包含了两个职责:一个是协议管理,一个是数据传送,dial() 和 hangup() 实现的是协议管理,分别负责拨号接通和挂断;chat() 实现的是数据的传送,把我们说的话转换成模拟信号或数字信号传递到对方,然后再把传送过来的信号转换成声音。
我们来考虑一个问题:协议管理的变化会引起这个接口或实现类的变化吗?拿数据传送的变化会引起这个接口或实现类的变化吗?答案是:都会!
我们再来考虑一个问题:这两个职责会相互影响吗?电话拨号,我们只管拨没拨通,甭管是电信还是网通的协议;电话连接后还关心传递的是什么数据吗?
通过上面两个问题的分析,我们发现类图上的 IPhone 接口包含了两个职责,而且这两个职责的变化互不影响,那就考虑拆分成两个接口,其类图如下:
这个类图看上有点复杂,完全满足了单一职责原则的要求,每个接口职责分明,结构清晰,但是我相信你在设计的时候肯定不会采用这种方式,一个收集类要把 ConnectionManager 和 DataTransfer 组合在一起才能使用,组合是一种强耦合关系,你和我都有共同的生命周期,这样的强耦合关系用接口实现的方式替代会更好,接口实现的方式还不会多了两个类,增加类的复杂性,经过思考,得如下类图:
或者直接一个实现类实现两个接口:
这样的设计才是完美的,一个类实现了两个接口,把两个职责融合在一个类中。
你可能会觉得这样的话 Phone 类就有两个原因引起变化了,没错,但是我们是面向接口编程,我们对外公布的是接口而不是实现类,接口的职责保持单一即可。
如果真的要实现类的单一职责,就必须使用上面说过的组合模式,但这会引起类间耦合过重、类的数量增加等问题,人为地增加了设计地复杂性。
通过上面的例子,我们来总结一下单一职责原则有什么好处:
- 类的复杂性降低了,实现什么职责都可以在接口中有明确清晰的定义;
- 复杂性降低,当然可读性就提高了;
- 可读性提高,当然就更容易维护了;
- 变更引起的风险降低。
变更是不可避免的,如果 SRP 原则做得好,一个接口修改只对相应得实现类有影响,对其他接口无影响,这对系统的可扩展性、维护性都有非常大的帮助。
看过电话这个例子后,是不是想反思一下:我以前的设计是不是有点问题了?
不,不是的,不要怀疑自己的技术能力,单一职责原则最难划分的就是职责。
一个职责一个接口,但问题是“职责”没有一个量化的标准,一个类到底要负责哪些职责?这些职责该怎么细化?细化后是否都要有一个接口或类?
这些都需要从实际的项目去考虑,从功能上来说,定义一个 IPhone 接口也没有错,实现了电话的功能,而且设计还很简单,仅仅一个接口一个实现类,实际的项目我想大家都会这么设计。
项目要考虑可变因素和不可变因素,以及相关的收益成本比率,因此设计一个 IPhone 接口也可能是没有错的,但是如果纯从“学究”理论上分析就有问题了,有两个变化的原因放到了一个接口中,这就为以后的变化带来了风险。
如果以后模拟电话升级到数字电话(现实是早就实现了),我们提供的接口 IPhone 是不是就要修改了?接口修改对其他的 Invoker 类是不是有很大的影响?
单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可度量的,因项目而异,因环境而异。
对于接口,我们在设计的时候一定要做到 SRP,但是对于实现类就需要多方面考虑了,生搬硬套 SRP 原则会引起类数量的剧增,给维护带来非常多的麻烦,而且过分细分类的职责,也会人为地增加系统的复杂性,本来一个类可以实现的行为硬要拆成两个类,然后再使用聚合或组合的方式耦合在一起,人为制造了系统地复杂性。
所以,原则是死的,人是活的,这句话很有道理。
单一职责原则很难在项目中得到体现,非常难,为什么?
在国内,技术人员的地位和话语权都比较低,因此在项目中需要考虑环境,考虑工作量,考虑人员的技术水平,考虑硬件的资源情况,等等,最终妥协的结果是经常违背 SRP 原则。
而且,我们中华文明就有很多属于混合型的产物,比如筷子,我们可以把筷子当作刀来使用,分割食物;还可以当作叉使用,把食物从盘子中移动到口中。
而在西方文化中,刀是刀,叉是叉,你去吃西餐的时候这两样肯定是都有的,刀就是切割食物,叉就是固定食物或者移动食物,分工很明确。
SRP 适用于接口、类,同时也适用于方法,什么意思呢?一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个“修改用户信息”方法的颗粒度很粗,如图:
在 IUserManager 中定义了一个方法 changeUser,根据传递的类型不同,把可变长度参数 changeOptions 修改到 userBO 这个对象上,并调用持久层的方法保存到数据库中。
在我的项目组中,如果有人写了这样一个方法,我不管他写了多少程序,花了多少功夫,一律重写!
原因很简单:方法职责不清晰,不单一,不要让别人猜测这个方法可能使用来处理什么逻辑的,比较好的设计如图:
通过类图可知,如果要修改用户名称,就调用 changeUserName 方法;要修改家庭地址,就调用 changeHomeAddress 方法;要修改单位电话,就调用 changeOfficeTel 方法。
每个方法的职责非常清晰,不仅开发简单,而且日后的维护也非常容易,大家可以逐渐养成这样的习惯。
“阅读到这里,可能有人会问我,你写的是类的设计原则吗?通篇都在说接口的单一职责,类的单一职责你都违背了呀!
这个还真是,我的本意是想把这个原则讲清楚,类的单一职责嘛,这个很简单,但当我回头写的时候,发现并不是这么回事,翻看了以前的一些设计和代码,基本上拿得出手的类设计都是与单一职责相违背的。
静下心来回忆,发觉每一个类这样设计都是有原因的。
我查阅了 WikiPedia、OODesign 等几个网站,专家和我也有类似的经验,基本上类的单一职责都用了类似的一句话来说:
‘This is sometimes hard to see',这句话翻译过来就是讲:这个有时候很难说。
是的,类的单一职责确实受非常多的因素制约,纯理论来讲,这个原则是非常优秀的,但是现实有现实的难处,你必须去考虑项目的工期、成本、人员技术水平、硬件情况、网络情况甚至有时候还要考虑政府政策、垄断协议等因素。
比如,2004 年我做过一个项目,做加密处理的,甲方就甩过来一句话,你什么都不用管,调用这个 API 就可以了,不用考虑什么传输协议、异常处理、安全连接等。所以我们就直接使用了 JNI 与加密厂商提供的 API 通信,什么单一职责原则,根本就不用考虑,因为对方不公布通信接口和异常判断。
对于单一职责原则,我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。”
----摘自《设计模式之禅》 2020.06.17