1 简介

1.1 定义

不要存在多于一个导致类变更的原因。

1.2 特点

一个类/接口/方法只负责一项职责。

1.3 优点

  • 降低类的复杂度、提高类的可读性,提高系统的可维护性、降低变更引起的风险。
  • 类的复杂性降低,实现什么职责都有清晰明确定义
  • 可读性提高,复杂性降低,那当然可读性提高
  • 可维护性提高,可读性提高,更易维护
  • 变更引起的风险降低,变更必不可少。若接口的单一职责做好,一个接口修改只对相应的实现类有影响,对其他接口无影响。对系统扩展性、维护性都有大帮助

名字容易让大部分人理解成:一个类只干一件事,似乎很合理呀!所有程序员都知道“高内聚、低耦合”的口号,把相关代码放一起。

若随便拿个模块去问作者,这个模块是不是只做一件事,异口同声:对,只做了一件事。看来原则很通用啊,都懂,为啥还要有这样一个设计原则?

因为一开始的理解就是错的!错在把单一职责理解成有关如何组合的原则,实际关于如何分解的。

Robert Martin对单一职责的定义的变化:

  • 《敏捷软件开发:原则、实践与模式》 一个模块应该有且仅有一个变化的原因
  • 《架构整洁之道》 一个模块应该对一类且仅对一类行为者(actor)负责
  • 单一职责原则 V.S 一个类只干一件事 最大的差别就是,将变化纳入考量。

分析第一个定义:一个模块应该有且仅有一个变化的原因。 软件设计关注长期变化,拥抱变化,我们最不愿意面对却不得不面对,只因变化会产生不确定性,可能:

  • 新业务的稳定问题
  • 旧业务遭到损害而带来的问题

所以,一个模块最理想的状态是不改变,其次是少改变,它可成为一个模块设计好坏的衡量标准。

但实际开发中,一个模块频繁变化,在于能诱导它改变的原因太多!

2 案例

2.1 一群鸟!

最初的Bird类:

1package com.javaedge.design.principle.singleresp.bird;23/**4 * @author JavaEdge5 */6public class Bird {78    public void mainMoveMode(String birdName) {9        System.out.println(birdName + "用翅膀飞");10    }11}

简单测试类:

1package com.javaedge.design.principle.singleresp.bird;23/**4 * @author JavaEdge5 */6public class Test {7    public static void main(String[] args) {8        Bird bird = new Bird();9        bird.mainMoveMode("大雁");10        bird.mainMoveMode("鸵鸟");11    }12}

显然鸵鸟还用翅膀飞是错误的!于是,修改类实现:

1package com.javaedge.design.principle.singleresp.bird;23/**4 * @author JavaEdge5 */6public class Bird {78    public void mainMoveMode(String birdName) {9        if ("鸵鸟".equals(birdName)) {10            System.out.println(birdName + "用脚走");11        } else {12            System.out.println(birdName + "用翅膀飞");13        }14    }15}16

这种设计依旧很low,总不能一味堆砌 if/else 添加鸟类!

结合该业务逻辑,考虑分别实现类职责,即根据单一原则创建两种鸟类即可:

1package com.javaedge.design.principle.singleresp.bird;23/**4 * 飞鸟5 *6 * @author JavaEdge7 */8public class FlyBird {910    public void mainMoveMode(String birdName) {11        System.out.println(birdName + "用翅膀飞");12    }13}
1package com.javaedge.design.principle.singleresp.bird;23/**4 * 走鸟5 *6 * @author JavaEdge7 */8public class WalkBird {910    public void mainMoveMode(String birdName) {11        System.out.println(birdName + "用脚走");12    }13}
1package com.javaedge.design.principle.singleresp.bird;23/**4 * @author JavaEdge5 */6public class Test {7    public static void main(String[] args) {8        FlyBird flyBird = new FlyBird();9        flyBird.mainMoveMode("大雁");1011        WalkBird walkBird = new WalkBird();12        walkBird.mainMoveMode("鸵鸟");13    }14}

2.2 课程案例

最初的课程接口有两个职责,耦合过大:

1package com.javaedge.design.principle.singleresp.course;23/**4 * @author JavaEdge5 */6public interface ICourse {7    8    // 一类职责9    /**10     * 获取课程名11     *12     * @return 课程名13     */14    String getCourseName();1516    /**17     * 获取课程视频18     *19     * @return 课程视频20     */21    byte[] getCourseVideo();2223    // 二类职责24    /**25     * 学习课程26     */27    void studyCourse();2829    /**30     * 退出课程31     */32    void refundCourse();3334}

按职责拆分:

1package com.javaedge.design.principle.singleresp.course;23/**4 * @author JavaEdge5 */6public interface ICourseContent {78    /**9     * 获取课程名10     *11     * @return 课程名12     */13    String getCourseName();1415    /**16     * 获取课程视频17     *18     * @return 课程视频19     */20    byte[] getCourseVideo();21}22
package com.javaedge.design.principle.singleresp.course;

/**
 * @author JavaEdge
 */
public interface ICourseManager {

    /**
     * 学习课程
     */
    void studyCourse();

    /**
     * 退出课程
     */
    void refundCourse();
}

2.3 用户管理

用户、机构、角色管理模块基本都是RBAC模型(Role-Based Access Control,基于角色的访问控制,通过分配和取消角色来完成用户权限的授予和取消,使动作主体(用户)与资源的行为(权限)分离)。

对于用户管理、修改用户的信息、增加机构(一个人属于多个机构)、增加角色等,用户有这么多的信息和行为要维护,我们就把这些写到一个接口中,都是用户管理类:

用户信息维护类图:

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_java

问题

用户属性和用户行为严重耦合!这个接口确实设计得一团糟,应当:

  • 用户信息抽取成一个BO(Business Object,业务对象)
  • 用户行为抽取成一个Biz(Business Logic,业务逻辑)

职责划分后的类图:

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_ci_02

重新拆分成两个接口:

  • IUserBO,负责用户的属性,职责就是收集和反馈用户的属性信息
  • IUserBiz,负责用户的行为,完成用户信息的维护和变更

面向接口编程,所以产生UserInfo对象后,可将其当IUserBO接口使用,也可当IUserBiz接口使用,取决于使用场景:

  • 获得用户信息,就当IUserBO的实现类
  • 维护用户的信息,就当IUserBiz的实现类
IUserInfo userInfo = new UserInfo();
// 我要赋值,就认为它是一个纯粹的BO
IUserBO userBO = (IUserBO)userInfo;
userBO.setPassword("abc");

// 我要执行动作,就认为是一个业务逻辑类
IUserBiz userBiz = (IUserBiz)userInfo;
userBiz.deleteUser();

拆分后,问题就解决了

为何要将一个接口拆分成两个?

实际使用中,更倾向使用两个不同的类或接口:

  • IUserBO
  • IUserBiz

项目中常用的SRP类图:

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_java_03

以上我们把一个接口拆分成两个接口的动作,就是依赖SRP,单一职责原则的定义:应有且仅有一个原因,引起类的变更。

2.4 电话通话

通话有4个过程:拨号、通话、回应、挂机。

原始接口

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_ci_04

package com.javaedge.design.principle.singleresp.phone;

/**
 * @author JavaEdge
 * @date 2021/7/5
 */
public interface IPhone {

    /**
     * 拨通电话
     * @param phoneNumber 手机号
     */
    void dial(String phoneNumber);

    /**
     * 通话
     * @param o 对象
     */
    void chat(Object o);

    /**
     * 通话完毕,挂电话
     */
    void hangup();
}

该接口接近完美,SRP要求一个接口或类只有一个原因引起变化,即一个接口或类只有一个职责,它就负责一件事情。看上面接口:

  • 只负责一件事?
  • 只有一个原因引起变化?

好像都不是!IPhone包含俩职责:

  • 协议管理:dial()、hangup()实现的是协议管理,分别负责拨号接通和挂机
  • 数据传送:chat()实现数据的传送,把说的话转换成模拟信号或数字信号传递到对方,再把对方传递过来的信号还原成语言

协议管理的变化会引起这个接口或实现类的变化吗?会! 那数据传送(电话不仅可以通话,还可上网!)的变化会引起这个接口或实现类的变化吗?会!

这里有两个原因都引起了类变化。这两个职责会相互影响吗?

  • 电话拨号,我只要能接通就成,不管是电信的还是联通的协议
  • 电话连接后还关心传递的是什么数据吗?

分析发现类图上的IPhone接口包含两个职责,这两个职责的变化不相互影响,考虑拆分成两个接口。

职责分明的电话类图

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_java_05

完全满足SRP,每个接口职责分明,结构清晰,但肯定不会采用。一个 Phone类要把ConnectionManager、DataTransfer组合才能使用。组合是一种强耦合关系,共同生命周期,这样强耦合不如使用接口实现,而且还多了俩类。

再修改类图

简洁清晰、职责分明的电话类图:

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_java_06

一个类实现了两个接口,把两个职责融合在一个类中。 你可能说Phone有两个原因引起变化呀!是,但别忘我们是面向接口编程,我们对外公布的是接口而非实现类。而且,若真要实现类的SRP,还必须使用组合模式,这会引起类间耦合过重、类的数量增加等问题,人为增加设计复杂性。

SRP最难划分:职责

一个职责一个接口,但“职责”无法量化,一个类到底要负责哪些职责?这些职责该怎么细化?细化后是否都要有一个接口或类?

从实际项目考虑,功能上,定义一个IPhone接口也没错,实现了电话功能,而且设计简单,仅仅一个接口一个实现类。项目要考虑可变因素、不可变因素及相关的收益成本比率,因此设计一个IPhone接口也可能没错。

但若纯从“学究”理论上分析就有问题,有两个可变的原因放到了一个接口,就为以后变化带来风险。若以后模拟电话升级到数字电话,我们提供的接口IPhone是不是要修改了?接口修改对其他的Invoker类是不是有很大影响?

SRP提出编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但“职责”和“变化原因”都不可度量,因项目、环境而异。

2.5 项目管理

开发一个项目管理工具,可能设计如下用户类,有用户信息管理、项目管理:

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_用户管理_07

新需求:每个用户都能设置电话号码,于是新增方法

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_用户管理_08

又来新需求:查看一个用户加入了多少个项目

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_ci_09

就这样,几乎每个和用户沾边的需求,你都改user类,导致:

  • User类一直膨胀
  • 内部实现越来越复杂

这个类变动的频繁程度显然不理想,因为诱导变动的需求太多:

  • 为什么要增加电话号码? 用户管理的需求。用户管理的需求还会有很多,比如,用户实名认证、用户组织归属等
  • 为什么要查看用户加入多少项目? 项目管理的需求。项目管理的需求还会有很多,比如,团队管理、项目权限等。

两种完全不同的需求,但你都改同一个类,所以,User类无法稳定。最好拆分不同需求引起的变动。用户管理、项目管理两种不同需求,User拆成两个类:

  • 用户管理类需求放到User
  • 项目管理类的需求放到Member

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_java_10

如此二者各自变动的理由就少了:

  • 用户管理的需求只需调整User类
  • 项目管理的需求只需调整Member类

变化来源

上面做法类似分离关注点。

要更好理解SRP,关键就是分离不同关注点。该案例分离的是不同的业务关注点。所以,理解SRP奥义在于理解分离关注点。

分离关注点,发现的关注点越多越好,粒度越小越好。你能看到的关注点越多,就能构建更多类,但每个类的规模相应越小,与之相关需求变动也越少,能稳定几率越大。

代码库里稳定的类越多越好。

将这种思路推演到极致,那一个类就应该只有一个方法,这样,它受到影响最小。

的确如此,但实际项目,一个类通常都不只一个方法,要求所有人都做到极致,不现实。

那应将哪些内容组织一起?

这就需要考虑SRP定义的升级版,即第二个定义:一个模块应该对一类且仅对一类行为者负责。若第一个定义将变化纳入考量,则升级版定义则将变化的来源纳入考量。

需求为什么会改变?

因为有各种人提需求,不同人提的需求关注点不同。 关心用户管理和关心项目管理的可能是两种不同角色的人。两件不同的事,到了代码,却混在一起,这显然不合理。 所以,分开才是一个好选择:

  • 用户管理的人,我和他们聊User
  • 项目管理的人,讨论Member

康威定律:一个组织设计出的系统,其结构受限于其组织的沟通结构。

Robert Martin说,SRP是基于康威定律的推论:一个软件系统的最佳结构高度依赖使用这个软件的组织的内部结构。 若软件结构不能够与组织结构对应,就会带来一系列麻烦。实际上,当我们更新对SRP的理解,你会发现,它的应用范围不仅可放在类这个级别,也可放到更大级别。

某交易平台有个关键模型:手续费率,交易一次按xx比例收佣金。平台可以利用手续费率做不同的活动,比如,给一些人比较低的手续费率,鼓励他们来交易,不同的手续费率意味着对不同交易行为的鼓励。

  • 对运营人员 手续费率是一个可以玩出花的东西
  • 对交易系统而言 稳定高效是重点。显然,经常修改的手续费率和稳定的系统之间存在矛盾。

这是两类不同行为者。所以,设计时,把手续费率设置放到运营子系统,而交易子系统只负责读取手续费率:

  • 当运营子系统修改手续费率,会把最新结果更新到交易子系统
  • 至于各种手续费率设置的花样,交易子系统根本无需关心

SRP还能指导我们在不同的子系统之间进行职责分配。所以,单一职责原则这个看起来最简单的原则,实际上也蕴含着很多值得挖掘的内容。 要想理解好单一职责原则:

  • 理解封装,把什么样的内容放到一起
  • 理解分离关注点,把不同的内容拆分开来
  • 理解变化的来源,把不同行为者负责的代码放到不同地方

就可更好理解函数要小,每个函数承担的职责要单一,才能稳定。

4 单一且快乐

对于:

  • 接口,设计时一定要单一
  • 但对于实现类就需要多方面考虑

生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分细分类的职责也会人为地增加系统的复杂性。本来一个类可以实现的行为硬要拆成两个类,然后再使用聚合或组合的方式耦合在一起,人为制造了系统的复杂性。所以原则是死的,人是活的。

单一职责原则很难体现在项目

国内的技术人员地位和话语权都是最低的,在项目中需要考虑环境、工作量、人员的技术水平、硬件的资源情况等,最终妥协经常违背单一职责原则。

单一职责适用于接口、类,同时也适用于方法。一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个方法放到“修改用户信息”方法中,这个方法的颗粒度很粗.

  • 一个方法承担多个职责
  • 软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_用户管理_11

在IUserManager中定义了一个方法changeUser,根据传递的类型不同,把可变长度参数changeOptions修改到userBO这个对象上,并调用持久层的方法保存到数据库中。

这种代码看到,直接要求其重写即可:方法职责不清晰,不单一,不要让别人猜测这个方法可能是用来处理什么逻辑的。

比较好的设计如下:

一个方法承担一个职责

软件设计原则(1)-单一职责原则(Single Responsibility Principle,SRP)_ci_12

若要修改用户名称,就调用changeUserName方法 要修改家庭地址,就调用changeHomeAddress方法 要修改单位电话,就调用changeOfficeTel方法 每个方法的职责非常清晰明确,不仅开发简单,而且日后的维护也非常容易。

5 最佳实践

SRP确实受非常多因素的制约,纯理论地来讲,这个原则很好,但现实有很多难处,须考虑项目工期、成本、人员技术水平、硬件情况、网络情况甚至有时候还要考虑政府政策、垄断协议等因素。

推荐:

  • 接口一定要做到单一职责
  • 类的设计尽量做到只有一个原因引起变化

参考

  • 《设计模式之蝉》